Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 키워드 알림 소프트 딜리트 구현 #1107

Open
wants to merge 3 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import java.util.ArrayList;
import java.util.List;

import org.hibernate.annotations.Where;

import in.koreatech.koin.domain.community.keyword.exception.KeywordDuplicationException;
import in.koreatech.koin.global.domain.BaseEntity;
import jakarta.persistence.CascadeType;
Expand All @@ -22,6 +24,7 @@

@Getter
@Entity
@Where(clause = "is_deleted = 0")
@Table(name = "article_keywords")
@NoArgsConstructor(access = PROTECTED)
public class ArticleKeyword extends BaseEntity {
Expand All @@ -39,6 +42,9 @@ public class ArticleKeyword extends BaseEntity {
@Column(name = "is_filtered", nullable = false)
private Boolean isFiltered = false;

@Column(name = "is_deleted", nullable = false)
private Boolean isDeleted = false;

@OneToMany(mappedBy = "articleKeyword", cascade = CascadeType.PERSIST)
private List<ArticleKeywordUserMap> articleKeywordUserMaps = new ArrayList<>();

Expand All @@ -50,13 +56,6 @@ private ArticleKeyword(String keyword, LocalDateTime lastUsedAt, Boolean isFilte
}

public void addUserMap(ArticleKeywordUserMap keywordUserMap) {
boolean isDuplicate = articleKeywordUserMaps.stream()
.anyMatch(map -> map.getUser().equals(keywordUserMap.getUser()));

if (isDuplicate) {
throw new KeywordDuplicationException("해당 키워드는 이미 등록되었습니다.");
}

articleKeywordUserMaps.add(keywordUserMap);
updateLastUsedAt();
}
Expand All @@ -68,4 +67,13 @@ private void updateLastUsedAt() {
public void applyFiltered(Boolean isFiltered) {
this.isFiltered = isFiltered;
}

public void delete() {
this.isDeleted = true;
}

public void restore() {
this.isDeleted = false;
this.lastUsedAt = LocalDateTime.now();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ private boolean hasDeviceToken(NotificationSubscribe subscribe) {

private boolean isKeywordRegistered(ArticleKeywordEvent event, NotificationSubscribe subscribe) {
return event.keyword().getArticleKeywordUserMaps().stream()
.filter(map -> !map.getIsDeleted())
.anyMatch(map -> map.getUser().getId().equals(subscribe.getUser().getId()));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
import static jakarta.persistence.GenerationType.IDENTITY;
import static lombok.AccessLevel.PROTECTED;

import org.hibernate.annotations.Where;

import in.koreatech.koin.domain.user.model.User;
import in.koreatech.koin.global.domain.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
Expand All @@ -18,6 +21,7 @@

@Getter
@Entity
@Where(clause = "is_deleted = 0")
@Table(name = "article_keyword_user_map", uniqueConstraints = {
@UniqueConstraint(columnNames = {"keyword_id", "user_id"})
})
Expand All @@ -36,9 +40,20 @@ public class ArticleKeywordUserMap extends BaseEntity {
@JoinColumn(name = "user_id", nullable = false)
private User user;

@Column(name = "is_deleted", nullable = false)
private Boolean isDeleted = false;

@Builder
private ArticleKeywordUserMap(ArticleKeyword articleKeyword, User user) {
this.articleKeyword = articleKeyword;
this.user = user;
}

public void delete() {
this.isDeleted = true;
}

public void restore() {
this.isDeleted = false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,21 @@
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param;

import in.koreatech.koin.domain.community.article.dto.ArticleKeywordResult;
import in.koreatech.koin.domain.community.keyword.exception.ArticleKeywordNotFoundException;
import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword;

public interface ArticleKeywordRepository extends Repository<ArticleKeyword, Integer> {

Optional<ArticleKeyword> findByKeyword(String keyword);

@Query(value = """
SELECT * FROM article_keywords ak
WHERE ak.keyword = :keyword
""", nativeQuery = true)
Optional<ArticleKeyword> findByKeywordIncludingDeleted(@Param("keyword") String keyword);

ArticleKeyword save(ArticleKeyword articleKeyword);

void deleteById(Integer id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,15 @@ default ArticleKeywordUserMap getById(Integer keywordUserMapId) {
JOIN akum.articleKeyword akw
WHERE akum.user.id = :userId
""")
List<String> findAllKeywordbyUserId(@Param("userId") Integer userId);
List<String> findAllKeywordByUserId(@Param("userId") Integer userId);

@Query(value = """
SELECT * FROM article_keyword_user_map akum
WHERE akum.keyword_id = :articleKeywordId
AND akum.user_id = :userId
""", nativeQuery = true)
Optional<ArticleKeywordUserMap> findByArticleKeywordIdAndUserIdIncludingDeleted(
@Param("articleKeywordId") Integer articleKeywordId,
@Param("userId") Integer userId
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import in.koreatech.koin.domain.community.keyword.dto.ArticleKeywordsResponse;
import in.koreatech.koin.domain.community.keyword.dto.ArticleKeywordsSuggestionResponse;
import in.koreatech.koin.domain.community.keyword.dto.KeywordNotificationRequest;
import in.koreatech.koin.domain.community.keyword.exception.KeywordDuplicationException;
import in.koreatech.koin.domain.community.keyword.exception.KeywordLimitExceededException;
import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword;
import in.koreatech.koin.domain.community.keyword.model.ArticleKeywordEvent;
Expand Down Expand Up @@ -52,30 +53,74 @@ public class KeywordService {
private final UserRepository userRepository;
private final UserNotificationStatusRepository userNotificationStatusRepository;

@ConcurrencyGuard(lockName = "createKeyword")
@Transactional
public ArticleKeywordResponse createKeyword(Integer userId, ArticleKeywordCreateRequest request) {
String keyword = validateAndGetKeyword(request.keyword());

validateKeywordLimit(userId);

ArticleKeyword existingKeyword = findOrRestoreKeyword(keyword);

ArticleKeywordUserMap userMap = findOrCreateKeywordMapping(existingKeyword, userId);

return new ArticleKeywordResponse(userMap.getId(), existingKeyword.getKeyword());
}

private void validateKeywordLimit(Integer userId) {
if (articleKeywordUserMapRepository.countByUserId(userId) >= ARTICLE_KEYWORD_LIMIT) {
throw KeywordLimitExceededException.withDetail("userId: " + userId);
}
}

ArticleKeyword existingKeyword = articleKeywordRepository.findByKeyword(keyword)
.orElseGet(() -> articleKeywordRepository.save(
ArticleKeyword.builder()
.keyword(keyword)
.lastUsedAt(LocalDateTime.now())
.build()
));
@ConcurrencyGuard(lockName = "createKeywordManagement")
private ArticleKeyword findOrRestoreKeyword(String keyword) {
return articleKeywordRepository.findByKeywordIncludingDeleted(keyword)
.map(keywordEntity -> {
if (keywordEntity.getIsDeleted()) {
keywordEntity.restore();
}
return keywordEntity;
})
.orElseGet(() -> createNewKeyword(keyword));
}

private ArticleKeyword createNewKeyword(String keyword) {
return articleKeywordRepository.save(
ArticleKeyword.builder()
.keyword(keyword)
.lastUsedAt(LocalDateTime.now())
.build()
);
}

@ConcurrencyGuard(lockName = "createKeywordMappingManagement")
private ArticleKeywordUserMap findOrCreateKeywordMapping(ArticleKeyword existingKeyword, Integer userId) {
return articleKeywordUserMapRepository.findByArticleKeywordIdAndUserIdIncludingDeleted(existingKeyword.getId(), userId)
.map(userMap -> {
if (!userMap.getIsDeleted()) {
throw new KeywordDuplicationException("해당 키워드는 이미 등록되었습니다.");
}
userMap.restore();
return userMap;
})
.orElseGet(() -> createNewKeywordMapping(existingKeyword, userId));
}

private ArticleKeywordUserMap createNewKeywordMapping(ArticleKeyword keyword, Integer userId) {
ArticleKeywordUserMap keywordUserMap = ArticleKeywordUserMap.builder()
.user(userRepository.getById(userId))
.articleKeyword(existingKeyword)
.articleKeyword(keyword)
.build();

existingKeyword.addUserMap(keywordUserMap);
articleKeywordUserMapRepository.save(keywordUserMap);
keyword.addUserMap(keywordUserMap);
return articleKeywordUserMapRepository.save(keywordUserMap);
}

return new ArticleKeywordResponse(keywordUserMap.getId(), existingKeyword.getKeyword());
private String validateAndGetKeyword(String keyword) {
if (keyword.contains(" ") || keyword.contains("\n")) {
throw new KoinIllegalArgumentException("키워드에 공백을 포함할 수 없습니다.");
}
return keyword.trim().toLowerCase();
}

@Transactional
Expand All @@ -85,16 +130,11 @@ public void deleteKeyword(Integer userId, Integer keywordUserMapId) {
throw AuthorizationException.withDetail("userId: " + userId);
}

deleteMappingAndUnusedKeywordWithLock(keywordUserMapId, articleKeywordUserMap.getArticleKeyword().getId());
}

@ConcurrencyGuard(lockName = "deleteKeyword")
private void deleteMappingAndUnusedKeywordWithLock(Integer keywordUserMapId, Integer articleKeywordId) {
articleKeywordUserMapRepository.deleteById(keywordUserMapId);
articleKeywordUserMap.delete();

boolean isKeywordUsedByOthers = articleKeywordUserMapRepository.existsByArticleKeywordId(articleKeywordId);
boolean isKeywordUsedByOthers = articleKeywordUserMapRepository.existsByArticleKeywordId(articleKeywordUserMap.getArticleKeyword().getId());
if (!isKeywordUsedByOthers) {
articleKeywordRepository.deleteById(articleKeywordId);
articleKeywordUserMap.getArticleKeyword().delete();
}
}

Expand Down Expand Up @@ -132,13 +172,6 @@ public void sendKeywordNotification(KeywordNotificationRequest request) {
}
}

private String validateAndGetKeyword(String keyword) {
if (keyword.contains(" ") || keyword.contains("\n")) {
throw new KoinIllegalArgumentException("키워드에 공백을 포함할 수 없습니다.");
}
return keyword.trim().toLowerCase();
}

private List<ArticleKeywordEvent> matchKeyword(List<Article> articles) {
List<ArticleKeywordEvent> keywordEvents = new ArrayList<>();
int offset = 0;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
ALTER TABLE `article_keywords`
ADD COLUMN `is_deleted` TINYINT(1) NOT NULL DEFAULT 0;

ALTER TABLE `article_keyword_user_map`
ADD COLUMN `is_deleted` TINYINT(1) NOT NULL DEFAULT 0;
Loading