diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cae63bcb..72692a17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: distribution: 'corretto' - name: Gradle 셋업, 빌드, 캐시 - uses: burrunan/gradle-cache-action@3bf23b8dd95e7d2bacf2470132454fe893a178a1 + uses: burrunan/gradle-cache-action@c15634bb25b7284dc084f38dff4e838048b7feaf with: arguments: build properties: | diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index c6ec672c..ffb0d6fd 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -58,7 +58,7 @@ jobs: run: cd /home/runner/work/ListyWave-back/ListyWave-back/ - name: Gradle 셋업, 빌드, 캐싱 - uses: burrunan/gradle-cache-action@3bf23b8dd95e7d2bacf2470132454fe893a178a1 + uses: burrunan/gradle-cache-action@c15634bb25b7284dc084f38dff4e838048b7feaf with: arguments: bootJar diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index 9b83a361..b13b2b5f 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -58,7 +58,7 @@ jobs: run: cd /home/runner/work/ListyWave-back/ListyWave-back/ - name: Gradle 셋업, 빌드, 캐싱 - uses: burrunan/gradle-cache-action@3bf23b8dd95e7d2bacf2470132454fe893a178a1 + uses: burrunan/gradle-cache-action@c15634bb25b7284dc084f38dff4e838048b7feaf with: arguments: bootJar diff --git a/build.gradle b/build.gradle index 4df9bf86..08b189b7 100644 --- a/build.gradle +++ b/build.gradle @@ -27,6 +27,7 @@ dependencies { annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" testImplementation 'org.springframework.boot:spring-boot-starter-test' implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-validation' // jwt implementation 'io.jsonwebtoken:jjwt-api:0.12.4' diff --git a/src/main/java/com/listywave/admin/Admin.java b/src/main/java/com/listywave/admin/Admin.java new file mode 100644 index 00000000..6a6d61ff --- /dev/null +++ b/src/main/java/com/listywave/admin/Admin.java @@ -0,0 +1,45 @@ +package com.listywave.admin; + +import static com.listywave.common.exception.ErrorCode.INVALID_ACCESS; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import com.listywave.common.exception.CustomException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = PROTECTED) +@AllArgsConstructor +public class Admin { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column(nullable = false, length = 40, unique = true) + private String ip; + + @Column(nullable = false, length = 50, unique = true) + private String account; + + @Column(nullable = false, length = 50) + private String password; + + public void validatePassword(String password) { + if (this.password.equals(password)) { + return; + } + throw new CustomException(INVALID_ACCESS); + } + + public void update(String password) { + this.password = password; + } +} diff --git a/src/main/java/com/listywave/admin/AdminController.java b/src/main/java/com/listywave/admin/AdminController.java new file mode 100644 index 00000000..133deb45 --- /dev/null +++ b/src/main/java/com/listywave/admin/AdminController.java @@ -0,0 +1,55 @@ +package com.listywave.admin; + +import com.listywave.common.auth.Auth; +import com.listywave.common.exception.CustomException; +import static com.listywave.common.exception.ErrorCode.RESOURCE_NOT_FOUND; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.core.env.Environment; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@Controller +@RequiredArgsConstructor +public class AdminController { + + private final Environment environment; + private final AdminService adminService; + + @GetMapping("/admin") + String redirectLoginPage(HttpServletRequest request) { + String clientIp = request.getHeader("X-Forwarded-For"); + + if (adminService.isValidIp(clientIp)) { + String[] activeProfiles = environment.getActiveProfiles(); + + for (String activeProfile : activeProfiles) { + switch (activeProfile) { + case "dev", "default" -> { + return "redirect:http://localhost:3000/admin/login"; + } + case "prod" -> { + return "redirect:https://listywave.com/admin/login"; + } + } + } + } + throw new CustomException(RESOURCE_NOT_FOUND); + } + + @PostMapping("/admin/login") + ResponseEntity login(@RequestBody AdminLoginRequest adminLoginRequest) { + AdminLoginResponse result = adminService.login(adminLoginRequest.account(), adminLoginRequest.password()); + return ResponseEntity.ok(result); + } + + @PutMapping("/admin") + ResponseEntity updateInfo(@Auth Long adminId, @RequestBody AdminUpdateRequest request) { + adminService.update(adminId, request.password()); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/listywave/admin/AdminLoginRequest.java b/src/main/java/com/listywave/admin/AdminLoginRequest.java new file mode 100644 index 00000000..09747d42 --- /dev/null +++ b/src/main/java/com/listywave/admin/AdminLoginRequest.java @@ -0,0 +1,7 @@ +package com.listywave.admin; + +public record AdminLoginRequest( + String account, + String password +) { +} diff --git a/src/main/java/com/listywave/admin/AdminLoginResponse.java b/src/main/java/com/listywave/admin/AdminLoginResponse.java new file mode 100644 index 00000000..23430b58 --- /dev/null +++ b/src/main/java/com/listywave/admin/AdminLoginResponse.java @@ -0,0 +1,7 @@ +package com.listywave.admin; + +public record AdminLoginResponse( + String accessToken, + String refreshToken +) { +} diff --git a/src/main/java/com/listywave/admin/AdminRepository.java b/src/main/java/com/listywave/admin/AdminRepository.java new file mode 100644 index 00000000..a9f576c3 --- /dev/null +++ b/src/main/java/com/listywave/admin/AdminRepository.java @@ -0,0 +1,18 @@ +package com.listywave.admin; + +import static com.listywave.common.exception.ErrorCode.RESOURCE_NOT_FOUND; + +import com.listywave.common.exception.CustomException; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AdminRepository extends JpaRepository { + + default Admin getById(Long id) { + return findById(id).orElseThrow(() -> new CustomException(RESOURCE_NOT_FOUND)); + } + + boolean existsByIp(String ip); + + Optional findByAccount(String account); +} diff --git a/src/main/java/com/listywave/admin/AdminService.java b/src/main/java/com/listywave/admin/AdminService.java new file mode 100644 index 00000000..b4a9f6ed --- /dev/null +++ b/src/main/java/com/listywave/admin/AdminService.java @@ -0,0 +1,51 @@ +package com.listywave.admin; + +import static com.listywave.common.exception.ErrorCode.INVALID_ACCESS; + +import com.listywave.auth.application.domain.JwtManager; +import com.listywave.common.encrypt.Sha256Cipher; +import com.listywave.common.exception.CustomException; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class AdminService { + + private final JwtManager jwtManager; + private final Sha256Cipher sha256Cipher; + private final AdminRepository adminRepository; + + @Transactional(readOnly = true) + public boolean isValidIp(String ip) { + return adminRepository.existsByIp(ip); + } + + @Transactional(readOnly = true) + public AdminLoginResponse login(String account, String password) { + Optional optionalAdmin = adminRepository.findByAccount(account); + if (optionalAdmin.isPresent()) { + Admin admin = optionalAdmin.get(); + + // 암호화 적용으로 인해, 임시로 작성해둔 코드입니다. + // 모든 어드민이 암호를 변경하면 if 조건식만 제거합니다. + if (!password.equals("1234")) { + admin.validatePassword(sha256Cipher.encrypt(password)); // 해당 라인은 제거하지 않습니다. + } + + String accessToken = jwtManager.createAdminAccessToken(admin.getId()); + String refreshToken = jwtManager.createAdminRefreshToken(admin.getId()); + return new AdminLoginResponse(accessToken, refreshToken); + } + throw new CustomException(INVALID_ACCESS); + } + + public void update(Long adminId, String password) { + Admin admin = adminRepository.getById(adminId); + String encryptedNewPassword = sha256Cipher.encrypt(password); + admin.update(encryptedNewPassword); + } +} diff --git a/src/main/java/com/listywave/admin/AdminUpdateRequest.java b/src/main/java/com/listywave/admin/AdminUpdateRequest.java new file mode 100644 index 00000000..88762a7d --- /dev/null +++ b/src/main/java/com/listywave/admin/AdminUpdateRequest.java @@ -0,0 +1,6 @@ +package com.listywave.admin; + +public record AdminUpdateRequest( + String password +) { +} diff --git a/src/main/java/com/listywave/alarm/application/domain/Alarm.java b/src/main/java/com/listywave/alarm/application/domain/Alarm.java index 08d466c4..a897e7c4 100644 --- a/src/main/java/com/listywave/alarm/application/domain/Alarm.java +++ b/src/main/java/com/listywave/alarm/application/domain/Alarm.java @@ -6,11 +6,16 @@ import static jakarta.persistence.TemporalType.TIMESTAMP; import static lombok.AccessLevel.PROTECTED; +import com.listywave.list.application.domain.comment.Comment; +import com.listywave.list.application.domain.list.ListEntity; +import com.listywave.list.application.domain.reply.Reply; +import com.listywave.notice.application.domain.Notice; import com.listywave.user.application.domain.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EntityListeners; import jakarta.persistence.Enumerated; +import jakarta.persistence.ForeignKey; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; @@ -37,17 +42,27 @@ public class Alarm { private Long id; @ManyToOne(fetch = LAZY) - @JoinColumn(name = "send_user_id") - private User user; + @JoinColumn(name = "send_user_id", nullable = false) + private User sendUser; - @Column(nullable = false) + @Column(nullable = true) private Long receiveUserId; - @Column(nullable = true) - private Long listId; + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "list_id", nullable = true, foreignKey = @ForeignKey(name = "alarm_list_fk")) + private ListEntity list; - @Column(nullable = true) - private Long commentId; + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "comment_id", nullable = true, foreignKey = @ForeignKey(name = "alarm_comment_fk")) + private Comment comment; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "reply_id", nullable = true, foreignKey = @ForeignKey(name = "alarm_reply_fk")) + private Reply reply; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "notice_id", nullable = true, foreignKey = @ForeignKey(name = "alarm_notice_fk")) + private Notice notice; @Column(nullable = false) @Enumerated(value = STRING) @@ -61,7 +76,7 @@ public class Alarm { @Column(updatable = false) private LocalDateTime createdDate; - public void readAlarm() { + public void check() { this.isChecked = true; } } diff --git a/src/main/java/com/listywave/alarm/application/domain/AlarmCreateEvent.java b/src/main/java/com/listywave/alarm/application/domain/AlarmCreateEvent.java new file mode 100644 index 00000000..e2d39c48 --- /dev/null +++ b/src/main/java/com/listywave/alarm/application/domain/AlarmCreateEvent.java @@ -0,0 +1,104 @@ +package com.listywave.alarm.application.domain; + +import static com.listywave.alarm.application.domain.AlarmType.COLLECT; +import static com.listywave.alarm.application.domain.AlarmType.COMMENT; +import static com.listywave.alarm.application.domain.AlarmType.FOLLOW; +import static com.listywave.alarm.application.domain.AlarmType.NOTICE; +import static com.listywave.alarm.application.domain.AlarmType.REACTION; +import static com.listywave.alarm.application.domain.AlarmType.REPLY; + +import com.listywave.list.application.domain.comment.Comment; +import com.listywave.list.application.domain.list.ListEntity; +import com.listywave.list.application.domain.reply.Reply; +import com.listywave.mention.Mention; +import com.listywave.notice.application.domain.Notice; +import com.listywave.user.application.domain.User; +import java.util.List; +import lombok.Builder; + +@Builder +public record AlarmCreateEvent( + User publisher, + Long listenerId, + ListEntity list, + Comment comment, + Reply reply, + List mentions, + Notice notice, + AlarmType alarmType +) { + + public Alarm toEntity() { + return Alarm.builder() + .sendUser(publisher) + .receiveUserId(listenerId) + .list(list) + .comment(comment) + .reply(reply) + .type(alarmType) + .notice(notice) + .isChecked(false) + .build(); + } + + public static AlarmCreateEvent comment(ListEntity list, Comment comment, List mentions) { + return AlarmCreateEvent.builder() + .publisher(comment.getUser()) + .listenerId(list.getUser().getId()) + .list(list) + .comment(comment) + .mentions(mentions) + .alarmType(COMMENT) + .build(); + } + + public static AlarmCreateEvent reply(Comment comment, Reply reply, List mentions) { + return AlarmCreateEvent.builder() + .publisher(reply.getUser()) + .listenerId(comment.getUser().getId()) + .list(comment.getList()) + .comment(comment) + .reply(reply) + .mentions(mentions) + .alarmType(REPLY) + .build(); + } + + public static AlarmCreateEvent follow(User publisher, User listenerUser) { + return AlarmCreateEvent.builder() + .publisher(publisher) + .listenerId(listenerUser.getId()) + .alarmType(FOLLOW) + .build(); + } + + public static AlarmCreateEvent collect(User publisher, ListEntity list) { + return AlarmCreateEvent.builder() + .publisher(publisher) + .listenerId(list.getUser().getId()) + .list(list) + .alarmType(COLLECT) + .build(); + } + + public static AlarmCreateEvent notice(User user, Notice notice) { + return AlarmCreateEvent.builder() + .publisher(user) + .notice(notice) + .alarmType(NOTICE) + .build(); + } + + public static AlarmCreateEvent reaction(User publisher, ListEntity list) { + return AlarmCreateEvent.builder() + .publisher(publisher) + .listenerId(list.getUser().getId()) + .list(list) + .alarmType(REACTION) + .build(); + } + + public boolean isToMyself() { + return this.publisher.isSame(listenerId); + } +} diff --git a/src/main/java/com/listywave/alarm/application/domain/AlarmDeleteEvent.java b/src/main/java/com/listywave/alarm/application/domain/AlarmDeleteEvent.java new file mode 100644 index 00000000..3cfd6177 --- /dev/null +++ b/src/main/java/com/listywave/alarm/application/domain/AlarmDeleteEvent.java @@ -0,0 +1,22 @@ +package com.listywave.alarm.application.domain; + +import static com.listywave.alarm.application.domain.AlarmType.COMMENT; +import static com.listywave.alarm.application.domain.AlarmType.REPLY; + +import com.listywave.list.application.domain.comment.Comment; +import com.listywave.list.application.domain.reply.Reply; + +public record AlarmDeleteEvent( + Comment comment, + Reply reply, + AlarmType type +) { + + public static AlarmDeleteEvent comment(Comment comment) { + return new AlarmDeleteEvent(comment, null, COMMENT); + } + + public static AlarmDeleteEvent reply(Reply reply) { + return new AlarmDeleteEvent(null, reply, REPLY); + } +} diff --git a/src/main/java/com/listywave/alarm/application/domain/AlarmEvent.java b/src/main/java/com/listywave/alarm/application/domain/AlarmEvent.java deleted file mode 100644 index dd033665..00000000 --- a/src/main/java/com/listywave/alarm/application/domain/AlarmEvent.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.listywave.alarm.application.domain; - -import static com.listywave.alarm.application.domain.AlarmType.COLLABORATOR; -import static com.listywave.alarm.application.domain.AlarmType.COLLECT; -import static com.listywave.alarm.application.domain.AlarmType.COMMENT; -import static com.listywave.alarm.application.domain.AlarmType.FOLLOW; -import static com.listywave.alarm.application.domain.AlarmType.REPLY; -import static com.listywave.common.exception.ErrorCode.CANNOT_SEND_OWN_ALARM; - -import com.listywave.collaborator.application.domain.Collaborator; -import com.listywave.common.exception.CustomException; -import com.listywave.list.application.domain.comment.Comment; -import com.listywave.list.application.domain.list.ListEntity; -import com.listywave.list.application.domain.reply.Reply; -import com.listywave.user.application.domain.User; -import lombok.Builder; - -@Builder -public record AlarmEvent( - User publisher, - Long listenerId, - Long listId, - Long commentId, - AlarmType alarmType -) { - - public Alarm toEntity() { - return Alarm.builder() - .user(publisher) - .receiveUserId(listenerId) - .listId(listId) - .commentId(commentId) - .type(alarmType) - .build(); - } - - public static AlarmEvent follow(User publisher, User listenerUser) { - return AlarmEvent.builder() - .publisher(publisher) - .listenerId(listenerUser.getId()) - .listId(null) - .commentId(null) - .alarmType(FOLLOW) - .build(); - } - - public static AlarmEvent collect(User publisher, ListEntity list) { - return AlarmEvent.builder() - .publisher(publisher) - .listenerId(list.getUser().getId()) - .listId(list.getId()) - .commentId(null) - .alarmType(COLLECT) - .build(); - } - - public static AlarmEvent comment(ListEntity list, Comment comment) { - return AlarmEvent.builder() - .publisher(comment.getUser()) - .listenerId(list.getUser().getId()) - .listId(list.getId()) - .commentId(comment.getId()) - .alarmType(COMMENT) - .build(); - } - - public static AlarmEvent reply(Comment comment, Reply reply) { - return AlarmEvent.builder() - .publisher(reply.getUser()) - .listenerId(comment.getUser().getId()) - .listId(comment.getList().getId()) - .commentId(comment.getId()) - .alarmType(REPLY) - .build(); - } - - public static AlarmEvent collaborator(Collaborator collaborator, User publisher) { - return AlarmEvent.builder() - .publisher(publisher) - .listenerId(collaborator.getUser().getId()) - .listId(collaborator.getList().getId()) - .commentId(null) - .alarmType(COLLABORATOR) - .build(); - } - - public void validateDifferentPublisherAndReceiver() { - if (publisher.isSame(listenerId)) { - throw new CustomException(CANNOT_SEND_OWN_ALARM); - } - } -} diff --git a/src/main/java/com/listywave/alarm/application/domain/AlarmType.java b/src/main/java/com/listywave/alarm/application/domain/AlarmType.java index b07f7945..ac14ac27 100644 --- a/src/main/java/com/listywave/alarm/application/domain/AlarmType.java +++ b/src/main/java/com/listywave/alarm/application/domain/AlarmType.java @@ -9,6 +9,9 @@ public enum AlarmType { COLLECT, COMMENT, REPLY, + MENTION, COLLABORATOR, + NOTICE, + REACTION, ; } diff --git a/src/main/java/com/listywave/alarm/application/dto/AlarmFindResponse.java b/src/main/java/com/listywave/alarm/application/dto/AlarmFindResponse.java new file mode 100644 index 00000000..f7ea3041 --- /dev/null +++ b/src/main/java/com/listywave/alarm/application/dto/AlarmFindResponse.java @@ -0,0 +1,136 @@ +package com.listywave.alarm.application.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.listywave.alarm.application.domain.Alarm; +import com.listywave.list.application.domain.comment.Comment; +import com.listywave.list.application.domain.list.ListEntity; +import com.listywave.list.application.domain.reply.Reply; +import com.listywave.mention.Mention; +import com.listywave.notice.application.domain.Notice; +import com.listywave.user.application.domain.User; +import jakarta.annotation.Nullable; +import java.time.LocalDateTime; +import java.util.List; +import lombok.Builder; + +@Builder +public record AlarmFindResponse( + Long id, + LocalDateTime createdDate, + @JsonProperty("checked") boolean isChecked, + String type, + UserDto sendUser, + @Nullable ListDto list, + @Nullable CommentDto comment, + @Nullable ReplyDto reply, + @Nullable NoticeDto notice +) { + + public static List toList(List alarms) { + return alarms.stream() + .map(alarm -> AlarmFindResponse.builder() + .id(alarm.getId()) + .createdDate(alarm.getCreatedDate()) + .isChecked(alarm.isChecked()) + .type(alarm.getType().name()) + .sendUser(UserDto.of(alarm.getSendUser())) + .list(ListDto.of(alarm.getList())) + .comment(CommentDto.of(alarm.getComment())) + .reply(ReplyDto.of(alarm.getReply())) + .notice(NoticeDto.of(alarm.getNotice())) + .build() + ).toList(); + } + + public record UserDto( + Long id, + String nickname, + String profileImageUrl + ) { + + public static UserDto of(User user) { + return new UserDto(user.getId(), user.getNickname(), user.getProfileImageUrl()); + } + } + + public record ListDto( + Long id, + String title + ) { + + public static ListDto of(@Nullable ListEntity list) { + if (list == null) { + return null; + } + return new ListDto(list.getId(), list.getTitle().getValue()); + } + } + + public record CommentDto( + Long id, + String content, + List mentions + ) { + + public static CommentDto of(@Nullable Comment comment) { + if (comment == null) { + return null; + } + return new CommentDto(comment.getId(), comment.getCommentContent(), MentionDto.toList(comment.getMentions())); + } + } + + public record ReplyDto( + Long id, + String content, + List mentions + ) { + + public static ReplyDto of(@Nullable Reply reply) { + if (reply == null) { + return null; + } + return new ReplyDto(reply.getId(), reply.getCommentContent(), MentionDto.toList(reply.getMentions())); + } + } + + public record MentionDto( + Long targetUserId, + String targetUserNickname + ) { + + public static List toList(List mentions) { + if (mentions.isEmpty()) { + return List.of(); + } + return mentions.stream() + .map(mention -> new MentionDto(mention.getUser().getId(), mention.getUser().getNickname())) + .toList(); + } + } + + @Builder + public record NoticeDto( + Long id, + String categoryCode, + String categoryViewName, + String title, + String description, + LocalDateTime createdDate + ) { + + public static NoticeDto of(@Nullable Notice notice) { + if (notice == null) { + return null; + } + return NoticeDto.builder() + .id(notice.getId()) + .categoryCode(String.valueOf(notice.getType().getCode())) + .categoryViewName(notice.getType().getViewName()) + .title(notice.getTitle().getValue()) + .description(notice.getDescription().getValue()) + .createdDate(notice.getCreatedDate()) + .build(); + } + } +} diff --git a/src/main/java/com/listywave/alarm/application/dto/AlarmListResponse.java b/src/main/java/com/listywave/alarm/application/dto/AlarmListResponse.java deleted file mode 100644 index 6b019f51..00000000 --- a/src/main/java/com/listywave/alarm/application/dto/AlarmListResponse.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.listywave.alarm.application.dto; - -import java.util.List; - -public record AlarmListResponse(List alarmList) { -} diff --git a/src/main/java/com/listywave/alarm/application/dto/AlarmReadResponse.java b/src/main/java/com/listywave/alarm/application/dto/AlarmReadResponse.java new file mode 100644 index 00000000..9bec5747 --- /dev/null +++ b/src/main/java/com/listywave/alarm/application/dto/AlarmReadResponse.java @@ -0,0 +1,6 @@ +package com.listywave.alarm.application.dto; + +public record AlarmReadResponse( + Boolean isAllChecked +) { +} diff --git a/src/main/java/com/listywave/alarm/application/dto/FindAlarmResponse.java b/src/main/java/com/listywave/alarm/application/dto/FindAlarmResponse.java deleted file mode 100644 index 163e62ae..00000000 --- a/src/main/java/com/listywave/alarm/application/dto/FindAlarmResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.listywave.alarm.application.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; -import java.time.LocalDateTime; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -public class FindAlarmResponse { - - private Long id; - private Long sendUserId; - private String nickname; - private String profileImageUrl; - private Long listId; - private Long commentId; - private String listTitle; - private String type; - @JsonProperty("checked") - private boolean isChecked; - private LocalDateTime createdDate; -} diff --git a/src/main/java/com/listywave/alarm/application/handler/AlarmEventHandler.java b/src/main/java/com/listywave/alarm/application/handler/AlarmEventHandler.java deleted file mode 100644 index 2ad3f86f..00000000 --- a/src/main/java/com/listywave/alarm/application/handler/AlarmEventHandler.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.listywave.alarm.application.handler; - -import com.listywave.alarm.application.domain.AlarmEvent; -import com.listywave.alarm.application.service.AlarmService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionalEventListener; - -@Component -@RequiredArgsConstructor -public class AlarmEventHandler { - - private final AlarmService alarmService; - - @TransactionalEventListener - public void saveAlarm(AlarmEvent alarmEvent) { - alarmService.save(alarmEvent); - } -} diff --git a/src/main/java/com/listywave/alarm/application/service/AlarmService.java b/src/main/java/com/listywave/alarm/application/service/AlarmService.java index e8b66e54..a642b73b 100644 --- a/src/main/java/com/listywave/alarm/application/service/AlarmService.java +++ b/src/main/java/com/listywave/alarm/application/service/AlarmService.java @@ -1,54 +1,177 @@ package com.listywave.alarm.application.service; -import static com.listywave.common.exception.ErrorCode.RESOURCE_NOT_FOUND; +import static com.listywave.alarm.application.domain.AlarmType.COMMENT; +import static com.listywave.alarm.application.domain.AlarmType.MENTION; +import static com.listywave.alarm.application.domain.AlarmType.NOTICE; +import static com.listywave.alarm.application.domain.AlarmType.REPLY; +import static org.springframework.transaction.annotation.Propagation.REQUIRED; +import static org.springframework.transaction.annotation.Propagation.REQUIRES_NEW; import com.listywave.alarm.application.domain.Alarm; -import com.listywave.alarm.application.domain.AlarmEvent; +import com.listywave.alarm.application.domain.AlarmCreateEvent; +import com.listywave.alarm.application.domain.AlarmDeleteEvent; import com.listywave.alarm.application.dto.AlarmCheckResponse; -import com.listywave.alarm.application.dto.AlarmListResponse; +import com.listywave.alarm.application.dto.AlarmFindResponse; import com.listywave.alarm.repository.AlarmRepository; -import com.listywave.common.exception.CustomException; +import com.listywave.list.application.domain.comment.Comment; +import com.listywave.list.application.domain.reply.Reply; +import com.listywave.list.repository.reply.ReplyRepository; +import com.listywave.mention.Mention; import com.listywave.user.application.domain.User; import com.listywave.user.repository.user.UserRepository; +import java.time.LocalDateTime; +import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionalEventListener; @Service @Transactional @RequiredArgsConstructor public class AlarmService { - private final AlarmRepository alarmRepository; private final UserRepository userRepository; + private final AlarmRepository alarmRepository; + private final ReplyRepository replyRepository; + + // TODO: 리팩터링 + @Transactional(propagation = REQUIRES_NEW) + @TransactionalEventListener(AlarmCreateEvent.class) + public void save(AlarmCreateEvent event) { + if (event.isToMyself()) { + return; + } + + if (event.alarmType().equals(REPLY)) { // 답글 타입이면 + if (event.mentions().isEmpty()) { // 아무도 멘션하지 않았다면 + // 일단 댓글 작성자에게 답글 알람 생성 + Alarm alarm = event.toEntity(); + + // 댓글에 작성한 모든 답글 작성자들에게 알람을 보낸다. + Comment comment = event.comment(); + List replies = replyRepository.findAllByComment(comment); + + List alarms = replies.stream() + .filter(reply -> !reply.getUserId().equals(event.publisher().getId())) // 본인에게 알람이 가지 않도록 한다. + .map(reply -> Alarm.builder() + .sendUser(event.publisher()) + .receiveUserId(reply.getUserId()) + .list(event.list()) + .comment(event.comment()) + .reply(reply) + .type(REPLY) // 답글 타입 ㅇㅇ + .build()) + .toList(); + alarmRepository.save(alarm); + alarmRepository.saveAll(alarms); + return; + } + + // 댓글 작성자에게 알람 생성 + Alarm alarm = event.toEntity(); + + // 멘션을 한 대상자들에게 알람 생성 + List mentions = event.mentions(); + List alarms = mentions.stream() + .filter(mention -> !mention.getUser().getId().equals(event.comment().getUserId())) // 언급의 대상이 댓글 작성자라면 제외 (우선순위) + .filter(mention -> !mention.getUser().getId().equals(event.publisher().getId())) // 언급의 대상이 본인이라면 제외 + .map(mention -> Alarm.builder() + .sendUser(event.publisher()) + .receiveUserId(mention.getUser().getId()) + .list(event.list()) + .comment(event.comment()) + .reply(event.reply()) + .type(MENTION) // 멘션으로 알람 타입 생성 + .build()) + .toList(); + alarmRepository.save(alarm); + alarmRepository.saveAll(alarms); + return; + } + + if (event.alarmType().equals(COMMENT)) { + if (event.mentions().isEmpty()) { + Alarm alarm = event.toEntity(); + alarmRepository.save(alarm); + return; + } else { + // 게시글 작성자에게 알람 생성 + Alarm alarm = event.toEntity(); + alarmRepository.save(alarm); + + // 언급 대상들에게 모두 알람을 보낸다. + // 이때, 본인을 언급했다면 생성하지 않는다. + List mentions = event.mentions(); + List alarms = mentions.stream() + .filter(mention -> !mention.getUser().getId().equals(event.listenerId())) // 언급의 대상이 게시글 작성자인 경우 제외 + .filter(mention -> !mention.getUser().getId().equals(event.publisher().getId())) // 언급의 대상이 본인인 경우 제외 + .map(mention -> Alarm.builder() + .sendUser(event.publisher()) + .receiveUserId(mention.getUser().getId()) + .list(event.list()) + .comment(event.comment()) + .type(MENTION) + .build()) + .toList(); + alarmRepository.saveAll(alarms); + return; + } + } + + Alarm alarm = event.toEntity(); + alarmRepository.save(alarm); + } @Transactional(readOnly = true) - public AlarmListResponse getAlarms(Long loginUserId) { - User user = userRepository.getById(loginUserId); - return new AlarmListResponse(alarmRepository.getAlarms(user)); + public List findAllBy(Long userId) { + userRepository.getById(userId); + List alarms = alarmRepository.findAllBy(userId, LocalDateTime.now().minusDays(30), NOTICE); + return AlarmFindResponse.toList(alarms); } - public void readAlarm(Long alarmId, Long loginUserId) { - User user = userRepository.getById(loginUserId); - Alarm alarm = alarmRepository.findAlarmByIdAndReceiveUserId(alarmId, user.getId()) - .orElseThrow(() -> new CustomException(RESOURCE_NOT_FOUND)); - alarm.readAlarm(); + public void check(Long alarmId) { + Alarm alarm = alarmRepository.getById(alarmId); + alarm.check(); } - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void save(AlarmEvent alarmEvent) { - alarmEvent.validateDifferentPublisherAndReceiver(); - alarmRepository.save(alarmEvent.toEntity()); + public AlarmCheckResponse isAllChecked(Long userId) { + User user = userRepository.getById(userId); + Boolean result = alarmRepository.isAllChecked(user.getId()); + return new AlarmCheckResponse(result); } - public AlarmCheckResponse checkAllAlarmsRead(Long loginUserId) { - User user = userRepository.getById(loginUserId); - return new AlarmCheckResponse(alarmRepository.hasCheckedAlarmsByReceiveUserId(user.getId())); + public void checkAll(Long userId) { + User user = userRepository.getById(userId); + alarmRepository.checkAll(user.getId()); } - public void readAllAlarm(Long loginUserId) { - User user = userRepository.getById(loginUserId); - alarmRepository.readAllAlarm(user.getId()); + @EventListener(AlarmDeleteEvent.class) + @Transactional(propagation = REQUIRED) + public void deleteAllBy(AlarmDeleteEvent event) { + if (event.type().equals(COMMENT)) { // 댓글을 삭제하는 경우 + Comment comment = event.comment(); + + if (comment.getMentions().isEmpty()) { // 멘션이 없는 경우 + alarmRepository.deleteAllByCommentAndReceiveUserId(comment, comment.getList().getUser().getId()); // 해당 댓글로 생성된 알람 삭제 + } else { + alarmRepository.deleteAllByCommentAndReceiveUserId(comment, comment.getList().getUser().getId()); // 해당 댓글로 생성된 알람 삭제 + + // 멘션에 포함된 알람 삭제 + List mentions = comment.getMentions(); + mentions.forEach(mention -> { + User receiveUser = mention.getUser(); + Long receiveUserId = receiveUser.getId(); + alarmRepository.deleteAllByCommentAndReceiveUserId(comment, receiveUserId); + }); + } + } + + if (event.type().equals(REPLY)) { + Reply reply = event.reply(); + List alarms = alarmRepository.findAllByReply(reply); + alarmRepository.deleteAll(alarms); + } } } diff --git a/src/main/java/com/listywave/alarm/presentation/controller/AlarmController.java b/src/main/java/com/listywave/alarm/presentation/controller/AlarmController.java index a745f471..998edf37 100644 --- a/src/main/java/com/listywave/alarm/presentation/controller/AlarmController.java +++ b/src/main/java/com/listywave/alarm/presentation/controller/AlarmController.java @@ -1,9 +1,11 @@ package com.listywave.alarm.presentation.controller; import com.listywave.alarm.application.dto.AlarmCheckResponse; -import com.listywave.alarm.application.dto.AlarmListResponse; +import com.listywave.alarm.application.dto.AlarmFindResponse; import com.listywave.alarm.application.service.AlarmService; import com.listywave.common.auth.Auth; +import com.listywave.user.application.service.UserService; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -15,37 +17,31 @@ @RequiredArgsConstructor public class AlarmController { + private final UserService userService; private final AlarmService alarmService; @GetMapping("/alarms") - ResponseEntity getAlarms( - @Auth Long loginUserId - ) { - AlarmListResponse response = alarmService.getAlarms(loginUserId); + ResponseEntity> findAllBy(@Auth Long loginUserId) { + List response = alarmService.findAllBy(loginUserId); return ResponseEntity.ok(response); } @PatchMapping("/alarms/{alarmId}") - ResponseEntity readAlarm( - @PathVariable("alarmId") Long alarmId, - @Auth Long loginUserId - ) { - alarmService.readAlarm(alarmId, loginUserId); + ResponseEntity check(@PathVariable("alarmId") Long alarmId, @Auth Long userId) { + userService.getById(userId); + alarmService.check(alarmId); return ResponseEntity.noContent().build(); } @GetMapping("/alarms/check-new") - ResponseEntity checkAllAlarmsRead( - @Auth Long loginUserId - ) { - return ResponseEntity.ok().body(alarmService.checkAllAlarmsRead(loginUserId)); + ResponseEntity isAllChecked(@Auth Long userId) { + AlarmCheckResponse result = alarmService.isAllChecked(userId); + return ResponseEntity.ok().body(result); } @PatchMapping("/alarms") - ResponseEntity readAllAlarm( - @Auth Long loginUserId - ) { - alarmService.readAllAlarm(loginUserId); + ResponseEntity checkAll(@Auth Long userId) { + alarmService.checkAll(userId); return ResponseEntity.noContent().build(); } } diff --git a/src/main/java/com/listywave/alarm/repository/AlarmRepository.java b/src/main/java/com/listywave/alarm/repository/AlarmRepository.java index a30ccfc4..c3abadd7 100644 --- a/src/main/java/com/listywave/alarm/repository/AlarmRepository.java +++ b/src/main/java/com/listywave/alarm/repository/AlarmRepository.java @@ -1,9 +1,15 @@ package com.listywave.alarm.repository; import com.listywave.alarm.application.domain.Alarm; +import com.listywave.alarm.application.domain.AlarmType; import com.listywave.alarm.repository.custom.CustomAlarmRepository; +import com.listywave.common.exception.CustomException; +import com.listywave.common.exception.ErrorCode; +import com.listywave.list.application.domain.comment.Comment; +import com.listywave.list.application.domain.list.ListEntity; +import com.listywave.list.application.domain.reply.Reply; +import java.time.LocalDateTime; import java.util.List; -import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -11,20 +17,22 @@ public interface AlarmRepository extends JpaRepository, CustomAlarmRepository { - Optional findAlarmByIdAndReceiveUserId(Long id, Long receiveUserId); + default Alarm getById(Long id) { + return findById(id).orElseThrow(() -> new CustomException(ErrorCode.RESOURCE_NOT_FOUND)); + } @Query(""" select case when count(*) > 0 then false else true end from Alarm a where a.receiveUserId = :receiveUserId and a.isChecked = false """) - Boolean hasCheckedAlarmsByReceiveUserId(Long receiveUserId); + Boolean isAllChecked(Long receiveUserId); - void deleteAllByListId(Long listId); + void deleteAllByList(ListEntity list); @Modifying - @Query("delete from Alarm a where a.listId in :listIds") - void deleteAllByListIdIn(@Param("listIds") List listIds); + @Query("delete from Alarm a where a.list in :lists") + void deleteAllByListsIn(@Param("lists") List lists); @Modifying(clearAutomatically = true) @Query(""" @@ -33,5 +41,22 @@ select case when count(*) > 0 then false else true end where a.receiveUserId = :receiveUserId and a.isChecked = false """) - void readAllAlarm(Long receiveUserId); + void checkAll(Long receiveUserId); + + @Query(""" + select a + from Alarm a + join fetch a.sendUser u + left join ListEntity l on a.list = l + left join Comment c on a.comment = c + left join Reply r on a.reply = r + left join Notice n on a.notice = n + where a.createdDate >= :thirtyDaysAgo and (a.receiveUserId = :receiveUserId or a.type = :type) + order by a.createdDate desc + """) + List findAllBy(Long receiveUserId, LocalDateTime thirtyDaysAgo, AlarmType type); + + List findAllByReply(Reply reply); + + void deleteAllByCommentAndReceiveUserId(Comment comment, Long receiveUserId); } diff --git a/src/main/java/com/listywave/alarm/repository/custom/CustomAlarmRepository.java b/src/main/java/com/listywave/alarm/repository/custom/CustomAlarmRepository.java index 02d67196..e464bd59 100644 --- a/src/main/java/com/listywave/alarm/repository/custom/CustomAlarmRepository.java +++ b/src/main/java/com/listywave/alarm/repository/custom/CustomAlarmRepository.java @@ -1,11 +1,5 @@ package com.listywave.alarm.repository.custom; -import com.listywave.alarm.application.dto.FindAlarmResponse; -import com.listywave.user.application.domain.User; -import java.util.List; - public interface CustomAlarmRepository { - List getAlarms(User user); - void deleteAlarmThirtyDaysAgo(); } diff --git a/src/main/java/com/listywave/alarm/repository/custom/impl/CustomAlarmRepositoryImpl.java b/src/main/java/com/listywave/alarm/repository/custom/impl/CustomAlarmRepositoryImpl.java index eec24de8..be0142ff 100644 --- a/src/main/java/com/listywave/alarm/repository/custom/impl/CustomAlarmRepositoryImpl.java +++ b/src/main/java/com/listywave/alarm/repository/custom/impl/CustomAlarmRepositoryImpl.java @@ -1,15 +1,10 @@ package com.listywave.alarm.repository.custom.impl; import static com.listywave.alarm.application.domain.QAlarm.alarm; -import static com.listywave.list.application.domain.list.QListEntity.listEntity; -import com.listywave.alarm.application.dto.FindAlarmResponse; import com.listywave.alarm.repository.custom.CustomAlarmRepository; -import com.listywave.user.application.domain.User; -import com.querydsl.core.types.Projections; import com.querydsl.jpa.impl.JPAQueryFactory; import java.time.LocalDateTime; -import java.util.List; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor @@ -17,33 +12,6 @@ public class CustomAlarmRepositoryImpl implements CustomAlarmRepository { private final JPAQueryFactory queryFactory; - @Override - public List getAlarms(User user) { - LocalDateTime thirtyDaysAgo = LocalDateTime.now().minusDays(30); - - return queryFactory - .select(Projections.fields(FindAlarmResponse.class, - alarm.id, - alarm.user.id.as("sendUserId"), - alarm.user.nickname.value.as("nickname"), - alarm.user.profileImageUrl.value.as("profileImageUrl"), - alarm.listId, - alarm.commentId, - listEntity.title.value.as("listTitle"), - alarm.type.stringValue().as("type"), - alarm.isChecked, - alarm.createdDate - )) - .from(alarm) - .leftJoin(listEntity).on(alarm.listId.eq(listEntity.id)) - .where( - alarm.receiveUserId.eq(user.getId()), - alarm.createdDate.goe(thirtyDaysAgo) - ) - .orderBy(alarm.id.desc()) - .fetch(); - } - @Override public void deleteAlarmThirtyDaysAgo() { LocalDateTime thirtyDaysAgo = LocalDateTime.now().minusDays(30); diff --git a/src/main/java/com/listywave/auth/application/domain/JwtManager.java b/src/main/java/com/listywave/auth/application/domain/JwtManager.java index c0c1ee00..8c353c64 100644 --- a/src/main/java/com/listywave/auth/application/domain/JwtManager.java +++ b/src/main/java/com/listywave/auth/application/domain/JwtManager.java @@ -48,7 +48,7 @@ public JwtManager( public String createAccessToken(Long userId) { Date now = new Date(); return Jwts.builder() - .header().type("jwt").and() + .header().type("accessToken").and() .signWith(secretKey) .issuer(issuer) .issuedAt(now) @@ -58,14 +58,42 @@ public String createAccessToken(Long userId) { .compact(); } + public String createAdminAccessToken(Long userId) { + Date now = new Date(); + return Jwts.builder() + .header().type("accessToken").and() + .signWith(secretKey) + .issuer(issuer) + .issuedAt(now) + .subject(String.valueOf(userId)) + .claim("roles", "admin") + .expiration(new Date(now.getTime() + convertTimeUnit(accessTokenValidTimeDuration, accessTokenValidTimeUnit, MILLISECONDS))) + .issuedAt(Date.from(Instant.now())) + .compact(); + } + public String createRefreshToken(Long userId) { Date now = new Date(); return Jwts.builder() - .header().type("jwt").and() + .header().type("refreshToken").and() + .signWith(secretKey) + .issuer(issuer) + .issuedAt(now) + .subject(String.valueOf(userId)) + .expiration(new Date(now.getTime() + convertTimeUnit(refreshTokenValidTimeDuration, refreshTokenValidTimeUnit, MILLISECONDS))) + .issuedAt(Date.from(Instant.now())) + .compact(); + } + + public String createAdminRefreshToken(Long userId) { + Date now = new Date(); + return Jwts.builder() + .header().type("refreshToken").and() .signWith(secretKey) .issuer(issuer) .issuedAt(now) .subject(String.valueOf(userId)) + .claim("roles", "admin") .expiration(new Date(now.getTime() + convertTimeUnit(refreshTokenValidTimeDuration, refreshTokenValidTimeUnit, MILLISECONDS))) .issuedAt(Date.from(Instant.now())) .compact(); diff --git a/src/main/java/com/listywave/auth/application/domain/kakao/KakaoOauthClient.java b/src/main/java/com/listywave/auth/application/domain/kakao/KakaoOauthClient.java index 3bc9effd..0ee99caf 100644 --- a/src/main/java/com/listywave/auth/application/domain/kakao/KakaoOauthClient.java +++ b/src/main/java/com/listywave/auth/application/domain/kakao/KakaoOauthClient.java @@ -1,7 +1,6 @@ package com.listywave.auth.application.domain.kakao; import com.listywave.auth.infra.kakao.KakaoOauthApiClient; -import com.listywave.auth.infra.kakao.response.KakaoLogoutResponse; import com.listywave.auth.infra.kakao.response.KakaoMember; import com.listywave.auth.infra.kakao.response.KakaoTokenResponse; import lombok.RequiredArgsConstructor; @@ -13,6 +12,7 @@ @RequiredArgsConstructor public class KakaoOauthClient { + private static final String TOKEN_PREFIX = "Bearer "; private final KakaoOauthConfig kakaoOauthConfig; private final KakaoOauthApiClient apiClient; @@ -29,12 +29,11 @@ public KakaoTokenResponse requestToken(String authCode) { } public KakaoMember fetchMember(String accessToken) { - return apiClient.fetchKakaoMember("Bearer " + accessToken); + return apiClient.fetchKakaoMember(TOKEN_PREFIX + accessToken); } - public Long logout(String oauthAccessToken) { - String accessToken = "Bearer " + oauthAccessToken; - KakaoLogoutResponse response = apiClient.logout(accessToken); - return response.id(); + public void logout(String oauthAccessToken) { + String accessToken = TOKEN_PREFIX + oauthAccessToken; + apiClient.logout(accessToken); } } diff --git a/src/main/java/com/listywave/auth/presentation/dto/LoginResponse.java b/src/main/java/com/listywave/auth/application/dto/LoginResponse.java similarity index 55% rename from src/main/java/com/listywave/auth/presentation/dto/LoginResponse.java rename to src/main/java/com/listywave/auth/application/dto/LoginResponse.java index bf0af3a1..6921cfa1 100644 --- a/src/main/java/com/listywave/auth/presentation/dto/LoginResponse.java +++ b/src/main/java/com/listywave/auth/application/dto/LoginResponse.java @@ -1,6 +1,5 @@ -package com.listywave.auth.presentation.dto; +package com.listywave.auth.application.dto; -import com.listywave.auth.application.dto.LoginResult; import com.listywave.user.application.domain.User; import lombok.Builder; @@ -18,22 +17,7 @@ public record LoginResponse( String refreshToken ) { - public static LoginResponse of(LoginResult result) { - return LoginResponse.builder() - .id(result.id()) - .profileImageUrl(result.profileImageUrl()) - .backgroundImageUrl(result.backgroundImageUrl()) - .nickname(result.nickname()) - .description(result.description()) - .followerCount(result.followerCount()) - .followingCount(result.followingCount()) - .isFirst(result.isFirst()) - .accessToken(result.accessToken()) - .refreshToken(result.refreshToken()) - .build(); - } - - public static LoginResponse of(User user, String accessToken, String refreshToken) { + public static LoginResponse of(User user, String accessToken, String refreshToken, boolean isFirst) { return LoginResponse.builder() .id(user.getId()) .profileImageUrl(user.getProfileImageUrl()) @@ -42,7 +26,7 @@ public static LoginResponse of(User user, String accessToken, String refreshToke .description(user.getDescription()) .followerCount(user.getFollowerCount()) .followingCount(user.getFollowingCount()) - .isFirst(false) + .isFirst(isFirst) .accessToken(accessToken) .refreshToken(refreshToken) .build(); diff --git a/src/main/java/com/listywave/auth/application/dto/LoginResult.java b/src/main/java/com/listywave/auth/application/dto/LoginResult.java deleted file mode 100644 index cee76d32..00000000 --- a/src/main/java/com/listywave/auth/application/dto/LoginResult.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.listywave.auth.application.dto; - -import com.listywave.user.application.domain.User; -import java.util.concurrent.TimeUnit; - -public record LoginResult( - Long id, - String profileImageUrl, - String backgroundImageUrl, - String nickname, - String description, - int followingCount, - int followerCount, - boolean isFirst, - String accessToken, - String refreshToken, - int accessTokenValidTimeDuration, - int refreshTokenValidTimeDuration, - TimeUnit accessTokenValidTimeUnit, - TimeUnit refreshTokenValidTimeUnit -) { - - public static LoginResult of( - User user, - boolean isFirst, - String accessToken, - String refreshToken, - int accessTokenValidTimeDuration, - int refreshTokenValidTimeDuration, - TimeUnit accessTokenValidTimeUnit, - TimeUnit refreshTokenValidTimeUnit - ) { - return new LoginResult( - user.getId(), - user.getProfileImageUrl(), - user.getBackgroundImageUrl(), - user.getNickname(), - user.getDescription(), - user.getFollowingCount(), - user.getFollowerCount(), - isFirst, - accessToken, - refreshToken, - accessTokenValidTimeDuration, - refreshTokenValidTimeDuration, - accessTokenValidTimeUnit, - refreshTokenValidTimeUnit - ); - } -} diff --git a/src/main/java/com/listywave/auth/presentation/dto/UpdateTokenResponse.java b/src/main/java/com/listywave/auth/application/dto/UpdateTokenResponse.java similarity index 60% rename from src/main/java/com/listywave/auth/presentation/dto/UpdateTokenResponse.java rename to src/main/java/com/listywave/auth/application/dto/UpdateTokenResponse.java index 1b32e127..bb16a28a 100644 --- a/src/main/java/com/listywave/auth/presentation/dto/UpdateTokenResponse.java +++ b/src/main/java/com/listywave/auth/application/dto/UpdateTokenResponse.java @@ -1,4 +1,4 @@ -package com.listywave.auth.presentation.dto; +package com.listywave.auth.application.dto; public record UpdateTokenResponse( String accessToken diff --git a/src/main/java/com/listywave/auth/application/dto/UpdateTokenResult.java b/src/main/java/com/listywave/auth/application/dto/UpdateTokenResult.java deleted file mode 100644 index 105a5638..00000000 --- a/src/main/java/com/listywave/auth/application/dto/UpdateTokenResult.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.listywave.auth.application.dto; - -import java.util.concurrent.TimeUnit; - -public record UpdateTokenResult( - String accessToken, - String refreshToken, - int accessTokenValidTimeDuration, - int refreshTokenValidTimeDuration, - TimeUnit accessTokenValidTimeUnit, - TimeUnit refreshTokenValidTimeUnit -) { -} diff --git a/src/main/java/com/listywave/auth/application/service/AuthService.java b/src/main/java/com/listywave/auth/application/service/AuthService.java index 8d753996..dac45be1 100644 --- a/src/main/java/com/listywave/auth/application/service/AuthService.java +++ b/src/main/java/com/listywave/auth/application/service/AuthService.java @@ -5,8 +5,8 @@ import com.listywave.auth.application.domain.JwtManager; import com.listywave.auth.application.domain.kakao.KakaoOauthClient; import com.listywave.auth.application.domain.kakao.KakaoRedirectUriProvider; -import com.listywave.auth.application.dto.LoginResult; -import com.listywave.auth.application.dto.UpdateTokenResult; +import com.listywave.auth.application.dto.LoginResponse; +import com.listywave.auth.application.dto.UpdateTokenResponse; import com.listywave.auth.infra.kakao.response.KakaoMember; import com.listywave.auth.infra.kakao.response.KakaoTokenResponse; import com.listywave.common.exception.CustomException; @@ -38,7 +38,7 @@ public String provideRedirectUri() { return kakaoRedirectUriProvider.provide(); } - public LoginResult login(String authCode) { + public LoginResponse login(String authCode) { KakaoTokenResponse kakaoTokenResponse = kakaoOauthClient.requestToken(authCode); KakaoMember kakaoMember = kakaoOauthClient.fetchMember(kakaoTokenResponse.accessToken()); @@ -51,40 +51,22 @@ public LoginResult login(String authCode) { ); } - private LoginResult loginNonInit(User user, String kakaoAccessToken) { + private LoginResponse loginNonInit(User user, String kakaoAccessToken) { if (user.isDelete()) { throw new CustomException(DELETED_USER_EXCEPTION); } user.updateKakaoAccessToken(kakaoAccessToken); String accessToken = jwtManager.createAccessToken(user.getId()); String refreshToken = jwtManager.createRefreshToken(user.getId()); - return LoginResult.of( - user, - false, - accessToken, - refreshToken, - jwtManager.getAccessTokenValidTimeDuration(), - jwtManager.getRefreshTokenValidTimeDuration(), - jwtManager.getAccessTokenValidTimeUnit(), - jwtManager.getRefreshTokenValidTimeUnit() - ); + return LoginResponse.of(user, accessToken, refreshToken, false); } - private LoginResult loginInit(Long kakaoId, String kakaoEmail, String kakaoAccessToken) { + private LoginResponse loginInit(Long kakaoId, String kakaoEmail, String kakaoAccessToken) { User user = User.init(kakaoId, kakaoEmail, kakaoAccessToken); - User createdUser = userRepository.save(user); + userRepository.save(user); String accessToken = jwtManager.createAccessToken(user.getId()); String refreshToken = jwtManager.createRefreshToken(user.getId()); - return LoginResult.of( - createdUser, - true, - accessToken, - refreshToken, - jwtManager.getAccessTokenValidTimeDuration(), - jwtManager.getRefreshTokenValidTimeDuration(), - jwtManager.getAccessTokenValidTimeUnit(), - jwtManager.getRefreshTokenValidTimeUnit() - ); + return LoginResponse.of(user, accessToken, refreshToken, true); } public void logout(Long userId) { @@ -95,19 +77,10 @@ public void logout(Long userId) { } @Transactional(readOnly = true) - public UpdateTokenResult updateToken(Long userId) { + public UpdateTokenResponse updateToken(Long userId) { User user = userRepository.getById(userId); - String accessToken = jwtManager.createAccessToken(user.getId()); - String newRefreshToken = jwtManager.createRefreshToken(user.getId()); - return new UpdateTokenResult( - accessToken, - newRefreshToken, - jwtManager.getAccessTokenValidTimeDuration(), - jwtManager.getRefreshTokenValidTimeDuration(), - jwtManager.getAccessTokenValidTimeUnit(), - jwtManager.getRefreshTokenValidTimeUnit() - ); + return new UpdateTokenResponse(accessToken); } public void withdraw(Long userId) { diff --git a/src/main/java/com/listywave/auth/dev/domain/DevAccount.java b/src/main/java/com/listywave/auth/dev/domain/DevAccount.java new file mode 100644 index 00000000..9d3dd06d --- /dev/null +++ b/src/main/java/com/listywave/auth/dev/domain/DevAccount.java @@ -0,0 +1,47 @@ +package com.listywave.auth.dev.domain; + +import static jakarta.persistence.FetchType.LAZY; + +import com.listywave.user.application.domain.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class DevAccount { + + @Id + @Column(name = "user_id", nullable = false, unique = true) + private Long id; + + @MapsId + @OneToOne(fetch = LAZY) + @JoinColumn(name = "user_id", nullable = false, unique = true) + private User user; + + @Column(nullable = false, unique = true) + private String account; + + @Column(nullable = false) + private String password; + + public void validatePassword(String password) { + if (this.password.equals(password)) { + return; + } + throw new IllegalArgumentException("비밀번호가 틀렸습니다."); + } + + public Long getUserId() { + return user.getId(); + } +} diff --git a/src/main/java/com/listywave/auth/dev/presentation/DevAuthController.java b/src/main/java/com/listywave/auth/dev/presentation/DevAuthController.java new file mode 100644 index 00000000..b43753d1 --- /dev/null +++ b/src/main/java/com/listywave/auth/dev/presentation/DevAuthController.java @@ -0,0 +1,39 @@ +package com.listywave.auth.dev.presentation; + +import com.listywave.auth.application.domain.JwtManager; +import com.listywave.auth.application.dto.LoginResponse; +import com.listywave.auth.dev.domain.DevAccount; +import com.listywave.auth.dev.repository.DevAccountRepository; +import com.listywave.user.application.domain.User; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Profile("!prod") +@RequiredArgsConstructor +public class DevAuthController { + + private final JwtManager jwtManager; + private final DevAccountRepository devAccountRepository; + + @PostMapping("/login/local") + ResponseEntity localLogin(@RequestBody LocalLoginRequest request) { + String account = request.account(); + String password = request.password(); + + Optional optional = devAccountRepository.findByAccount(account); + DevAccount devAccount = optional.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 계정입니다.")); + + devAccount.validatePassword(password); + User user = devAccount.getUser(); + String accessToken = jwtManager.createAccessToken(user.getId()); + String refreshToken = jwtManager.createRefreshToken(user.getId()); + LoginResponse response = LoginResponse.of(user, accessToken, refreshToken, false); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/listywave/auth/dev/presentation/LocalLoginRequest.java b/src/main/java/com/listywave/auth/dev/presentation/LocalLoginRequest.java new file mode 100644 index 00000000..054b82ed --- /dev/null +++ b/src/main/java/com/listywave/auth/dev/presentation/LocalLoginRequest.java @@ -0,0 +1,7 @@ +package com.listywave.auth.dev.presentation; + +public record LocalLoginRequest( + String account, + String password +) { +} diff --git a/src/main/java/com/listywave/auth/dev/repository/DevAccountRepository.java b/src/main/java/com/listywave/auth/dev/repository/DevAccountRepository.java new file mode 100644 index 00000000..ae293253 --- /dev/null +++ b/src/main/java/com/listywave/auth/dev/repository/DevAccountRepository.java @@ -0,0 +1,12 @@ +package com.listywave.auth.dev.repository; + +import com.listywave.auth.dev.domain.DevAccount; +import java.util.Optional; +import org.springframework.context.annotation.Profile; +import org.springframework.data.jpa.repository.JpaRepository; + +@Profile("!prod") +public interface DevAccountRepository extends JpaRepository { + + Optional findByAccount(String account); +} diff --git a/src/main/java/com/listywave/auth/presentation/AuthController.java b/src/main/java/com/listywave/auth/presentation/AuthController.java index 6f301f30..2db1725c 100644 --- a/src/main/java/com/listywave/auth/presentation/AuthController.java +++ b/src/main/java/com/listywave/auth/presentation/AuthController.java @@ -1,20 +1,12 @@ package com.listywave.auth.presentation; -import static java.util.concurrent.TimeUnit.SECONDS; -import static org.springframework.http.HttpHeaders.SET_COOKIE; - -import com.listywave.auth.application.dto.LoginResult; -import com.listywave.auth.application.dto.UpdateTokenResult; +import com.listywave.auth.application.dto.LoginResponse; +import com.listywave.auth.application.dto.UpdateTokenResponse; import com.listywave.auth.application.service.AuthService; -import com.listywave.auth.presentation.dto.LoginResponse; -import com.listywave.auth.presentation.dto.UpdateTokenResponse; import com.listywave.common.auth.Auth; -import com.listywave.common.util.TimeUtils; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; -import java.time.Duration; import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -36,45 +28,9 @@ ResponseEntity redirectAuthCodeRequestUrl(HttpServletResponse response) th } @GetMapping("/auth/redirect/kakao") - ResponseEntity login( - @RequestParam("code") String authCode - ) { - LoginResult loginResult = authService.login(authCode); - - ResponseCookie accessTokenCookie = createCookie( - "accessToken", - loginResult.accessToken(), - Duration.ofSeconds( - TimeUtils.convertTimeUnit(loginResult.accessTokenValidTimeDuration(), - loginResult.accessTokenValidTimeUnit(), - SECONDS) - ), true, true, "sameSite" - ); - ResponseCookie refreshTokenCookie = createCookie( - "refreshToken", - loginResult.refreshToken(), - Duration.ofSeconds( - TimeUtils.convertTimeUnit(loginResult.refreshTokenValidTimeDuration(), - loginResult.refreshTokenValidTimeUnit(), - SECONDS) - ), true, true, "sameSite" - ); - LoginResponse response = LoginResponse.of(loginResult); - - return ResponseEntity.ok() - .header(SET_COOKIE, accessTokenCookie.toString()) - .header(SET_COOKIE, refreshTokenCookie.toString()) - .body(response); - } - - private ResponseCookie createCookie(String name, String value, Duration maxAge, boolean httpOnly, boolean secure, String sameSite) { - return ResponseCookie.from(name) - .value(value) - .maxAge(maxAge) - .httpOnly(httpOnly) - .secure(secure) - .sameSite(sameSite) - .build(); + ResponseEntity login(@RequestParam("code") String authCode) { + LoginResponse response = authService.login(authCode); + return ResponseEntity.ok().body(response); } @PatchMapping("/auth/kakao") @@ -84,24 +40,9 @@ ResponseEntity logout(@Auth Long loginUserId) { } @GetMapping("/auth/token") - ResponseEntity updateToken( - @Auth Long userId - ) { - UpdateTokenResult result = authService.updateToken(userId); - - ResponseCookie accessTokenCookie = createCookie( - "accessToken", - result.accessToken(), - Duration.ofSeconds( - TimeUtils.convertTimeUnit(result.accessTokenValidTimeDuration(), - result.accessTokenValidTimeUnit(), - SECONDS) - ), true, true, "sameSite" - ); - - return ResponseEntity.ok() - .header(SET_COOKIE, accessTokenCookie.toString()) - .body(new UpdateTokenResponse(result.accessToken())); + ResponseEntity updateToken(@Auth Long userId) { + UpdateTokenResponse response = authService.updateToken(userId); + return ResponseEntity.ok().body(response); } @DeleteMapping("/withdraw") diff --git a/src/main/java/com/listywave/auth/presentation/LocalAuthController.java b/src/main/java/com/listywave/auth/presentation/LocalAuthController.java deleted file mode 100644 index 1cef335b..00000000 --- a/src/main/java/com/listywave/auth/presentation/LocalAuthController.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.listywave.auth.presentation; - -import static com.listywave.common.exception.ErrorCode.INVALID_ACCESS; -import static java.util.concurrent.TimeUnit.SECONDS; -import static org.springframework.http.HttpHeaders.SET_COOKIE; - -import com.listywave.auth.application.domain.JwtManager; -import com.listywave.auth.presentation.dto.LoginResponse; -import com.listywave.common.exception.CustomException; -import com.listywave.common.util.TimeUtils; -import com.listywave.user.application.domain.User; -import com.listywave.user.repository.user.UserRepository; -import java.time.Duration; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Profile; -import org.springframework.http.ResponseCookie; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@Profile("!prod") -@RequiredArgsConstructor -public class LocalAuthController { - - private final UserRepository userRepository; - private final JwtManager jwtManager; - - @Value("${local-login.id}") - private String id; - @Value("${local-login.password}") - private String password; - - @GetMapping("/login/local") - ResponseEntity localLogin( - @RequestParam(name = "id") String id, - @RequestParam(name = "password") String password - ) { - if (this.id.equals(id) && this.password.equals(password)) { - User user = userRepository.getById(1L); - - String accessToken = jwtManager.createAccessToken(user.getId()); - String refreshToken = jwtManager.createRefreshToken(user.getId()); - - ResponseCookie accessTokenCookie = createCookie( - "accessToken", - accessToken, - Duration.ofSeconds(TimeUtils.convertTimeUnit( - jwtManager.getAccessTokenValidTimeDuration(), - jwtManager.getAccessTokenValidTimeUnit(), - SECONDS - )) - ); - ResponseCookie refreshTokenCookie = createCookie( - "refreshToken", - refreshToken, - Duration.ofSeconds(TimeUtils.convertTimeUnit( - jwtManager.getRefreshTokenValidTimeDuration(), - jwtManager.getRefreshTokenValidTimeUnit(), - SECONDS - )) - ); - - return ResponseEntity.ok() - .header(SET_COOKIE, accessTokenCookie.toString()) - .header(SET_COOKIE, refreshTokenCookie.toString()) - .body(LoginResponse.of(user, accessToken, refreshToken)); - } - throw new CustomException(INVALID_ACCESS); - } - - private ResponseCookie createCookie(String name, String value, Duration maxAge) { - return ResponseCookie.from(name) - .value(value) - .maxAge(maxAge) - .domain("dev.api.listywave.com") - .path("/") - .httpOnly(false) - .secure(true) - .sameSite("None") - .build(); - } -} diff --git a/src/main/java/com/listywave/collection/application/domain/Collect.java b/src/main/java/com/listywave/collection/application/domain/Collect.java index bc923432..e980f954 100644 --- a/src/main/java/com/listywave/collection/application/domain/Collect.java +++ b/src/main/java/com/listywave/collection/application/domain/Collect.java @@ -31,11 +31,16 @@ public class Collect { @JoinColumn(name = "list_id") private ListEntity list; + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "folder_id") + private Folder folder; + @Column(name = "user_id", nullable = false) private Long userId; - public Collect(ListEntity list, Long userId) { + public Collect(ListEntity list, Long userId, Folder folder) { this.list = list; this.userId = userId; + this.folder = folder; } } diff --git a/src/main/java/com/listywave/collection/application/domain/Folder.java b/src/main/java/com/listywave/collection/application/domain/Folder.java new file mode 100644 index 00000000..9cc308f6 --- /dev/null +++ b/src/main/java/com/listywave/collection/application/domain/Folder.java @@ -0,0 +1,43 @@ +package com.listywave.collection.application.domain; + +import static com.listywave.common.exception.ErrorCode.INVALID_ACCESS; +import static lombok.AccessLevel.PROTECTED; + +import com.listywave.common.BaseEntity; +import com.listywave.common.exception.CustomException; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@AllArgsConstructor +@Table(name = "folder") +@NoArgsConstructor(access = PROTECTED) +public class Folder extends BaseEntity { + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Embedded + private FolderName name; + + public void updateFolderName(Long userId, FolderName folderName) { + validateOwner(userId); + this.name = folderName; + } + + public String getFolderName() { + return this.name.getValue(); + } + + public void validateOwner(Long userId) { + if (!this.userId.equals(userId)) { + throw new CustomException(INVALID_ACCESS); + } + } +} diff --git a/src/main/java/com/listywave/collection/application/domain/FolderName.java b/src/main/java/com/listywave/collection/application/domain/FolderName.java new file mode 100644 index 00000000..64c22850 --- /dev/null +++ b/src/main/java/com/listywave/collection/application/domain/FolderName.java @@ -0,0 +1,34 @@ +package com.listywave.collection.application.domain; + +import static com.listywave.common.exception.ErrorCode.LENGTH_EXCEEDED_EXCEPTION; + +import com.listywave.common.exception.CustomException; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED, force = true) +public class FolderName { + + private static final int LENGTH_LIMIT = 30; + + @Column(name = "name", nullable = false, length = LENGTH_LIMIT) + private final String value; + + public FolderName(String value) { + validate(value); + this.value = value; + } + + private void validate(String value) { + if (value.length() > LENGTH_LIMIT) { + throw new CustomException(LENGTH_EXCEEDED_EXCEPTION, "폴더 이름은 " + LENGTH_LIMIT + "자를 넘을 수 없습니다."); + } + } +} diff --git a/src/main/java/com/listywave/collection/application/dto/CollectionResponse.java b/src/main/java/com/listywave/collection/application/dto/CollectionFindResponse.java similarity index 59% rename from src/main/java/com/listywave/collection/application/dto/CollectionResponse.java rename to src/main/java/com/listywave/collection/application/dto/CollectionFindResponse.java index 8d75471b..8e0be44d 100644 --- a/src/main/java/com/listywave/collection/application/dto/CollectionResponse.java +++ b/src/main/java/com/listywave/collection/application/dto/CollectionFindResponse.java @@ -7,38 +7,44 @@ import java.util.List; import lombok.Builder; -public record CollectionResponse( +public record CollectionFindResponse( Long cursorId, Boolean hasNext, - List collectionLists + List collectionLists, + String folderName ) { - public static CollectionResponse of(Long cursorId, Boolean hasNext, List collects) { - return new CollectionResponse(cursorId, hasNext, toList(collects)); + public static CollectionFindResponse of( + Long cursorId, + Boolean hasNext, + List collects, + String folderName + ) { + return new CollectionFindResponse(cursorId, hasNext, toList(collects), folderName); } - public static List toList(List collects) { + public static List toList(List collects) { return collects.stream() - .map(CollectionListsResponse::of) + .map(CollectionDto::of) .toList(); } - public record CollectionListsResponse( + public record CollectionDto( Long id, - ListsResponse list + ListsDto list ) { - public static CollectionListsResponse of(Collect collect) { - return new CollectionListsResponse(collect.getId(), toResponse(collect.getList())); + public static CollectionDto of(Collect collect) { + return new CollectionDto(collect.getId(), toResponse(collect.getList())); } - public static ListsResponse toResponse(ListEntity list) { - return ListsResponse.of(list); + public static ListsDto toResponse(ListEntity list) { + return ListsDto.of(list); } } @Builder - public record ListsResponse( + public record ListsDto( Long id, String backgroundColor, String title, @@ -46,12 +52,13 @@ public record ListsResponse( String ownerNickname, String ownerProfileImageUrl, String representativeImageUrl, + String category, LocalDateTime updatedDate, - List listItems + List listItems ) { - public static ListsResponse of(ListEntity list) { - return ListsResponse.builder() + public static ListsDto of(ListEntity list) { + return ListsDto.builder() .id(list.getId()) .backgroundColor(list.getBackgroundColor().name()) .title(list.getTitle().getValue()) @@ -59,28 +66,29 @@ public static ListsResponse of(ListEntity list) { .ownerNickname(list.getUser().getNickname()) .ownerProfileImageUrl(list.getUser().getProfileImageUrl()) .representativeImageUrl(list.getRepresentImageUrl()) + .category(list.getCategory().getViewName()) .updatedDate(list.getUpdatedDate()) .listItems(toList(list.getTop3Items().getValues())) .build(); } - public static List toList(List items) { + public static List toList(List items) { return items.stream() - .map(ListItemsResponse::of) + .map(ListItemsDto::of) .toList(); } } @Builder - public record ListItemsResponse( + public record ListItemsDto( Long id, int rank, String title, String imageUrl ) { - public static ListItemsResponse of(Item item) { - return ListItemsResponse.builder() + public static ListItemsDto of(Item item) { + return ListItemsDto.builder() .id(item.getId()) .rank(item.getRanking()) .title(item.getTitle().getValue()) diff --git a/src/main/java/com/listywave/collection/application/dto/FindFolderResponse.java b/src/main/java/com/listywave/collection/application/dto/FindFolderResponse.java new file mode 100644 index 00000000..6b0871fe --- /dev/null +++ b/src/main/java/com/listywave/collection/application/dto/FindFolderResponse.java @@ -0,0 +1,12 @@ +package com.listywave.collection.application.dto; + +import java.util.List; + +public record FindFolderResponse( + List folders +) { + + public static FindFolderResponse of(List list) { + return new FindFolderResponse(list); + } +} diff --git a/src/main/java/com/listywave/collection/application/dto/FolderCreateResponse.java b/src/main/java/com/listywave/collection/application/dto/FolderCreateResponse.java new file mode 100644 index 00000000..85ec7c05 --- /dev/null +++ b/src/main/java/com/listywave/collection/application/dto/FolderCreateResponse.java @@ -0,0 +1,6 @@ +package com.listywave.collection.application.dto; + +public record FolderCreateResponse( + Long folderId +) { +} diff --git a/src/main/java/com/listywave/collection/application/dto/FolderResponse.java b/src/main/java/com/listywave/collection/application/dto/FolderResponse.java new file mode 100644 index 00000000..c942e4b5 --- /dev/null +++ b/src/main/java/com/listywave/collection/application/dto/FolderResponse.java @@ -0,0 +1,8 @@ +package com.listywave.collection.application.dto; + +public record FolderResponse( + Long folderId, + String folderName, + Long listCount +) { +} diff --git a/src/main/java/com/listywave/collection/application/service/CollectionService.java b/src/main/java/com/listywave/collection/application/service/CollectionService.java index 19b0fbe6..931fcb1a 100644 --- a/src/main/java/com/listywave/collection/application/service/CollectionService.java +++ b/src/main/java/com/listywave/collection/application/service/CollectionService.java @@ -1,9 +1,11 @@ package com.listywave.collection.application.service; -import com.listywave.alarm.application.domain.AlarmEvent; +import com.listywave.alarm.application.domain.AlarmCreateEvent; import com.listywave.collection.application.domain.Collect; -import com.listywave.collection.application.dto.CollectionResponse; +import com.listywave.collection.application.domain.Folder; +import com.listywave.collection.application.dto.CollectionFindResponse; import com.listywave.collection.repository.CollectionRepository; +import com.listywave.collection.repository.FolderRepository; import com.listywave.list.application.domain.category.CategoryType; import com.listywave.list.application.domain.list.ListEntity; import com.listywave.list.application.dto.response.CategoryTypeResponse; @@ -26,45 +28,55 @@ public class CollectionService { private final UserRepository userRepository; private final ListRepository listRepository; + private final FolderRepository folderRepository; private final CollectionRepository collectionRepository; private final ApplicationEventPublisher applicationEventPublisher; - public void collectOrCancel(Long listId, Long loginUserId) { - User loginUser = userRepository.getById(loginUserId); + private final static String FOLDER_ENTIRE_NAME = "전체"; + + public void collectOrCancel(Long listId, Long folderId, Long userId) { + User user = userRepository.getById(userId); ListEntity list = listRepository.getById(listId); + Folder folder = folderRepository.getById(folderId); - list.validateNotOwner(loginUser); + folder.validateOwner(userId); + list.validateNotOwner(user); - if (collectionRepository.existsByListAndUserId(list, loginUser.getId())) { - cancelCollect(list, loginUser.getId()); + if (collectionRepository.existsByListAndUserId(list, user.getId())) { + cancelCollect(list, user.getId()); } else { - addCollect(list, loginUser); + addCollect(list, user, folder); } } - private void addCollect(ListEntity list, User user) { - Collect collection = new Collect(list, user.getId()); - collectionRepository.save(collection); - list.increaseCollectCount(); - - applicationEventPublisher.publishEvent(AlarmEvent.collect(user, list)); - } - private void cancelCollect(ListEntity list, Long userId) { collectionRepository.deleteByListAndUserId(list, userId); list.decreaseCollectCount(); } - public CollectionResponse getCollection(Long loginUserId, Long cursorId, Pageable pageable, CategoryType category) { - User user = userRepository.getById(loginUserId); - Slice result = collectionRepository.getAllCollectionList(cursorId, pageable, user.getId(), category); + private void addCollect(ListEntity list, User user, Folder folder) { + Collect collection = new Collect(list, user.getId(), folder); + collectionRepository.save(collection); + list.increaseCollectCount(); + + applicationEventPublisher.publishEvent(AlarmCreateEvent.collect(user, list)); + } + + public CollectionFindResponse getCollection(Long userId, Long cursorId, Pageable pageable, Long folderId) { + User user = userRepository.getById(userId); + String folderName = FOLDER_ENTIRE_NAME; + if (folderId != 0L) { + Folder folder = folderRepository.getById(folderId); + folderName = folder.getFolderName(); + } + Slice result = collectionRepository.getAllCollectionList(cursorId, pageable, user.getId(), folderId); List collectionList = result.getContent(); cursorId = null; if (!collectionList.isEmpty()) { cursorId = collectionList.get(collectionList.size() - 1).getId(); } - return CollectionResponse.of(cursorId, result.hasNext(), collectionList); + return CollectionFindResponse.of(cursorId, result.hasNext(), collectionList, folderName); } public List getCategoriesOfCollection(Long loginUserId) { diff --git a/src/main/java/com/listywave/collection/application/service/FolderService.java b/src/main/java/com/listywave/collection/application/service/FolderService.java new file mode 100644 index 00000000..0908cff2 --- /dev/null +++ b/src/main/java/com/listywave/collection/application/service/FolderService.java @@ -0,0 +1,66 @@ +package com.listywave.collection.application.service; + +import static com.listywave.common.exception.ErrorCode.DUPLICATE_FOLDER_NAME_EXCEPTION; + +import com.listywave.collection.application.domain.Collect; +import com.listywave.collection.application.domain.Folder; +import com.listywave.collection.application.domain.FolderName; +import com.listywave.collection.application.dto.FindFolderResponse; +import com.listywave.collection.application.dto.FolderCreateResponse; +import com.listywave.collection.repository.CollectionRepository; +import com.listywave.collection.repository.FolderRepository; +import com.listywave.common.exception.CustomException; +import com.listywave.user.application.domain.User; +import com.listywave.user.application.service.UserService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class FolderService { + + private final UserService userService; + private final FolderRepository folderRepository; + private final CollectionRepository collectionRepository; + + public FolderCreateResponse create(Long userId, String folderName) { + User user = userService.getById(userId); + if (folderRepository.existsByNameValueAndUserId(folderName, user.getId())) { + throw new CustomException(DUPLICATE_FOLDER_NAME_EXCEPTION); + } + Folder folder = new Folder(user.getId(), new FolderName(folderName)); + folder = folderRepository.save(folder); + return new FolderCreateResponse(folder.getId()); + } + + public void updateFolder(Long loginUserId, Long folderId, String folderName) { + User user = userService.getById(loginUserId); + if (folderRepository.existsByNameValueAndUserId(folderName, user.getId())) { + throw new CustomException(DUPLICATE_FOLDER_NAME_EXCEPTION); + } + Folder folder = folderRepository.getById(folderId); + folder.updateFolderName(user.getId(), new FolderName(folderName)); + } + + public void deleteFolder(Long loginUserId, Long folderId) { + User user = userService.getById(loginUserId); + Folder folder = folderRepository.getById(folderId); + folder.validateOwner(user.getId()); + cancelCollectionsIn(folder); + folderRepository.deleteById(folderId); + } + + private void cancelCollectionsIn(Folder folder) { + List collects = collectionRepository.findAllByFolder(folder); + collects.forEach(collect -> collect.getList().decreaseCollectCount()); + collectionRepository.deleteAllByFolder(folder); + } + + public FindFolderResponse getFolders(Long loginUserId) { + User user = userService.getById(loginUserId); + return FindFolderResponse.of(folderRepository.findByFolders(user.getId())); + } +} diff --git a/src/main/java/com/listywave/collection/presentation/controller/CollectionController.java b/src/main/java/com/listywave/collection/presentation/controller/CollectionController.java index cded983e..6e85ec53 100644 --- a/src/main/java/com/listywave/collection/presentation/controller/CollectionController.java +++ b/src/main/java/com/listywave/collection/presentation/controller/CollectionController.java @@ -1,9 +1,9 @@ package com.listywave.collection.presentation.controller; -import com.listywave.collection.application.dto.CollectionResponse; +import com.listywave.collection.application.dto.CollectionFindResponse; import com.listywave.collection.application.service.CollectionService; +import com.listywave.collection.presentation.dto.FolderSelectionRequest; import com.listywave.common.auth.Auth; -import com.listywave.list.application.domain.category.CategoryType; import com.listywave.list.application.dto.response.CategoryTypeResponse; import java.util.List; import lombok.RequiredArgsConstructor; @@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -25,20 +26,21 @@ public class CollectionController { @PostMapping("/lists/{listId}/collect") ResponseEntity collectOrCancel( @PathVariable("listId") Long listId, + @RequestBody FolderSelectionRequest request, @Auth Long loginUserId ) { - collectionService.collectOrCancel(listId, loginUserId); + collectionService.collectOrCancel(listId, request.folderId(), loginUserId); return ResponseEntity.noContent().build(); } - @GetMapping("/lists/collect") - ResponseEntity getCollection( + @GetMapping("/folder/{folderId}/collections") + ResponseEntity getCollection( @Auth Long loginUserId, - @RequestParam(name = "category", defaultValue = "entire") CategoryType category, + @PathVariable("folderId") Long folderId, @RequestParam(name = "cursorId", required = false) Long cursorId, @PageableDefault(size = 10) Pageable pageable ) { - CollectionResponse collection = collectionService.getCollection(loginUserId, cursorId, pageable, category); + CollectionFindResponse collection = collectionService.getCollection(loginUserId, cursorId, pageable, folderId); return ResponseEntity.ok(collection); } diff --git a/src/main/java/com/listywave/collection/presentation/controller/FolderController.java b/src/main/java/com/listywave/collection/presentation/controller/FolderController.java new file mode 100644 index 00000000..2cfa4f0e --- /dev/null +++ b/src/main/java/com/listywave/collection/presentation/controller/FolderController.java @@ -0,0 +1,62 @@ +package com.listywave.collection.presentation.controller; + +import static org.springframework.http.HttpStatus.CREATED; + +import com.listywave.collection.application.dto.FindFolderResponse; +import com.listywave.collection.application.dto.FolderCreateResponse; +import com.listywave.collection.application.service.FolderService; +import com.listywave.collection.presentation.dto.FolderCreateRequest; +import com.listywave.collection.presentation.dto.FolderUpdateRequest; +import com.listywave.common.auth.Auth; +import lombok.RequiredArgsConstructor; +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.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class FolderController { + + private final FolderService folderService; + + @PostMapping("/folders") + ResponseEntity create( + @Auth Long loginUserId, + @RequestBody FolderCreateRequest request + ) { + FolderCreateResponse response = folderService.create(loginUserId, request.folderName()); + return ResponseEntity.status(CREATED).body(response); + } + + @PutMapping("/folders/{folderId}") + ResponseEntity update( + @Auth Long loginUserId, + @PathVariable("folderId") Long folderId, + @RequestBody FolderUpdateRequest request + ) { + folderService.updateFolder(loginUserId, folderId, request.folderName()); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/folders/{folderId}") + ResponseEntity delete( + @Auth Long loginUserId, + @PathVariable("folderId") Long folderId + ) { + folderService.deleteFolder(loginUserId, folderId); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/folders") + ResponseEntity getFolders( + @Auth Long loginUserId + ) { + FindFolderResponse response = folderService.getFolders(loginUserId); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/listywave/collection/presentation/dto/FolderCreateRequest.java b/src/main/java/com/listywave/collection/presentation/dto/FolderCreateRequest.java new file mode 100644 index 00000000..592b35df --- /dev/null +++ b/src/main/java/com/listywave/collection/presentation/dto/FolderCreateRequest.java @@ -0,0 +1,6 @@ +package com.listywave.collection.presentation.dto; + +public record FolderCreateRequest( + String folderName +) { +} diff --git a/src/main/java/com/listywave/collection/presentation/dto/FolderSelectionRequest.java b/src/main/java/com/listywave/collection/presentation/dto/FolderSelectionRequest.java new file mode 100644 index 00000000..e08918ef --- /dev/null +++ b/src/main/java/com/listywave/collection/presentation/dto/FolderSelectionRequest.java @@ -0,0 +1,6 @@ +package com.listywave.collection.presentation.dto; + +public record FolderSelectionRequest( + Long folderId +) { +} diff --git a/src/main/java/com/listywave/collection/presentation/dto/FolderUpdateRequest.java b/src/main/java/com/listywave/collection/presentation/dto/FolderUpdateRequest.java new file mode 100644 index 00000000..05d1023a --- /dev/null +++ b/src/main/java/com/listywave/collection/presentation/dto/FolderUpdateRequest.java @@ -0,0 +1,6 @@ +package com.listywave.collection.presentation.dto; + +public record FolderUpdateRequest( + String folderName +) { +} diff --git a/src/main/java/com/listywave/collection/repository/CollectionRepository.java b/src/main/java/com/listywave/collection/repository/CollectionRepository.java index 15897678..104aa83f 100644 --- a/src/main/java/com/listywave/collection/repository/CollectionRepository.java +++ b/src/main/java/com/listywave/collection/repository/CollectionRepository.java @@ -1,6 +1,7 @@ package com.listywave.collection.repository; import com.listywave.collection.application.domain.Collect; +import com.listywave.collection.application.domain.Folder; import com.listywave.collection.repository.custom.CustomCollectionRepository; import com.listywave.list.application.domain.list.ListEntity; import java.util.List; @@ -20,4 +21,10 @@ public interface CollectionRepository extends JpaRepository, Cust @Modifying @Query("delete from Collect c where c.list in :lists") void deleteAllByListIn(@Param("lists") List lists); + + @Modifying + @Query("delete from Collect c where c.folder =:folder") + void deleteAllByFolder(@Param("folder") Folder folder); + + List findAllByFolder(Folder folder); } diff --git a/src/main/java/com/listywave/collection/repository/FolderRepository.java b/src/main/java/com/listywave/collection/repository/FolderRepository.java new file mode 100644 index 00000000..7235c4ea --- /dev/null +++ b/src/main/java/com/listywave/collection/repository/FolderRepository.java @@ -0,0 +1,17 @@ +package com.listywave.collection.repository; + +import static com.listywave.common.exception.ErrorCode.RESOURCE_NOT_FOUND; + +import com.listywave.collection.application.domain.Folder; +import com.listywave.collection.repository.custom.CustomFolderRepository; +import com.listywave.common.exception.CustomException; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FolderRepository extends JpaRepository, CustomFolderRepository { + + boolean existsByNameValueAndUserId(String nameValue, Long loginUserId); + + default Folder getById(Long folderId) { + return findById(folderId).orElseThrow(() -> new CustomException(RESOURCE_NOT_FOUND)); + } +} diff --git a/src/main/java/com/listywave/collection/repository/custom/CustomCollectionRepository.java b/src/main/java/com/listywave/collection/repository/custom/CustomCollectionRepository.java index 66aa94a6..7451a3fb 100644 --- a/src/main/java/com/listywave/collection/repository/custom/CustomCollectionRepository.java +++ b/src/main/java/com/listywave/collection/repository/custom/CustomCollectionRepository.java @@ -9,7 +9,7 @@ public interface CustomCollectionRepository { - Slice getAllCollectionList(Long cursorId, Pageable pageable, Long userId, CategoryType category); + Slice getAllCollectionList(Long cursorId, Pageable pageable, Long userId, Long folderId); List getCategoriesByCollect(User user); } diff --git a/src/main/java/com/listywave/collection/repository/custom/CustomFolderRepository.java b/src/main/java/com/listywave/collection/repository/custom/CustomFolderRepository.java new file mode 100644 index 00000000..b7cfb874 --- /dev/null +++ b/src/main/java/com/listywave/collection/repository/custom/CustomFolderRepository.java @@ -0,0 +1,9 @@ +package com.listywave.collection.repository.custom; + +import com.listywave.collection.application.dto.FolderResponse; +import java.util.List; + +public interface CustomFolderRepository { + + List findByFolders(Long loginUserId); +} diff --git a/src/main/java/com/listywave/collection/repository/custom/impl/CustomCollectionRepositoryImpl.java b/src/main/java/com/listywave/collection/repository/custom/impl/CustomCollectionRepositoryImpl.java index e05ab9c5..4d469afe 100644 --- a/src/main/java/com/listywave/collection/repository/custom/impl/CustomCollectionRepositoryImpl.java +++ b/src/main/java/com/listywave/collection/repository/custom/impl/CustomCollectionRepositoryImpl.java @@ -2,7 +2,6 @@ import static com.listywave.collection.application.domain.QCollect.collect; import static com.listywave.common.util.PaginationUtils.checkEndPage; -import static com.listywave.list.application.domain.category.CategoryType.ENTIRE; import static com.listywave.list.application.domain.item.QItem.item; import static com.listywave.list.application.domain.list.QListEntity.listEntity; import static com.listywave.user.application.domain.QUser.user; @@ -24,7 +23,7 @@ public class CustomCollectionRepositoryImpl implements CustomCollectionRepositor private final JPAQueryFactory queryFactory; @Override - public Slice getAllCollectionList(Long cursorId, Pageable pageable, Long userId, CategoryType category) { + public Slice getAllCollectionList(Long cursorId, Pageable pageable, Long userId, Long folderId) { List fetch = queryFactory .selectFrom(collect) .join(collect.list, listEntity).fetchJoin() @@ -33,7 +32,7 @@ public Slice getAllCollectionList(Long cursorId, Pageable pageable, Lon .where( collectIdLt(cursorId), userIdEq(userId), - categoryEq(category) + folderIdEq(folderId) ) .distinct() .limit(pageable.getPageSize() + 1) @@ -42,15 +41,12 @@ public Slice getAllCollectionList(Long cursorId, Pageable pageable, Lon return checkEndPage(pageable, fetch); } - private BooleanExpression collectIdLt(Long cursorId) { - return cursorId == null ? null : collect.id.lt(cursorId); + private BooleanExpression folderIdEq(Long folderId) { + return folderId == 0L ? null : collect.folder.id.eq(folderId); } - private BooleanExpression categoryEq(CategoryType category) { - if (category.equals(ENTIRE)) { - return null; - } - return listEntity.category.eq(category); + private BooleanExpression collectIdLt(Long cursorId) { + return cursorId == null ? null : collect.id.lt(cursorId); } @Override diff --git a/src/main/java/com/listywave/collection/repository/custom/impl/CustomFolderRepositoryImpl.java b/src/main/java/com/listywave/collection/repository/custom/impl/CustomFolderRepositoryImpl.java new file mode 100644 index 00000000..10da21b2 --- /dev/null +++ b/src/main/java/com/listywave/collection/repository/custom/impl/CustomFolderRepositoryImpl.java @@ -0,0 +1,40 @@ +package com.listywave.collection.repository.custom.impl; + +import static com.listywave.collection.application.domain.QCollect.collect; +import static com.listywave.collection.application.domain.QFolder.folder; +import static com.querydsl.jpa.JPAExpressions.select; + +import com.listywave.collection.application.dto.FolderResponse; +import com.listywave.collection.repository.custom.CustomFolderRepository; +import com.querydsl.core.types.ExpressionUtils; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.NumberPath; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class CustomFolderRepositoryImpl implements CustomFolderRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public List findByFolders(Long loginUserId) { + NumberPath listCountAlias = Expressions.numberPath(Long.class, "listCount"); + return queryFactory + .select(Projections.constructor(FolderResponse.class, + folder.id.as("folderId"), + folder.name.value.as("folderName"), + ExpressionUtils.as( + select(collect.count()) + .from(collect) + .where(collect.folder.id.eq(folder.id)), listCountAlias) + ) + ) + .from(folder) + .where(folder.userId.eq(loginUserId)) + .orderBy(folder.updatedDate.desc()) + .fetch(); + } +} diff --git a/src/main/java/com/listywave/common/auth/AuthorizationInterceptor.java b/src/main/java/com/listywave/common/auth/AuthorizationInterceptor.java index a4316332..43a79d91 100644 --- a/src/main/java/com/listywave/common/auth/AuthorizationInterceptor.java +++ b/src/main/java/com/listywave/common/auth/AuthorizationInterceptor.java @@ -1,13 +1,16 @@ package com.listywave.common.auth; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; import static org.springframework.http.HttpMethod.GET; import static org.springframework.http.HttpMethod.OPTIONS; +import com.listywave.auth.application.domain.JwtManager; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.util.Arrays; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpMethod; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.method.HandlerMethod; @@ -18,12 +21,13 @@ public class AuthorizationInterceptor implements HandlerInterceptor { private static final UriAndMethod[] whiteList = { - new UriAndMethod("/lists/explore", GET), + new UriAndMethod("/lists/recommend", GET), new UriAndMethod("/lists/search", GET), new UriAndMethod("/lists/{listId}/comments", GET), new UriAndMethod("/lists/upload-url", GET), new UriAndMethod("/lists/upload-complete", GET), new UriAndMethod("/lists/{listId}/histories", GET), + new UriAndMethod("/lists", GET), new UriAndMethod("/users/{userId}/lists", GET), new UriAndMethod("/users/{userId}/followers", GET), new UriAndMethod("/users/{userId}/followings", GET), @@ -33,10 +37,12 @@ public class AuthorizationInterceptor implements HandlerInterceptor { new UriAndMethod("/categories", GET), new UriAndMethod("/users/basic-profile-image", GET), new UriAndMethod("/users/basic-background-image", GET), + new UriAndMethod("/topics", GET), + new UriAndMethod("/users/nickname-validate", GET) }; + private final JwtManager jwtManager; private final AuthContext authContext; - private final TokenReader tokenReader; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { @@ -46,11 +52,11 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons if (!(handler instanceof HandlerMethod)) { return true; } - if (isNonRequiredAuthentication(handler)) { + if (doesNotRequiredAuthentication(handler)) { return true; } - Long userId = tokenReader.readAccessToken(request); + Long userId = readAccessToken(request); authContext.setUserId(userId); return true; } @@ -59,7 +65,7 @@ private boolean isPreflight(HttpServletRequest request) { return request.getMethod().equals(OPTIONS.name()); } - private boolean isNonRequiredAuthentication(Object handler) { + private boolean doesNotRequiredAuthentication(Object handler) { HandlerMethod handlerMethod = (HandlerMethod) handler; RequestMapping requestMapping = handlerMethod.getMethodAnnotation(RequestMapping.class); String mappingUri = requestMapping.value()[0]; @@ -68,14 +74,22 @@ private boolean isNonRequiredAuthentication(Object handler) { return Arrays.stream(whiteList) .anyMatch(it -> it.isMatch(mappingUri, mappingMethod)); } -} -record UriAndMethod( - String uri, - HttpMethod method -) { + @Nullable + private Long readAccessToken(HttpServletRequest request) { + String authorizationValue = request.getHeader(AUTHORIZATION); + if (authorizationValue == null || authorizationValue.isBlank()) { + return null; + } + return jwtManager.readTokenWithPrefix(authorizationValue); + } - public boolean isMatch(String mappingUrl, HttpMethod method) { - return this.uri.equals(mappingUrl) && this.method.equals(method); + private record UriAndMethod( + String uri, + HttpMethod method + ) { + public boolean isMatch(String mappingUrl, HttpMethod method) { + return this.uri.equals(mappingUrl) && this.method.equals(method); + } } } diff --git a/src/main/java/com/listywave/common/auth/TokenReader.java b/src/main/java/com/listywave/common/auth/TokenReader.java deleted file mode 100644 index 3008f873..00000000 --- a/src/main/java/com/listywave/common/auth/TokenReader.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.listywave.common.auth; - -import static org.springframework.http.HttpHeaders.AUTHORIZATION; - -import com.listywave.auth.application.domain.JwtManager; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; -import java.util.Arrays; -import lombok.RequiredArgsConstructor; -import org.springframework.lang.Nullable; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class TokenReader { - - private final JwtManager jwtManager; - - @Nullable - public Long readAccessToken(HttpServletRequest request) { - String authorizationValue = request.getHeader(AUTHORIZATION); - if (authorizationValue != null && !authorizationValue.isBlank()) { - return jwtManager.readTokenWithPrefix(authorizationValue); - } - - Cookie[] cookies = request.getCookies(); - if (cookies == null || cookies.length == 0) { - return null; - } - return Arrays.stream(cookies) - .filter(cookie -> cookie.getName().equals("accessToken") || cookie.getName().equals("refreshToken")) - .findFirst() - .map(cookie -> jwtManager.readTokenWithoutPrefix(cookie.getValue())) - .orElse(null); - } -} diff --git a/src/main/java/com/listywave/common/config/QuerydslConfig.java b/src/main/java/com/listywave/common/config/QuerydslConfig.java index 1967c990..ae83b630 100644 --- a/src/main/java/com/listywave/common/config/QuerydslConfig.java +++ b/src/main/java/com/listywave/common/config/QuerydslConfig.java @@ -9,7 +9,7 @@ public class QuerydslConfig { @Bean - JPAQueryFactory jpaQueryFactory(EntityManager em){ + JPAQueryFactory jpaQueryFactory(EntityManager em) { return new JPAQueryFactory(em); } } diff --git a/src/main/java/com/listywave/common/config/WebConfig.java b/src/main/java/com/listywave/common/config/WebConfig.java index 689a2cb8..fa4f34a7 100644 --- a/src/main/java/com/listywave/common/config/WebConfig.java +++ b/src/main/java/com/listywave/common/config/WebConfig.java @@ -13,7 +13,7 @@ public class WebConfig implements WebMvcConfigurer { @Value("${cors.allowedOrigins}") private String[] allowedOrigins; - + @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") diff --git a/src/main/java/com/listywave/common/encrypt/Aes256Cipher.java b/src/main/java/com/listywave/common/encrypt/Aes256Cipher.java new file mode 100644 index 00000000..11b74393 --- /dev/null +++ b/src/main/java/com/listywave/common/encrypt/Aes256Cipher.java @@ -0,0 +1,82 @@ +package com.listywave.common.encrypt; + +import static com.listywave.common.exception.ErrorCode.ENCRYPT_ERROR; + +import com.listywave.common.exception.CustomException; +import jakarta.annotation.PostConstruct; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.KeySpec; +import java.util.Base64; +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class Aes256Cipher { + + private static final String ALGORITHM = "AES/CBC/PKCS5Padding"; + + @Value("${aes.password}") + private String password; + @Value("${aes.salt}") + private String salt; + + private SecretKey secretKey; + private IvParameterSpec iv; + + @PostConstruct + public void init() throws NoSuchAlgorithmException, InvalidKeySpecException { + this.secretKey = createSecretKey(); + this.iv = createIv(); + } + + private SecretKey createSecretKey() throws NoSuchAlgorithmException, InvalidKeySpecException { + SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); + KeySpec keySpec = new PBEKeySpec(password.toCharArray(), salt.getBytes(), 65536, 256); + return new SecretKeySpec(secretKeyFactory.generateSecret(keySpec).getEncoded(), "AES"); + } + + private IvParameterSpec createIv() { + byte[] iv = new byte[16]; + new SecureRandom().nextBytes(iv); + return new IvParameterSpec(iv); + } + + public String encrypt(String plainText) { + try { + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, secretKey, iv); + byte[] cipherText = cipher.doFinal(plainText.getBytes()); + return Base64.getEncoder().encodeToString(cipherText); + } catch ( + NoSuchPaddingException | IllegalBlockSizeException | NoSuchAlgorithmException | + InvalidAlgorithmParameterException | BadPaddingException | InvalidKeyException e) { + throw new CustomException(ENCRYPT_ERROR, e.getMessage()); + } + } + + public String decrypt(String cipherText) { + try { + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, secretKey, iv); + byte[] plainText = cipher.doFinal(Base64.getDecoder().decode(cipherText)); + return new String(plainText); + } catch ( + NoSuchPaddingException | IllegalBlockSizeException | NoSuchAlgorithmException | + InvalidAlgorithmParameterException | BadPaddingException | InvalidKeyException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/com/listywave/common/encrypt/Sha256Cipher.java b/src/main/java/com/listywave/common/encrypt/Sha256Cipher.java new file mode 100644 index 00000000..ed5fc6da --- /dev/null +++ b/src/main/java/com/listywave/common/encrypt/Sha256Cipher.java @@ -0,0 +1,36 @@ +package com.listywave.common.encrypt; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class Sha256Cipher { + + @Value("${sha.salt}") + private String salt; + + public String encrypt(String plainText) { + try { + MessageDigest messageDigest = MessageDigest.getInstance("SHA-256"); + byte[] encodedHash = messageDigest.digest(plainText.concat(salt).getBytes()); + return bytesToHex(encodedHash); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + private String bytesToHex(byte[] encodedHash) { + StringBuilder hexString = new StringBuilder(2 * encodedHash.length); + + for (byte hash : encodedHash) { + String hex = Integer.toHexString(0xff & hash); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } +} diff --git a/src/main/java/com/listywave/common/exception/CustomException.java b/src/main/java/com/listywave/common/exception/CustomException.java index c64a1bf2..2c9c6004 100644 --- a/src/main/java/com/listywave/common/exception/CustomException.java +++ b/src/main/java/com/listywave/common/exception/CustomException.java @@ -1,14 +1,17 @@ package com.listywave.common.exception; -import lombok.AllArgsConstructor; import lombok.Getter; @Getter -@AllArgsConstructor -public class CustomException extends RuntimeException{ +public class CustomException extends RuntimeException { private final ErrorCode errorCode; + public CustomException(ErrorCode errorCode) { + super(errorCode.getDetail()); + this.errorCode = errorCode; + } + public CustomException(ErrorCode errorCode, String message) { super(message); this.errorCode = errorCode; diff --git a/src/main/java/com/listywave/common/exception/ErrorCode.java b/src/main/java/com/listywave/common/exception/ErrorCode.java index 4f2a654f..9bd4c187 100644 --- a/src/main/java/com/listywave/common/exception/ErrorCode.java +++ b/src/main/java/com/listywave/common/exception/ErrorCode.java @@ -21,10 +21,11 @@ public enum ErrorCode { INVALID_ACCESS_TOKEN(UNAUTHORIZED, "유효하지 않은 AccessToken 입니다. 다시 로그인해주세요."), INVALID_ACCESS(FORBIDDEN, "접근 권한이 존재하지 않습니다."), CANNOT_COLLECT_OWN_LIST(BAD_REQUEST, "리스트 작성자는 자신의 리스트에 콜렉트할 수 없습니다."), - CANNOT_SEND_OWN_ALARM(BAD_REQUEST, "알람을 자신에게 보낼 수 없습니다."), + ENCRYPT_ERROR(INTERNAL_SERVER_ERROR, "암호화 과정 중 문제가 발생했습니다."), // Http Request METHOD_ARGUMENT_TYPE_MISMATCH(BAD_REQUEST, "요청 한 값 타입이 잘못되어 binding에 실패하였습니다."), + METHOD_ARGUMENT_NOT_VALID_EXCEPTION(BAD_REQUEST, "요청에 담긴 값에 문제가 있습니다."), RESOURCE_NOT_FOUND(NOT_FOUND, "대상이 존재하지 않습니다."), RESOURCES_EMPTY(NOT_FOUND, "해당 대상들이 존재하지 않습니다."), ELASTICSEARCH_REQUEST_FAILED(BAD_REQUEST, "Elasticsearch 검색 요청에 실패했습니다."), @@ -44,6 +45,10 @@ public enum ErrorCode { ALREADY_LOGOUT_EXCEPTION(BAD_REQUEST, "이미 로그아웃 처리가 된 상태입니다."), DUPLICATE_NICKNAME_EXCEPTION(BAD_REQUEST, "중복된 닉네임입니다."), DUPLICATE_COLLABORATOR_EXCEPTION(BAD_REQUEST, "이미 동일한 콜라보레이터가 존재합니다"), + DUPLICATE_FOLDER_NAME_EXCEPTION(BAD_REQUEST, "중복된 폴더명입니다."), + NULL_OR_BLANK_EXCEPTION(BAD_REQUEST, "값이 null이거나 공백일 수 없습니다."), + NOT_EXIST_CODE(BAD_REQUEST, "존재하지 않는 코드입니다."), + ALREADY_SENT_ALARM_NOTICE(BAD_REQUEST, "이미 발송된 공지입니다."), // S3 S3_DELETE_OBJECTS_EXCEPTION(INTERNAL_SERVER_ERROR, "S3의 이미지를 삭제 요청하는 과정에서 에러가 발생했습니다."), diff --git a/src/main/java/com/listywave/common/exception/ErrorResponse.java b/src/main/java/com/listywave/common/exception/ErrorResponse.java index 20a42459..b67a04c8 100644 --- a/src/main/java/com/listywave/common/exception/ErrorResponse.java +++ b/src/main/java/com/listywave/common/exception/ErrorResponse.java @@ -19,7 +19,7 @@ public static ResponseEntity toResponseEntity(CustomException e) errorCode.getDetail(), e.getMessage() ); - + return ResponseEntity.status(errorCode.getStatus()).body(errorResponse); } } diff --git a/src/main/java/com/listywave/common/exception/GlobalExceptionHandler.java b/src/main/java/com/listywave/common/exception/GlobalExceptionHandler.java index 301a1c63..8a733c4c 100644 --- a/src/main/java/com/listywave/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/listywave/common/exception/GlobalExceptionHandler.java @@ -1,19 +1,23 @@ package com.listywave.common.exception; import static com.listywave.common.exception.ErrorCode.INVALID_ACCESS_TOKEN; -import static com.listywave.common.exception.ErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH; -import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; +import static com.listywave.common.exception.ErrorCode.METHOD_ARGUMENT_NOT_VALID_EXCEPTION; import static org.springframework.http.HttpStatus.UNAUTHORIZED; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.security.SignatureException; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.context.request.WebRequest; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; @Slf4j @@ -29,16 +33,32 @@ ResponseEntity handleCustomException(CustomException e) { } @ExceptionHandler(Exception.class) - protected ResponseEntity handleException(Exception e) { + protected ResponseEntity handleException(Exception e) { log.error("[InternalServerError] : {}", e.getMessage(), e); - return ResponseEntity.status(INTERNAL_SERVER_ERROR).build(); + return ResponseEntity.internalServerError().body(e.getMessage()); } - @ExceptionHandler(MethodArgumentTypeMismatchException.class) - ResponseEntity handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) { - log.error("[MethodArgumentTypeMismatchException] : {}", e.getMessage(), e); - CustomException customException = new CustomException(METHOD_ARGUMENT_TYPE_MISMATCH); - return ErrorResponse.toResponseEntity(customException); + @ExceptionHandler(IllegalArgumentException.class) + ResponseEntity handleIllegalArgumentException(IllegalArgumentException e) { + log.error("[IllegalArgumentException] : {}", e.getMessage(), e); + return ResponseEntity.badRequest().body(e.getMessage()); + } + + @Override + protected ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException e, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request + ) { + log.error("[MethodArgumentNotValidException] : {}", e.getMessage(), e); + String errorMessage = e.getFieldErrors() + .stream() + .map(DefaultMessageSourceResolvable::getDefaultMessage) + .collect(Collectors.joining(", ")); + ErrorCode errorCode = METHOD_ARGUMENT_NOT_VALID_EXCEPTION; + ErrorResponse errorResponse = new ErrorResponse(status.value(), errorMessage, errorCode.name(), errorCode.getDetail(), errorMessage); + return ResponseEntity.badRequest().body(errorResponse); } @ExceptionHandler(SignatureException.class) @@ -60,4 +80,10 @@ ResponseEntity handleMalformedJwtException(MalformedJwtException e) { log.error("[MalformedJwtException] : {}", e.getMessage(), e); return ResponseEntity.status(UNAUTHORIZED).build(); } + + @ExceptionHandler(NullPointerException.class) + ResponseEntity handleNullPointerException(NullPointerException e) { + log.error("[NullPointerException] : {}", e.getMessage(), e); + return ResponseEntity.internalServerError().body("NullPointException이 발생했습니다. " + e.getMessage()); + } } diff --git a/src/main/java/com/listywave/common/util/DataUpdateUtils.java b/src/main/java/com/listywave/common/util/DataUpdateUtils.java new file mode 100644 index 00000000..12fa1e68 --- /dev/null +++ b/src/main/java/com/listywave/common/util/DataUpdateUtils.java @@ -0,0 +1,18 @@ +package com.listywave.common.util; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public abstract class DataUpdateUtils { + + public static void update(List before, List after) { + Set removable = new HashSet<>(before); + after.forEach(removable::remove); + before.removeAll(removable); + + Set addable = new HashSet<>(after); + before.forEach(addable::remove); + before.addAll(addable); + } +} diff --git a/src/main/java/com/listywave/image/application/domain/ImageFileExtension.java b/src/main/java/com/listywave/image/application/domain/ImageFileExtension.java index 40d199a9..560d8434 100644 --- a/src/main/java/com/listywave/image/application/domain/ImageFileExtension.java +++ b/src/main/java/com/listywave/image/application/domain/ImageFileExtension.java @@ -1,31 +1,27 @@ package com.listywave.image.application.domain; +import static com.listywave.common.exception.ErrorCode.RESOURCE_NOT_FOUND; + import com.fasterxml.jackson.annotation.JsonCreator; import com.listywave.common.exception.CustomException; -import com.listywave.common.exception.ErrorCode; import java.util.Arrays; import lombok.AllArgsConstructor; import lombok.Getter; -// TODO: 정수씨 이 쪽 나중에 테스트 부탁해요~ -// TODO: ImageService 쪽에서 많이 쓰이고 있어서 쉽게 리팩터링 못하겠네요.. -// TODO: uploadExtension 필드 제거하고 `fromString`을 `ofName` 로 바꿔주세요! @Getter @AllArgsConstructor public enum ImageFileExtension { - JPEG("jpeg"), - JPG("jpg"), - PNG("png"), + JPEG, + JPG, + PNG, ; - private final String uploadExtension; - @JsonCreator - public static ImageFileExtension fromString(String key) { + public static ImageFileExtension ofName(String value) { return Arrays.stream(ImageFileExtension.values()) - .filter(extensionType -> extensionType.name().equalsIgnoreCase(key)) + .filter(extensionType -> extensionType.name().equalsIgnoreCase(value)) .findFirst() - .orElseThrow(() -> new CustomException(ErrorCode.RESOURCE_NOT_FOUND, "해당 이미지 확장자가 존재하지 않습니다.")); + .orElseThrow(() -> new CustomException(RESOURCE_NOT_FOUND, "지원하지 않는 이미지 확장자입니다.")); } } diff --git a/src/main/java/com/listywave/image/application/domain/ImageType.java b/src/main/java/com/listywave/image/application/domain/ImageType.java index c6b820bc..a4d88036 100644 --- a/src/main/java/com/listywave/image/application/domain/ImageType.java +++ b/src/main/java/com/listywave/image/application/domain/ImageType.java @@ -10,5 +10,6 @@ public enum ImageType { LISTS_ITEM, USER_PROFILE, USER_BACKGROUND, + NOTICE, ; } diff --git a/src/main/java/com/listywave/image/application/dto/response/ItemPresignedUrlResponse.java b/src/main/java/com/listywave/image/application/dto/response/ItemPresignedUrlResponse.java deleted file mode 100644 index 10b613b4..00000000 --- a/src/main/java/com/listywave/image/application/dto/response/ItemPresignedUrlResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.listywave.image.application.dto.response; - -import lombok.Builder; - -@Builder -public record ItemPresignedUrlResponse(int rank, String presignedUrl) { - public static ItemPresignedUrlResponse of(int rank, String presignedUrl) { - return ItemPresignedUrlResponse.builder() - .rank(rank) - .presignedUrl(presignedUrl) - .build(); - } -} diff --git a/src/main/java/com/listywave/image/application/dto/response/ListItemPresignedUrlResponse.java b/src/main/java/com/listywave/image/application/dto/response/ListItemPresignedUrlResponse.java new file mode 100644 index 00000000..44ed424e --- /dev/null +++ b/src/main/java/com/listywave/image/application/dto/response/ListItemPresignedUrlResponse.java @@ -0,0 +1,11 @@ +package com.listywave.image.application.dto.response; + +public record ListItemPresignedUrlResponse( + int rank, + String presignedUrl +) { + + public static ListItemPresignedUrlResponse from(int rank, String presignedUrl) { + return new ListItemPresignedUrlResponse(rank, presignedUrl); + } +} diff --git a/src/main/java/com/listywave/image/application/dto/response/UserPresignedUrlCreateResponse.java b/src/main/java/com/listywave/image/application/dto/response/UserPresignedUrlCreateResponse.java new file mode 100644 index 00000000..3afa6e24 --- /dev/null +++ b/src/main/java/com/listywave/image/application/dto/response/UserPresignedUrlCreateResponse.java @@ -0,0 +1,12 @@ +package com.listywave.image.application.dto.response; + +public record UserPresignedUrlCreateResponse( + Long userId, + String profilePresignedUrl, + String backgroundPresignedUrl +) { + + public static UserPresignedUrlCreateResponse of(Long userId, String profilePresignedUrl, String backgroundPresignedUrl) { + return new UserPresignedUrlCreateResponse(userId, profilePresignedUrl, backgroundPresignedUrl); + } +} diff --git a/src/main/java/com/listywave/image/application/dto/response/UserPresignedUrlResponse.java b/src/main/java/com/listywave/image/application/dto/response/UserPresignedUrlResponse.java deleted file mode 100644 index 14ef954c..00000000 --- a/src/main/java/com/listywave/image/application/dto/response/UserPresignedUrlResponse.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.listywave.image.application.dto.response; - -import lombok.Builder; - -@Builder -public record UserPresignedUrlResponse(Long ownerId, String profilePresignedUrl, String backgroundPresignedUrl) { - - public static UserPresignedUrlResponse of(Long ownerId, String profilePresignedUrl, String backgroundPresignedUrl) { - return UserPresignedUrlResponse.builder() - .ownerId(ownerId) - .profilePresignedUrl(profilePresignedUrl) - .backgroundPresignedUrl(backgroundPresignedUrl) - .build(); - } -} diff --git a/src/main/java/com/listywave/image/application/service/ImageService.java b/src/main/java/com/listywave/image/application/service/ImageService.java index a9a74440..c667367b 100644 --- a/src/main/java/com/listywave/image/application/service/ImageService.java +++ b/src/main/java/com/listywave/image/application/service/ImageService.java @@ -1,32 +1,42 @@ package com.listywave.image.application.service; +import static com.amazonaws.HttpMethod.PUT; +import static com.amazonaws.services.s3.Headers.S3_CANNED_ACL; +import static com.amazonaws.services.s3.model.CannedAccessControlList.PublicRead; +import static com.listywave.common.exception.ErrorCode.RESOURCE_NOT_FOUND; import static com.listywave.common.exception.ErrorCode.S3_DELETE_OBJECTS_EXCEPTION; import static com.listywave.image.application.domain.ImageType.LISTS_ITEM; +import static com.listywave.image.application.domain.ImageType.NOTICE; +import static com.listywave.image.application.domain.ImageType.USER_BACKGROUND; +import static com.listywave.image.application.domain.ImageType.USER_PROFILE; import static java.util.Locale.ENGLISH; import com.amazonaws.AmazonServiceException; -import com.amazonaws.HttpMethod; import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.Headers; -import com.amazonaws.services.s3.model.CannedAccessControlList; import com.amazonaws.services.s3.model.DeleteObjectRequest; import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; import com.amazonaws.services.s3.model.ListObjectsV2Result; import com.amazonaws.services.s3.model.S3ObjectSummary; import com.listywave.common.exception.CustomException; -import com.listywave.common.exception.ErrorCode; import com.listywave.image.application.domain.ImageFileExtension; import com.listywave.image.application.domain.ImageType; -import com.listywave.image.application.dto.ExtensionRanks; -import com.listywave.image.application.dto.response.ItemPresignedUrlResponse; -import com.listywave.image.application.dto.response.UserPresignedUrlResponse; +import com.listywave.image.application.dto.response.ListItemPresignedUrlResponse; +import com.listywave.image.application.dto.response.UserPresignedUrlCreateResponse; +import com.listywave.image.presentation.dto.request.ListImagesCreateRequest.ExtensionRanks; import com.listywave.list.application.domain.item.Item; import com.listywave.list.application.domain.item.ItemImageUrl; import com.listywave.list.application.domain.list.ListEntity; import com.listywave.list.repository.ItemRepository; import com.listywave.list.repository.list.ListRepository; +import com.listywave.notice.application.domain.Notice; +import com.listywave.notice.application.domain.NoticeContent; +import com.listywave.notice.application.dto.NoticeImagePresignedUrlCreateResponse; +import com.listywave.notice.application.dto.OrderAndExtensionDto; +import com.listywave.notice.repository.NoticeContentRepository; +import com.listywave.notice.repository.NoticeRepository; import com.listywave.user.application.domain.User; import com.listywave.user.repository.user.UserRepository; +import java.net.URL; import java.util.Arrays; import java.util.Date; import java.util.List; @@ -54,118 +64,123 @@ public class ImageService { private final ItemRepository itemRepository; private final ListRepository listRepository; private final UserRepository userRepository; + private final NoticeRepository noticeRepository; + private final NoticeContentRepository noticeContentRepository; - public List createListsPresignedUrl(Long loginUserId, Long listId, List extensionRanks) { - User user = userRepository.getById(loginUserId); - - ListEntity list = findListById(listId); - validateListUserMismatch(list, user); + public List createPresignedUrlOfItem(Long userId, Long listId, List extensionRanks) { + User user = userRepository.getById(userId); + ListEntity list = listRepository.getById(listId); + list.validateOwner(user); return extensionRanks.stream() - .map((extensionRank) -> { - String imageKey = generatedUUID(); - GeneratePresignedUrlRequest generatePresignedUrlRequest = - getGeneratePresignedUrl(LISTS_ITEM, listId, imageKey, extensionRank.extension()); - updateItemImageKey(listId, extensionRank, imageKey); - - return ItemPresignedUrlResponse.of( - extensionRank.rank(), - amazonS3.generatePresignedUrl(generatePresignedUrlRequest).toString()); + .map(it -> { + Item item = itemRepository.findByListIdAndRanking(listId, it.rank()) + .orElseThrow(() -> new CustomException(RESOURCE_NOT_FOUND)); + String imageKey = UUID.randomUUID().toString(); + item.updateItemImageKey(imageKey); + + String fileName = createFileName(LISTS_ITEM, listId, imageKey, it.extension()); + GeneratePresignedUrlRequest request = createGeneratePreSignedUrlRequest(fileName); + + String presignedUrl = amazonS3.generatePresignedUrl(request).toString(); + return ListItemPresignedUrlResponse.from(it.rank(), presignedUrl); } - ) - .toList(); + ).toList(); } - public void uploadCompleteItemImages(Long loginUserId, Long listId, List extensionRanks) { - final User user = userRepository.getById(loginUserId); - - ListEntity list = findListById(listId); - validateListUserMismatch(list, user); + private String createFileName( + ImageType imageType, + Long resourceId, + String imageKey, + ImageFileExtension imageFileExtension + ) { + return getCurrentProfile() + + "/" + imageType.name().toLowerCase(ENGLISH) + + "/" + resourceId + + "/" + imageKey + + "." + imageFileExtension.name().toLowerCase(ENGLISH); + } - extensionRanks.forEach( - extensionRank -> { - Item item = findItem(listId, extensionRank.rank()); - String imageUrl = createReadImageUrl(LISTS_ITEM, listId, item.getImageKey(), extensionRank.extension()); - item.updateItemImageUrl(imageUrl); - } - ); + public String getCurrentProfile() { + return Arrays.stream(environment.getActiveProfiles()) + .filter(profile -> profile.equals("dev") || profile.equals("prod")) + .findFirst() + .orElse(LOCAL); } - public UserPresignedUrlResponse updateUserImagePresignedUrl( - ImageFileExtension profileExtension, - ImageFileExtension backgroundExtension, - Long loginUserId - ) { - User user = userRepository.getById(loginUserId); - - if (isExistProfileExtension(profileExtension, backgroundExtension)) { - deleteCustomUserImageFile(user.getProfileImageUrl()); - return getUserPresignedUrlResponse( - ImageType.USER_PROFILE, - null, - profileExtension, - backgroundExtension, - user, - false - ); - } + private GeneratePresignedUrlRequest createGeneratePreSignedUrlRequest(String fileName) { + var request = new GeneratePresignedUrlRequest(bucket, fileName, PUT) + .withExpiration(createPresignedUrlExpiration()); + request.addRequestParameter(S3_CANNED_ACL, PublicRead.toString()); - if (isExistBackgroundExtension(backgroundExtension, profileExtension)) { - deleteCustomUserImageFile(user.getBackgroundImageUrl()); - return getUserPresignedUrlResponse( - null, - ImageType.USER_BACKGROUND, - profileExtension, - backgroundExtension, - user, - false - ); - } + return request; + } + + private Date createPresignedUrlExpiration() { + Date expiration = new Date(); + var expTimeMillis = expiration.getTime(); + expTimeMillis += 1000 * 60 * 30; + expiration.setTime(expTimeMillis); + return expiration; + } - deleteCustomUserImageFile(user.getProfileImageUrl()); - deleteCustomUserImageFile(user.getBackgroundImageUrl()); + public void updateAllItemsImageUrl(Long userId, Long listId, List extensionRanks) { + User user = userRepository.getById(userId); + ListEntity list = listRepository.getById(listId); + list.validateOwner(user); - return getUserPresignedUrlResponse( - ImageType.USER_PROFILE, - ImageType.USER_BACKGROUND, - profileExtension, - backgroundExtension, - user, - true + extensionRanks.forEach(it -> { + Item item = itemRepository.findByListIdAndRanking(listId, it.rank()) + .orElseThrow(() -> new CustomException(RESOURCE_NOT_FOUND, "해당 아이템이 존재하지 않습니다.")); + String imageUrl = createReadImageUrl(LISTS_ITEM, listId, item.getImageKey(), it.extension()); + item.updateItemImageUrl(new ItemImageUrl(imageUrl)); + } ); } - public void uploadCompleteUserImages( + private String createReadImageUrl( + ImageType imageType, + Long resourceId, + String imageKey, + ImageFileExtension imageFileExtension + ) { + return IMAGE_DOMAIN_URL + + "/" + getCurrentProfile() + + "/" + imageType.name().toLowerCase(ENGLISH) + + "/" + resourceId + + "/" + imageKey + + "." + imageFileExtension.name().toLowerCase(ENGLISH); + } + + public UserPresignedUrlCreateResponse createPresignedUrlOfUserImage( ImageFileExtension profileExtension, ImageFileExtension backgroundExtension, - Long ownerId + Long userId ) { - User user = userRepository.getById(ownerId); + User user = userRepository.getById(userId); - String profileImageUrl = ""; - String backgroundImageUrl = ""; - boolean isBoth = true; + String profileImageKey = UUID.randomUUID().toString(); + String backgroundImageKey = UUID.randomUUID().toString(); - if (isExistProfileExtension(profileExtension, backgroundExtension)) { - profileImageUrl = createReadImageUrl(ImageType.USER_PROFILE, user.getId(), user.getProfileImageUrl(), profileExtension); - user.updateUserImageUrl(profileImageUrl, backgroundImageUrl); - isBoth = false; - } + String profilePresignedUrl = ""; + String backgroundPresignedUrl = ""; + + if (profileExtension != null) { + deleteUserImageFileIfCustomImage(user.getProfileImageUrl()); - if (isExistBackgroundExtension(backgroundExtension, profileExtension)) { - backgroundImageUrl = createReadImageUrl(ImageType.USER_BACKGROUND, user.getId(), user.getBackgroundImageUrl(), backgroundExtension); - user.updateUserImageUrl(profileImageUrl, backgroundImageUrl); - isBoth = false; + var presignedUrlRequest = createUserGeneratePresignedUrlRequest(USER_PROFILE, profileExtension, user, profileImageKey, ""); + profilePresignedUrl = amazonS3.generatePresignedUrl(presignedUrlRequest).toString(); } + if (backgroundExtension != null) { + deleteUserImageFileIfCustomImage(user.getBackgroundImageUrl()); - if (isBoth) { - profileImageUrl = createReadImageUrl(ImageType.USER_PROFILE, user.getId(), user.getProfileImageUrl(), profileExtension); - backgroundImageUrl = createReadImageUrl(ImageType.USER_BACKGROUND, user.getId(), user.getBackgroundImageUrl(), backgroundExtension); - user.updateUserImageUrl(profileImageUrl, backgroundImageUrl); + var presignedUrlRequest = createUserGeneratePresignedUrlRequest(USER_BACKGROUND, backgroundExtension, user, "", backgroundImageKey); + backgroundPresignedUrl = amazonS3.generatePresignedUrl(presignedUrlRequest).toString(); } + return UserPresignedUrlCreateResponse.of(userId, profilePresignedUrl, backgroundPresignedUrl); } - private void deleteCustomUserImageFile(String imageUrl) { + private void deleteUserImageFileIfCustomImage(String imageUrl) { if (isCustomUserImage(imageUrl)) { String fileFullPath = getFileFullName(imageUrl); deleteImageFile(fileFullPath); @@ -173,21 +188,14 @@ private void deleteCustomUserImageFile(String imageUrl) { } private boolean isCustomUserImage(String url) { - if (url.split("/").length >= 4) { - String type = url.split("/")[3]; + String[] split = url.split("/"); + if (split.length >= 4) { + String type = split[3]; return !type.equals("basic"); } return false; } - private void deleteImageFile(String fileFullPath) { - try { - amazonS3.deleteObject(bucket, fileFullPath); - } catch (AmazonServiceException e) { - throw new CustomException(ErrorCode.S3_DELETE_OBJECTS_EXCEPTION); - } - } - private String getFileFullName(String url) { String[] parts = url.split("/"); StringBuilder extracted = new StringBuilder(); @@ -200,63 +208,15 @@ private String getFileFullName(String url) { return extracted.toString(); } - private UserPresignedUrlResponse getUserPresignedUrlResponse( - ImageType profileImageType, - ImageType backgroundImageType, - ImageFileExtension profileExtension, - ImageFileExtension backgroundExtension, - User user, - Boolean isBoth - ) { - if (!isBoth && profileImageType != null) { - return generateUserPresignedUrlResponse(profileImageType, profileExtension, user, generatedUUID(), ""); - } - if (!isBoth && backgroundImageType != null) { - return generateUserPresignedUrlResponse(backgroundImageType, backgroundExtension, user, "", generatedUUID()); + private void deleteImageFile(String filePath) { + try { + amazonS3.deleteObject(bucket, filePath); + } catch (AmazonServiceException e) { + throw new CustomException(S3_DELETE_OBJECTS_EXCEPTION); } - return generateUserPresignedUrlResponseByBoth(profileImageType, backgroundImageType, profileExtension, backgroundExtension, user); - } - - private UserPresignedUrlResponse generateUserPresignedUrlResponseByBoth( - ImageType profileImageType, - ImageType backgroundImageType, - ImageFileExtension profileExtension, - ImageFileExtension backgroundExtension, - User user - ) { - String profileImageKey = generatedUUID(); - String backgroundImageKey = generatedUUID(); - - GeneratePresignedUrlRequest profileUrlRequest = - getGeneratePresignedUrl(profileImageType, user.getId(), profileImageKey, profileExtension); - GeneratePresignedUrlRequest backgroundUrlRequest = - getGeneratePresignedUrlRequest(backgroundImageType, backgroundExtension, user, profileImageKey, backgroundImageKey); - return UserPresignedUrlResponse.of( - user.getId(), - amazonS3.generatePresignedUrl(profileUrlRequest).toString(), - amazonS3.generatePresignedUrl(backgroundUrlRequest).toString() - ); - } - - private UserPresignedUrlResponse generateUserPresignedUrlResponse( - ImageType imageType, - ImageFileExtension extension, - User user, - String profileImageKey, - String backgroundImageKey - ) { - GeneratePresignedUrlRequest presignedUrlRequest = - getGeneratePresignedUrlRequest(imageType, extension, user, profileImageKey, backgroundImageKey); - return UserPresignedUrlResponse.of( - user.getId(), - imageType == ImageType.USER_PROFILE ? - amazonS3.generatePresignedUrl(presignedUrlRequest).toString() : "", - imageType == ImageType.USER_BACKGROUND ? - amazonS3.generatePresignedUrl(presignedUrlRequest).toString() : "" - ); } - private GeneratePresignedUrlRequest getGeneratePresignedUrlRequest( + private GeneratePresignedUrlRequest createUserGeneratePresignedUrlRequest( ImageType imageType, ImageFileExtension extension, User user, @@ -264,146 +224,36 @@ private GeneratePresignedUrlRequest getGeneratePresignedUrlRequest( String backgroundImageKey ) { String imageKey = ""; - if (imageType == ImageType.USER_PROFILE) { + if (imageType == USER_PROFILE) { imageKey = profileImageKey; } - if (imageType == ImageType.USER_BACKGROUND) { + if (imageType == USER_BACKGROUND) { imageKey = backgroundImageKey; } - GeneratePresignedUrlRequest generatePresignedUrlRequest = - getGeneratePresignedUrl(imageType, user.getId(), imageKey, extension); - updateUserImageKey(user, profileImageKey, backgroundImageKey); - return generatePresignedUrlRequest; - } + user.updateUserImageUrl(profileImageKey, backgroundImageKey); - private boolean isExistProfileExtension( - ImageFileExtension profileExtension, - ImageFileExtension backgroundExtension - ) { - return backgroundExtension == null && - profileExtension != null && - !profileExtension.getUploadExtension().isEmpty(); + String fileName = createFileName(imageType, user.getId(), imageKey, extension); + return createGeneratePreSignedUrlRequest(fileName); } - private boolean isExistBackgroundExtension( + public void updateUserImages( + ImageFileExtension profileExtension, ImageFileExtension backgroundExtension, - ImageFileExtension profileExtension - ) { - return profileExtension == null && - backgroundExtension != null && - !backgroundExtension.getUploadExtension().isEmpty(); - } - - private GeneratePresignedUrlRequest getGeneratePresignedUrl( - ImageType type, - Long targetId, - String imageKey, - ImageFileExtension extension - ) { - String fileName = createFileName(type, targetId, imageKey, extension); - return createGeneratePreSignedUrlRequest(bucket, fileName); - } - - - private String createFileName( - ImageType imageType, - Long targetId, - String imageKey, - ImageFileExtension imageFileExtension - ) { - return getCurrentProfile() - + "/" - + imageType.name().toLowerCase(ENGLISH) - + "/" - + targetId - + "/" - + imageKey - + "." - + imageFileExtension.getUploadExtension(); - } - - private String createReadImageUrl( - ImageType imageType, - Long targetId, - String imageKey, - ImageFileExtension imageFileExtension - ) { - return IMAGE_DOMAIN_URL - + "/" - + getCurrentProfile() - + "/" - + imageType.name().toLowerCase(ENGLISH) - + "/" - + targetId - + "/" - + imageKey - + "." - + imageFileExtension.getUploadExtension(); - } - - private void updateItemImageKey(Long listId, ExtensionRanks extensionRank, String imageKey) { - findItem(listId, extensionRank.rank()) - .updateItemImageKey(imageKey); - } - - private Item findItem(Long listId, int rank) { - return itemRepository - .findByListIdAndRanking(listId, rank) - .orElseThrow(() -> new CustomException(ErrorCode.RESOURCE_NOT_FOUND, "해당 아이템이 존재하지 않습니다.")); - } - - private void updateUserImageKey(User user, String profileImageKey, String backgroundImageKey) { - user.updateUserImageUrl(profileImageKey, backgroundImageKey); - } - - private GeneratePresignedUrlRequest createGeneratePreSignedUrlRequest( - String bucket, - String fileName + Long ownerId ) { - GeneratePresignedUrlRequest generatePresignedUrlRequest = - new GeneratePresignedUrlRequest(bucket, fileName) - .withMethod(HttpMethod.PUT) - .withExpiration(getPresignedUrlExpiration()); - - generatePresignedUrlRequest.addRequestParameter( - Headers.S3_CANNED_ACL, CannedAccessControlList.PublicRead.toString() - ); - return generatePresignedUrlRequest; - } - - private Date getPresignedUrlExpiration() { - Date expiration = new Date(); - var expTimeMillis = expiration.getTime(); - expTimeMillis += 1000 * 60 * 30; - expiration.setTime(expTimeMillis); - return expiration; - } + User user = userRepository.getById(ownerId); - private String generatedUUID() { - return UUID.randomUUID().toString(); - } + String profileImageUrl = ""; + String backgroundImageUrl = ""; - private void validateListUserMismatch(ListEntity list, User user) { - if (!list.getUser().getId().equals(user.getId())) { - throw new CustomException(ErrorCode.INVALID_ACCESS, "리스트를 생성한 유저와 로그인한 계정이 일치하지 않습니다."); + if (profileExtension != null) { + profileImageUrl = createReadImageUrl(USER_PROFILE, user.getId(), user.getProfileImageUrl(), profileExtension); + } + if (backgroundExtension != null) { + backgroundImageUrl = createReadImageUrl(USER_BACKGROUND, user.getId(), user.getBackgroundImageUrl(), backgroundExtension); } - } - - private ListEntity findListById(Long listId) { - return listRepository - .findById(listId) - .orElseThrow(() -> new CustomException(ErrorCode.RESOURCE_NOT_FOUND, "존재하지 않는 리스트입니다.")); - } - - public String getCurrentProfile() { - return Arrays.stream(environment.getActiveProfiles()) - .filter(this::isActivateDev) - .findFirst() - .orElse(LOCAL); - } - private boolean isActivateDev(String profile) { - return profile.equals(DEV); + user.updateUserImageUrl(profileImageUrl, backgroundImageUrl); } @Async @@ -435,4 +285,37 @@ public void deleteImageOfItem(Long listId, Long itemId, Long loginUserID) { String fileFullName = getFileFullName(itemImageUrl.getValue()); deleteImageFile(fileFullName); } + + public List createNoticeImagePresignedUrl( + Long noticeId, + List requests + ) { + Notice notice = noticeRepository.getById(noticeId); + + return requests.stream() + .map(it -> { + String imageKey = UUID.randomUUID().toString(); + NoticeContent noticeContent = noticeContentRepository.findByNoticeAndOrder(notice, it.order()) + .orElseThrow(); + + noticeContent.updateImageUrl(imageKey); + + String fileName = createFileName(NOTICE, noticeId, imageKey, it.extension()); + GeneratePresignedUrlRequest request = createGeneratePreSignedUrlRequest(fileName); + URL presignedUrl = amazonS3.generatePresignedUrl(request); + + return NoticeImagePresignedUrlCreateResponse.of(it.order(), presignedUrl.toString()); + }).toList(); + } + + public void updateNoticeContentImages(Long noticeId, List requests) { + Notice notice = noticeRepository.getById(noticeId); + requests.forEach(it -> { + NoticeContent noticeContent = noticeContentRepository.findByNoticeAndOrder(notice, it.order()) + .orElseThrow(); + + String imageUrl = createReadImageUrl(NOTICE, noticeId, noticeContent.getImageUrl(), it.extension()); + noticeContent.updateImageUrl(imageUrl); + }); + } } diff --git a/src/main/java/com/listywave/image/presentation/controller/ImageController.java b/src/main/java/com/listywave/image/presentation/controller/ImageController.java index 9f63a296..156e3f57 100644 --- a/src/main/java/com/listywave/image/presentation/controller/ImageController.java +++ b/src/main/java/com/listywave/image/presentation/controller/ImageController.java @@ -5,11 +5,13 @@ import com.listywave.image.application.domain.DefaultProfileImages; import com.listywave.image.application.dto.response.DefaultBackgroundImageUrlResponse; import com.listywave.image.application.dto.response.DefaultProfileImageUrlResponse; -import com.listywave.image.application.dto.response.ItemPresignedUrlResponse; -import com.listywave.image.application.dto.response.UserPresignedUrlResponse; +import com.listywave.image.application.dto.response.ListItemPresignedUrlResponse; +import com.listywave.image.application.dto.response.UserPresignedUrlCreateResponse; import com.listywave.image.application.service.ImageService; -import com.listywave.image.presentation.dto.request.ListsImagesCreateRequest; +import com.listywave.image.presentation.dto.request.ListImagesCreateRequest; import com.listywave.image.presentation.dto.request.UserImageUpdateRequest; +import com.listywave.notice.application.dto.NoticeImagePresignedUrlCreateResponse; +import com.listywave.notice.application.dto.OrderAndExtensionDto; import java.util.Arrays; import java.util.List; import lombok.RequiredArgsConstructor; @@ -28,50 +30,35 @@ public class ImageController { private final ImageService imageService; @PostMapping("/lists/upload-url") - ResponseEntity> listItemPresignedUrlCreate( - @RequestBody ListsImagesCreateRequest request, - @Auth Long loginUserId + ResponseEntity> createPresignedUrlOfItem( + @RequestBody ListImagesCreateRequest request, + @Auth Long userId ) { - List response = imageService.createListsPresignedUrl( - loginUserId, - request.listId(), - request.extensionRanks() - ); + var response = imageService.createPresignedUrlOfItem(userId, request.listId(), request.extensionRanks()); return ResponseEntity.ok().body(response); } @PostMapping("/lists/upload-complete") - ResponseEntity listItemImagesUpload( - @RequestBody ListsImagesCreateRequest request, - @Auth Long loginUserId - ) { - imageService.uploadCompleteItemImages(loginUserId, request.listId(), request.extensionRanks()); + ResponseEntity completeUploadItems(@RequestBody ListImagesCreateRequest request, @Auth Long userId) { + imageService.updateAllItemsImageUrl(userId, request.listId(), request.extensionRanks()); return ResponseEntity.noContent().build(); } @PostMapping("/users/upload-url") - ResponseEntity userImagePresignedUrlCreate( + ResponseEntity createPresignedUrlOfUserImage( @RequestBody UserImageUpdateRequest request, - @Auth Long loginUserId + @Auth Long userId ) { - UserPresignedUrlResponse userPresignedUrlResponse = imageService.updateUserImagePresignedUrl( - request.profileExtension(), - request.backgroundExtension(), - loginUserId - ); + var userPresignedUrlResponse = imageService.createPresignedUrlOfUserImage(request.profileExtension(), request.backgroundExtension(), userId); return ResponseEntity.ok(userPresignedUrlResponse); } @PostMapping("/users/upload-complete") - ResponseEntity userImageUpload( + ResponseEntity completeUploadUserImage( @RequestBody UserImageUpdateRequest request, - @Auth Long loginUserId + @Auth Long userId ) { - imageService.uploadCompleteUserImages( - request.profileExtension(), - request.backgroundExtension(), - loginUserId - ); + imageService.updateUserImages(request.profileExtension(), request.backgroundExtension(), userId); return ResponseEntity.noContent().build(); } @@ -101,4 +88,21 @@ ResponseEntity> getAllDefaultBackgroundI return ResponseEntity.ok().body(response); } + @PostMapping("/admin/notices/{noticeId}/presigned-url") + ResponseEntity> createPresignedUrlOfNoticeContent( + @PathVariable Long noticeId, + @RequestBody List request + ) { + var result = imageService.createNoticeImagePresignedUrl(noticeId, request); + return ResponseEntity.ok(result); + } + + @PostMapping("/admin/notices/{noticeId}/upload-complete") + ResponseEntity completeUploadNoticeImages( + @PathVariable Long noticeId, + @RequestBody List requests + ) { + imageService.updateNoticeContentImages(noticeId, requests); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/com/listywave/image/presentation/dto/request/ListImagesCreateRequest.java b/src/main/java/com/listywave/image/presentation/dto/request/ListImagesCreateRequest.java new file mode 100644 index 00000000..71deb68d --- /dev/null +++ b/src/main/java/com/listywave/image/presentation/dto/request/ListImagesCreateRequest.java @@ -0,0 +1,16 @@ +package com.listywave.image.presentation.dto.request; + +import com.listywave.image.application.domain.ImageFileExtension; +import java.util.List; + +public record ListImagesCreateRequest( + Long listId, + List extensionRanks +) { + + public record ExtensionRanks( + int rank, + ImageFileExtension extension + ) { + } +} diff --git a/src/main/java/com/listywave/image/presentation/dto/request/ListsImagesCreateRequest.java b/src/main/java/com/listywave/image/presentation/dto/request/ListsImagesCreateRequest.java deleted file mode 100644 index 8e70c235..00000000 --- a/src/main/java/com/listywave/image/presentation/dto/request/ListsImagesCreateRequest.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.listywave.image.presentation.dto.request; - -import com.listywave.image.application.dto.ExtensionRanks; -import java.util.List; - -public record ListsImagesCreateRequest( - Long listId, - List extensionRanks -) { -} diff --git a/src/main/java/com/listywave/list/application/domain/category/CategoryType.java b/src/main/java/com/listywave/list/application/domain/category/CategoryType.java index a98f5a32..ae239eac 100644 --- a/src/main/java/com/listywave/list/application/domain/category/CategoryType.java +++ b/src/main/java/com/listywave/list/application/domain/category/CategoryType.java @@ -12,27 +12,29 @@ @AllArgsConstructor public enum CategoryType { - ENTIRE("0", "전체", "https://image.listywave.com/category/entire.webp"), - CULTURE("1", "문화", "https://image.listywave.com/category/culture.webp"), - LIFE("2", "일상생활", "https://image.listywave.com/category/life.webp"), - PLACE("3", "장소", "https://image.listywave.com/category/place.webp"), - MUSIC("4", "음악", "https://image.listywave.com/category/musiic.webp"), - MOVIE_DRAMA("5", "영화/드라마", "https://image.listywave.com/category/muvie_drama.webp"), - BOOK("6", "도서", "https://image.listywave.com/category/book.webp"), - ANIMAL_PLANT("7", "동식물", "https://image.listywave.com/category/animal_plant.webp"), - FOOD("9", "음식", "https://image.listywave.com/category/food.webp"), - ETC("8", "기타", "https://image.listywave.com/category/etc.webp"), + ENTIRE("0", "전체"), + MUSIC("1", "음악"), + MOVIE_DRAMA("2", "영화&드라마"), + ENTERTAINMENT_ARTS("3", "엔터&예술"), + TRAVEL("4", "여행"), + RESTAURANT_CAFE("5", "맛집&카페"), + FOOD_RECIPES("6", "음식&레시피"), + PLACE("7", "공간"), + DAILYLIFE_THOUGHTS("8", "일상&생각"), + HOBBY_LEISURE("9", "취미&레저"), + ETC("10", "기타"), ; + private static final String ERROR_MESSAGE = "해당 카테고리는 존재하지 않습니다. 입력값: "; + private final String code; private final String viewName; - private final String imageUrl; public static CategoryType codeOf(String code) { return Arrays.stream(CategoryType.values()) - .filter(t -> t.getCode().equals(code)) + .filter(categoryType -> categoryType.getCode().equals(code)) .findAny() - .orElseThrow(() -> new CustomException(RESOURCE_NOT_FOUND, "해당 카테고리코드는 존재하지 않습니다.")); + .orElseThrow(() -> new CustomException(RESOURCE_NOT_FOUND, ERROR_MESSAGE + code)); } @JsonCreator @@ -40,6 +42,13 @@ public static CategoryType nameOf(String name) { return Arrays.stream(CategoryType.values()) .filter(categoryType -> categoryType.name().equalsIgnoreCase(name)) .findFirst() - .orElseThrow(() -> new CustomException(RESOURCE_NOT_FOUND, "해당 카테고리는 존재하지 않습니다.")); + .orElseThrow(() -> new CustomException(RESOURCE_NOT_FOUND, ERROR_MESSAGE + name)); + } + + public static CategoryType viewNameOf(String viewName) { + return Arrays.stream(CategoryType.values()) + .filter(categoryType -> categoryType.getViewName().equals(viewName)) + .findFirst() + .orElseThrow(() -> new CustomException(RESOURCE_NOT_FOUND, ERROR_MESSAGE + viewName)); } } diff --git a/src/main/java/com/listywave/list/application/domain/category/CategoryTypeConverter.java b/src/main/java/com/listywave/list/application/domain/category/CategoryTypeConverter.java index cab3022a..7dd6c340 100644 --- a/src/main/java/com/listywave/list/application/domain/category/CategoryTypeConverter.java +++ b/src/main/java/com/listywave/list/application/domain/category/CategoryTypeConverter.java @@ -1,7 +1,9 @@ package com.listywave.list.application.domain.category; import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +@Converter(autoApply = true) public class CategoryTypeConverter implements AttributeConverter { @Override diff --git a/src/main/java/com/listywave/list/application/domain/comment/Comment.java b/src/main/java/com/listywave/list/application/domain/comment/Comment.java index 2611c5e9..d3088ea9 100644 --- a/src/main/java/com/listywave/list/application/domain/comment/Comment.java +++ b/src/main/java/com/listywave/list/application/domain/comment/Comment.java @@ -1,23 +1,27 @@ package com.listywave.list.application.domain.comment; +import static jakarta.persistence.CascadeType.ALL; import static jakarta.persistence.FetchType.LAZY; import static lombok.AccessLevel.PROTECTED; import com.listywave.common.BaseEntity; +import com.listywave.common.util.DataUpdateUtils; import com.listywave.list.application.domain.list.ListEntity; +import com.listywave.mention.Mention; import com.listywave.user.application.domain.User; import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import lombok.AllArgsConstructor; +import jakarta.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; import lombok.Getter; import lombok.NoArgsConstructor; @Getter @Entity -@AllArgsConstructor @NoArgsConstructor(access = PROTECTED) public class Comment extends BaseEntity { @@ -32,11 +36,21 @@ public class Comment extends BaseEntity { @Embedded private CommentContent commentContent; + @OneToMany(fetch = LAZY, cascade = ALL, orphanRemoval = true, mappedBy = "comment") + private final List mentions = new ArrayList<>(); + @Column(nullable = false, length = 5) private boolean isDeleted; - public static Comment create(ListEntity list, User user, CommentContent content) { - return new Comment(list, user, content, false); + public Comment(ListEntity list, User user, CommentContent content, List mentions) { + this.list = list; + this.user = user; + this.commentContent = content; + mentions.forEach(it -> { + this.mentions.add(it); + it.setComment(this); + }); + this.isDeleted = false; } public boolean isOwner(User user) { @@ -47,8 +61,10 @@ public void softDelete() { this.isDeleted = true; } - public void update(CommentContent content) { + public void update(CommentContent content, List mentions) { this.commentContent = content; + DataUpdateUtils.update(this.mentions, mentions); + mentions.forEach(mention -> mention.setComment(this)); } public boolean isDeleted() { diff --git a/src/main/java/com/listywave/list/application/domain/item/Item.java b/src/main/java/com/listywave/list/application/domain/item/Item.java index 35aa5402..e1e8dc71 100644 --- a/src/main/java/com/listywave/list/application/domain/item/Item.java +++ b/src/main/java/com/listywave/list/application/domain/item/Item.java @@ -54,8 +54,8 @@ public void updateItemImageKey(String imageKey) { this.imageKey = imageKey; } - public void updateItemImageUrl(String imageUrl) { - this.imageUrl = new ItemImageUrl(imageUrl); + public void updateItemImageUrl(ItemImageUrl imageUrl) { + this.imageUrl = imageUrl; } public void updateList(ListEntity list) { diff --git a/src/main/java/com/listywave/list/application/domain/list/BackgroundColor.java b/src/main/java/com/listywave/list/application/domain/list/BackgroundColor.java index d0113997..d9f863cd 100644 --- a/src/main/java/com/listywave/list/application/domain/list/BackgroundColor.java +++ b/src/main/java/com/listywave/list/application/domain/list/BackgroundColor.java @@ -19,11 +19,14 @@ public enum BackgroundColor { GRAY_VERYLIGHT, GRAY_LIGHT, GRAY_MEDIUM, + GRAY_SOFT_BLUE, + GRAY_LIGHT_BLUE, + GRAY_MEDIUM_BLUE, - LISTY_WHITE, - LISTY_YELLOW, - LISTY_ORANGE, - LISTY_GREEN, - LISTY_BLUE, - LISTY_PURPLE, + NEON_WHITE, + NEON_YELLOW, + NEON_ORANGE, + NEON_GREEN, + NEON_BLUE, + NEON_PURPLE, } diff --git a/src/main/java/com/listywave/list/application/domain/list/BackgroundPalette.java b/src/main/java/com/listywave/list/application/domain/list/BackgroundPalette.java index dd573a45..44baf953 100644 --- a/src/main/java/com/listywave/list/application/domain/list/BackgroundPalette.java +++ b/src/main/java/com/listywave/list/application/domain/list/BackgroundPalette.java @@ -5,5 +5,5 @@ public enum BackgroundPalette { PASTEL, VIVID, GRAY, - LISTY, + NEON, } diff --git a/src/main/java/com/listywave/list/application/domain/list/ListEntity.java b/src/main/java/com/listywave/list/application/domain/list/ListEntity.java index bca172d6..967836b9 100644 --- a/src/main/java/com/listywave/list/application/domain/list/ListEntity.java +++ b/src/main/java/com/listywave/list/application/domain/list/ListEntity.java @@ -9,16 +9,13 @@ import static jakarta.persistence.TemporalType.TIMESTAMP; import static lombok.AccessLevel.PROTECTED; -import com.listywave.collaborator.application.domain.Collaborators; import com.listywave.common.exception.CustomException; import com.listywave.list.application.domain.category.CategoryType; -import com.listywave.list.application.domain.category.CategoryTypeConverter; import com.listywave.list.application.domain.item.Item; import com.listywave.list.application.domain.item.Items; import com.listywave.list.application.domain.label.Labels; import com.listywave.user.application.domain.User; import jakarta.persistence.Column; -import jakarta.persistence.Convert; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.EntityListeners; @@ -56,7 +53,6 @@ public class ListEntity { private User user; @Column(name = "category_code", length = 10, nullable = false) - @Convert(converter = CategoryTypeConverter.class) private CategoryType category; @Embedded @@ -91,6 +87,9 @@ public class ListEntity { @Embedded private Items items; + @Column(nullable = false) + private int updateCount; + @CreatedDate @Temporal(TIMESTAMP) @Column(updatable = false) @@ -177,11 +176,11 @@ public int scoreRelation(String keyword) { return totalScore; } - public void increaseCollectCount() { + public synchronized void increaseCollectCount() { this.collectCount++; } - public void decreaseCollectCount() { + public synchronized void decreaseCollectCount() { if (this.collectCount > 0) { this.collectCount--; } @@ -229,8 +228,8 @@ public void validateHasItem(Item item) { } } - public void updateVisibility(Boolean isPublic) { - this.isPublic = isPublic; + public void updateVisibility() { + this.isPublic = !this.isPublic; } public String getRepresentImageUrl() { @@ -241,22 +240,24 @@ public boolean isDeletedUser() { return user.isDelete(); } - public void validateOwnerIsNotDelete() { + public void validateOwnerIsNotDeleted() { if (this.user.isDelete()) { throw new CustomException(DELETED_USER_EXCEPTION, "탈퇴한 회원의 리스트입니다."); } } - public void validateUpdateAuthority(User loginUser, Collaborators beforeCollaborators) { + public void validateUpdateAuthority(User loginUser) { if (this.user.equals(loginUser)) { return; } - if (beforeCollaborators.isEmpty()) { - return; - } - if (beforeCollaborators.contains(loginUser)) { - return; - } throw new CustomException(INVALID_ACCESS); } + + public void increaseUpdateCount() { + this.updateCount++; + } + + public boolean isOwner(User loginUser) { + return this.user.equals(loginUser); + } } diff --git a/src/main/java/com/listywave/list/application/domain/reply/Reply.java b/src/main/java/com/listywave/list/application/domain/reply/Reply.java index 45a3d33c..529e2739 100644 --- a/src/main/java/com/listywave/list/application/domain/reply/Reply.java +++ b/src/main/java/com/listywave/list/application/domain/reply/Reply.java @@ -1,16 +1,22 @@ package com.listywave.list.application.domain.reply; +import static jakarta.persistence.CascadeType.ALL; import static jakarta.persistence.FetchType.LAZY; import static lombok.AccessLevel.PROTECTED; import com.listywave.common.BaseEntity; +import com.listywave.common.util.DataUpdateUtils; import com.listywave.list.application.domain.comment.Comment; import com.listywave.list.application.domain.comment.CommentContent; +import com.listywave.mention.Mention; import com.listywave.user.application.domain.User; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -32,12 +38,27 @@ public class Reply extends BaseEntity { @Embedded private CommentContent commentContent; + @OneToMany(fetch = LAZY, cascade = ALL, orphanRemoval = true, mappedBy = "reply") + private final List mentions = new ArrayList<>(); + + public Reply(Comment comment, User user, CommentContent commentContent, List mentions) { + this.comment = comment; + this.user = user; + this.commentContent = commentContent; + mentions.forEach(it -> { + this.mentions.add(it); + it.setReply(this); + }); + } + public boolean isOwner(User user) { return this.user.equals(user); } - public void update(CommentContent content) { + public void update(CommentContent content, List mentions) { this.commentContent = content; + DataUpdateUtils.update(this.mentions, mentions); + mentions.forEach(mention -> mention.setReply(this)); } public Long getCommentId() { diff --git a/src/main/java/com/listywave/list/application/dto/ReplyUpdateCommand.java b/src/main/java/com/listywave/list/application/dto/ReplyUpdateCommand.java index dc00714b..d6748877 100644 --- a/src/main/java/com/listywave/list/application/dto/ReplyUpdateCommand.java +++ b/src/main/java/com/listywave/list/application/dto/ReplyUpdateCommand.java @@ -1,9 +1,12 @@ package com.listywave.list.application.dto; +import java.util.List; + public record ReplyUpdateCommand( Long listId, Long commentId, Long replyId, - String content + String content, + List mentionIds ) { } diff --git a/src/main/java/com/listywave/list/application/dto/response/CategoryTypeResponse.java b/src/main/java/com/listywave/list/application/dto/response/CategoryTypeResponse.java index 98cde6d1..9a3e833b 100644 --- a/src/main/java/com/listywave/list/application/dto/response/CategoryTypeResponse.java +++ b/src/main/java/com/listywave/list/application/dto/response/CategoryTypeResponse.java @@ -5,16 +5,14 @@ public record CategoryTypeResponse( String code, String engName, - String korName, - String categoryImageUrl + String korName ) { public static CategoryTypeResponse of(CategoryType categoryType) { return new CategoryTypeResponse( categoryType.getCode(), categoryType.name().toLowerCase(), - categoryType.getViewName(), - categoryType.getImageUrl() + categoryType.getViewName() ); } } diff --git a/src/main/java/com/listywave/list/application/dto/response/CommentFindResponse.java b/src/main/java/com/listywave/list/application/dto/response/CommentFindResponse.java index d46c21d4..1e22e8b9 100644 --- a/src/main/java/com/listywave/list/application/dto/response/CommentFindResponse.java +++ b/src/main/java/com/listywave/list/application/dto/response/CommentFindResponse.java @@ -1,9 +1,12 @@ package com.listywave.list.application.dto.response; import static java.util.Collections.emptyList; +import static java.util.Comparator.comparingLong; import com.listywave.list.application.domain.comment.Comment; import com.listywave.list.application.domain.reply.Reply; +import com.listywave.mention.Mention; +import com.listywave.user.application.domain.User; import java.time.LocalDateTime; import java.util.List; import java.util.Map; @@ -14,7 +17,7 @@ public record CommentFindResponse( Long totalCount, Long cursorId, boolean hasNext, - List comments + List comments ) { public static CommentFindResponse emptyResponse() { @@ -36,12 +39,12 @@ public static CommentFindResponse from( .totalCount(totalCount) .cursorId(cursorId) .hasNext(hasNext) - .comments(CommentResponse.toList(comments)) + .comments(CommentDto.toList(comments)) .build(); } @Builder - public record CommentResponse( + public record CommentDto( Long id, Long userId, String userNickname, @@ -50,17 +53,18 @@ public record CommentResponse( LocalDateTime createdDate, LocalDateTime updatedDate, boolean isDeleted, - List replies + List replies, + List mentions ) { - public static List toList(Map> comments) { + public static List toList(Map> comments) { return comments.keySet().stream() - .map(comment -> CommentResponse.of(comment, comments.get(comment))) + .map(comment -> CommentDto.of(comment, comments.get(comment))) .toList(); } - public static CommentResponse of(Comment comment, List replies) { - return CommentResponse.builder() + public static CommentDto of(Comment comment, List replies) { + return CommentDto.builder() .id(comment.getId()) .userId(comment.getUserId()) .userNickname(comment.getUserNickname()) @@ -69,13 +73,14 @@ public static CommentResponse of(Comment comment, List replies) { .createdDate(comment.getCreatedDate()) .updatedDate(comment.getUpdatedDate()) .isDeleted(comment.isDeleted()) - .replies(ReplyResponse.toList(replies)) + .replies(ReplyDto.toList(replies)) + .mentions(MentionDto.toList(comment.getMentions())) .build(); } } @Builder - public record ReplyResponse( + public record ReplyDto( Long id, Long commentId, Long userId, @@ -83,17 +88,18 @@ public record ReplyResponse( String userProfileImageUrl, String content, LocalDateTime createdDate, - LocalDateTime updatedDate + LocalDateTime updatedDate, + List mentions ) { - public static List toList(List replies) { + public static List toList(List replies) { return replies.stream() - .map(ReplyResponse::of) + .map(ReplyDto::of) .toList(); } - public static ReplyResponse of(Reply reply) { - return ReplyResponse.builder() + public static ReplyDto of(Reply reply) { + return ReplyDto.builder() .id(reply.getId()) .commentId(reply.getCommentId()) .userId(reply.getUserId()) @@ -102,7 +108,27 @@ public static ReplyResponse of(Reply reply) { .content(reply.getCommentContent()) .createdDate(reply.getCreatedDate()) .updatedDate(reply.getUpdatedDate()) + .mentions(MentionDto.toList(reply.getMentions())) .build(); } } + + public record MentionDto( + Long userId, + String userNickname + ) { + + public static List toList(List mentions) { + return mentions.stream() + .sorted(comparingLong(Mention::getId)) + .map(mention -> { + User user = mention.getUser(); + if (user.isDelete()) { + return new MentionDto(0L, "withdrawer"); + } + return new MentionDto(user.getId(), user.getNickname()); + }) + .toList(); + } + } } diff --git a/src/main/java/com/listywave/list/application/dto/response/ListDetailResponse.java b/src/main/java/com/listywave/list/application/dto/response/ListDetailResponse.java index 439710b3..f62273ec 100644 --- a/src/main/java/com/listywave/list/application/dto/response/ListDetailResponse.java +++ b/src/main/java/com/listywave/list/application/dto/response/ListDetailResponse.java @@ -1,16 +1,20 @@ package com.listywave.list.application.dto.response; import com.listywave.collaborator.application.domain.Collaborator; +import com.listywave.list.application.domain.comment.Comment; import com.listywave.list.application.domain.item.Item; import com.listywave.list.application.domain.label.Label; import com.listywave.list.application.domain.list.ListEntity; +import com.listywave.reaction.application.dto.response.ReactionResponse; import com.listywave.user.application.domain.User; +import jakarta.annotation.Nullable; import java.time.LocalDateTime; import java.util.List; import lombok.Builder; @Builder public record ListDetailResponse( + String categoryCode, String categoryEngName, String categoryKorName, List labels, @@ -25,19 +29,31 @@ public record ListDetailResponse( List items, boolean isCollected, boolean isPublic, + boolean isFollowing, String backgroundPalette, String backgroundColor, - int collectCount, - int viewCount + Integer collectCount, + int viewCount, + int updateCount, + long totalCommentCount, + @Nullable NewestComment newestComment, + List reactions ) { public static ListDetailResponse of( ListEntity list, User owner, + boolean isOwner, boolean isCollected, - List collaborators + boolean isFollowing, + List collaborators, + long totalCommentCount, + Comment newestComment, + Long totalReplyCount, + List reactions ) { return ListDetailResponse.builder() + .categoryCode(list.getCategory().getCode()) .categoryEngName(list.getCategory().name().toLowerCase()) .categoryKorName(list.getCategory().getViewName()) .labels(LabelResponse.toList(list.getLabels().getValues())) @@ -52,10 +68,15 @@ public static ListDetailResponse of( .items(ItemResponse.toList(list.getSortedItems().getValues())) .isCollected(isCollected) .isPublic(list.isPublic()) + .isFollowing(isFollowing) .backgroundColor(list.getBackgroundColor().name()) .backgroundPalette(list.getBackgroundPalette().name()) - .collectCount(list.getCollectCount()) + .collectCount(isOwner ? list.getCollectCount() : null) .viewCount(list.getViewCount()) + .updateCount(list.getUpdateCount()) + .totalCommentCount(totalCommentCount) + .newestComment(NewestComment.of(newestComment, totalReplyCount)) + .reactions(reactions) .build(); } @@ -123,4 +144,29 @@ public static ItemResponse of(Item item) { .build(); } } + + @Builder + public record NewestComment( + Long userId, + String userNickname, + String userProfileImageUrl, + LocalDateTime createdDate, + String content, + Long totalReplyCount + ) { + + public static NewestComment of(@Nullable Comment comment, Long totalReplyCount) { + if (comment == null) { + return null; + } + return NewestComment.builder() + .userId(comment.getUserId()) + .userNickname(comment.getUserNickname()) + .userProfileImageUrl(comment.getUserProfileImageUrl()) + .createdDate(comment.getCreatedDate()) + .content(comment.getCommentContent()) + .totalReplyCount(totalReplyCount) + .build(); + } + } } diff --git a/src/main/java/com/listywave/list/application/dto/response/ListRecentResponse.java b/src/main/java/com/listywave/list/application/dto/response/ListRecentResponse.java index 9a433495..af163499 100644 --- a/src/main/java/com/listywave/list/application/dto/response/ListRecentResponse.java +++ b/src/main/java/com/listywave/list/application/dto/response/ListRecentResponse.java @@ -1,10 +1,8 @@ package com.listywave.list.application.dto.response; import com.listywave.list.application.domain.item.Item; -import com.listywave.list.application.domain.label.Label; import com.listywave.list.application.domain.list.ListEntity; import java.time.LocalDateTime; -import java.util.Comparator; import java.util.List; import lombok.Builder; @@ -25,15 +23,13 @@ public static ListRecentResponse of(List lists, LocalDateTime cursor @Builder public record ListResponse( Long id, - String category, - String backgroundColor, Long ownerId, String ownerNickname, String ownerProfileImage, - List labels, String title, String description, - List items + List items, + int updateCount ) { public static List toList(List lists) { @@ -45,35 +41,13 @@ public static List toList(List lists) { public static ListResponse of(ListEntity list) { return ListResponse.builder() .id(list.getId()) - .category(list.getCategory().getViewName()) - .backgroundColor(list.getBackgroundColor().name()) .ownerId(list.getUser().getId()) .ownerNickname(list.getUser().getNickname()) .ownerProfileImage(list.getUser().getProfileImageUrl()) - .labels(LabelsResponse.toList(list.getLabels().getValues())) .title(list.getTitle().getValue()) .description(list.getDescription().getValue()) .items(ItemsResponse.toList(list.getTop3Items().getValues())) - .build(); - } - } - - @Builder - public record LabelsResponse( - Long id, - String name - ) { - - public static List toList(List