Skip to content
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
@@ -0,0 +1,28 @@
package im.toduck.domain.badge.domain.checker;

import org.springframework.stereotype.Component;

import im.toduck.domain.badge.persistence.entity.BadgeCode;
import im.toduck.domain.routine.persistence.repository.RoutineRepository;
import im.toduck.domain.user.persistence.entity.User;
import lombok.RequiredArgsConstructor;

/**
* 까마귀 뱃지 체커: 기억력 카테고리(Routine Category) 5개 이상 사용 시 지급
*/
@Component
@RequiredArgsConstructor
public class CrowBadgeChecker implements BadgeConditionChecker {

private final RoutineRepository routineRepository;

@Override
public BadgeCode getBadgeCode() {
return BadgeCode.CROW;
}

@Override
public boolean checkCondition(final User user) {
return routineRepository.countDistinctCategoryByUser(user) >= 5;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package im.toduck.domain.badge.domain.checker;

import java.time.LocalDate;
import java.time.YearMonth;

import org.springframework.stereotype.Component;

import im.toduck.domain.badge.persistence.entity.BadgeCode;
import im.toduck.domain.diary.persistence.repository.DiaryRepository;
import im.toduck.domain.user.persistence.entity.User;
import lombok.RequiredArgsConstructor;

/**
* 하루일기 뱃지 체커: 감정일기 한 달 작성률 50% 이상 달성 시 지급
*/
@Component
@RequiredArgsConstructor
public class DailyDiaryBadgeChecker implements BadgeConditionChecker {
private static final int FIRST_DAY_OF_MONTH = 1;
private static final double MINIMUM_WRITTEN_RATIO = 0.5;

private final DiaryRepository diaryRepository;

@Override
public BadgeCode getBadgeCode() {
return BadgeCode.DAILY_DIARY;
}

@Override
public boolean checkCondition(final User user) {
YearMonth currentMonth = YearMonth.now();
LocalDate startDate = currentMonth.atDay(FIRST_DAY_OF_MONTH);
LocalDate endDate = currentMonth.atEndOfMonth();
int daysInMonth = currentMonth.lengthOfMonth();

long writtenDays = diaryRepository.countDistinctDateByUserAndDateBetween(user, startDate, endDate);

return writtenDays >= daysInMonth * MINIMUM_WRITTEN_RATIO;
}
Comment on lines +29 to +39
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p4:

YearMonth.now()를 직접 호출하고 있어서, 월말/월초 경계에서 테스트가 flaky해질 가능성이 있지 않을까요?

예를 들어 테스트가 1월 31일 23:59에 시작되었는데 실제 쿼리 시점에 2월 1일로 넘어가면, now()가 반환하는 월과 DB에 넣은 데이터의 월이 달라져서 테스트가 실패할 수 있을 것 같아요. 또한 2월(28일)과 7월(31일)에서 50% 기준 일수가 다르기 때문에 특정 월에서만 실패하는 케이스도 생길 수 있고요.

당장은 큰 문제가 없어 보여서 참고차 남깁니다..!

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package im.toduck.domain.badge.domain.checker;

import org.springframework.stereotype.Component;

import im.toduck.domain.badge.persistence.entity.BadgeCode;
import im.toduck.domain.concentration.persistence.repository.ConcentrationRepository;
import im.toduck.domain.user.persistence.entity.User;
import lombok.RequiredArgsConstructor;

/**
* 집중천재 뱃지 체커: 타이머 15회 이상 사용 시 지급
*/
@Component
@RequiredArgsConstructor
public class FocusGeniusBadgeChecker implements BadgeConditionChecker {

private final ConcentrationRepository concentrationRepository;

@Override
public BadgeCode getBadgeCode() {
return BadgeCode.FOCUS_GENIUS;
}

@Override
public boolean checkCondition(final User user) {
return concentrationRepository.sumTargetCountByUser(user) >= 15;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package im.toduck.domain.badge.domain.checker;

import org.springframework.stereotype.Component;

import im.toduck.domain.badge.persistence.entity.BadgeCode;
import im.toduck.domain.routine.persistence.repository.RoutineRepository;
import im.toduck.domain.user.persistence.entity.User;
import lombok.RequiredArgsConstructor;

/**
* 완벽주의 뱃지 체커: 루틴 10개 이상 등록 시 지급
*/
@Component
@RequiredArgsConstructor
public class PerfectionistBadgeChecker implements BadgeConditionChecker {

private final RoutineRepository routineRepository;

@Override
public BadgeCode getBadgeCode() {
return BadgeCode.PERFECTIONIST;
}

@Override
public boolean checkCondition(final User user) {
return routineRepository.countByUserAndDeletedAtIsNull(user) >= 10;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package im.toduck.domain.badge.domain.checker;

import org.springframework.stereotype.Component;

import im.toduck.domain.badge.persistence.entity.BadgeCode;
import im.toduck.domain.social.persistence.repository.SocialRepository;
import im.toduck.domain.user.persistence.entity.User;
import lombok.RequiredArgsConstructor;

/**
* 꽥꽥 뱃지 체커: 소셜 글 15개 이상 작성 시 지급
*/
@Component
@RequiredArgsConstructor
public class QuackQuackBadgeChecker implements BadgeConditionChecker {

private final SocialRepository socialRepository;

@Override
public BadgeCode getBadgeCode() {
return BadgeCode.QUACK_QUACK;
}

@Override
public boolean checkCondition(final User user) {
return socialRepository.countByUserId(user.getId()) >= 15;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
import im.toduck.domain.badge.domain.checker.BadgeConditionChecker;
import im.toduck.domain.badge.domain.usecase.BadgeUseCase;
import im.toduck.domain.badge.persistence.entity.BadgeCode;
import im.toduck.domain.concentration.domain.event.ConcentrationSavedEvent;
import im.toduck.domain.diary.domain.event.DiaryCreatedEvent;
import im.toduck.domain.routine.domain.event.RoutineCreatedEvent;
import im.toduck.domain.social.domain.event.SocialCreatedEvent;
import im.toduck.domain.user.domain.event.UserSignedUpEvent;
import im.toduck.domain.user.domain.service.UserService;
import im.toduck.domain.user.persistence.entity.User;
Expand Down Expand Up @@ -39,14 +43,59 @@ public void handleUserSignedUp(final UserSignedUpEvent event) {
checkAndGrantBadge(user, BadgeCode.BABY_DUCK);
}

@Async
@EventListener
@Transactional
public void handleRoutineCreated(final RoutineCreatedEvent event) {
log.info("루틴 생성 이벤트 수신 - UserId: {}", event.getUserId());
User user = userService.getUserById(event.getUserId())
.orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER));

checkAndGrantBadge(user, BadgeCode.PERFECTIONIST);
checkAndGrantBadge(user, BadgeCode.CROW);
}

@Async
@EventListener
@Transactional
public void handleSocialCreated(final SocialCreatedEvent event) {
log.info("소셜 게시글 생성 이벤트 수신 - UserId: {}", event.getUserId());
User user = userService.getUserById(event.getUserId())
.orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER));

checkAndGrantBadge(user, BadgeCode.QUACK_QUACK);
}

@Async
@EventListener
@Transactional
public void handleConcentrationSaved(final ConcentrationSavedEvent event) {
log.info("집중 저장 이벤트 수신 - UserId: {}", event.getUserId());
User user = userService.getUserById(event.getUserId())
.orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER));

checkAndGrantBadge(user, BadgeCode.FOCUS_GENIUS);
}

@Async
@EventListener
@Transactional
public void handleDiaryCreated(final DiaryCreatedEvent event) {
log.info("일기 생성 이벤트 수신 - UserId: {}", event.getUserId());
User user = userService.getUserById(event.getUserId())
.orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER));

checkAndGrantBadge(user, BadgeCode.DAILY_DIARY);
}

private void checkAndGrantBadge(final User user, final BadgeCode badgeCode) {
findCheckerByBadgeCode(badgeCode)
.filter(checker -> checker.checkCondition(user))
.ifPresent(checker -> {
badgeUseCase.grantBadge(user, badgeCode);
});

log.info("뱃지 부여 완료 - UserId: {}, BadgeCode: {}", user.getId(), badgeCode);
log.info("배지 부여 완료 - UserId: {}, BadgeCode: {}", user.getId(), badgeCode);
}

private Optional<BadgeConditionChecker> findCheckerByBadgeCode(final BadgeCode badgeCode) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public enum BadgeCode {
CROW("까마귀"), // 기억력 카테고리 5개
QUACK_QUACK("꽥꽥"), // 소셜 글 15개
FOCUS_GENIUS("집중천재"), // 타이머 15회
PAT_PAT("토닥토닥"), // 감정일기 월 50%
DAILY_DIARY("하루일기"), // 감정일기 월 50%

// 출석 기반
THREE_DAYS_STREAK("작심삼일"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package im.toduck.domain.concentration.domain.event;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public class ConcentrationSavedEvent {
private final Long userId;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
import java.time.YearMonth;
import java.util.List;

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;

import im.toduck.domain.concentration.common.mapper.ConcentrationMapper;
import im.toduck.domain.concentration.domain.event.ConcentrationSavedEvent;
import im.toduck.domain.concentration.persistence.entity.Concentration;
import im.toduck.domain.concentration.persistence.repository.ConcentrationRepository;
import im.toduck.domain.concentration.presentation.dto.request.ConcentrationRequest;
Expand All @@ -20,6 +22,7 @@
@RequiredArgsConstructor
public class ConcentrationService {
private final ConcentrationRepository concentrationRepository;
private final ApplicationEventPublisher eventPublisher;

@Transactional
public Concentration saveConcentration(User user, ConcentrationRequest request) {
Expand All @@ -30,7 +33,11 @@ public Concentration saveConcentration(User user, ConcentrationRequest request)
concentration.addSettingCount(request.settingCount());
concentration.addTime(request.time());

return concentrationRepository.save(concentration);
Concentration saved = concentrationRepository.save(concentration);

eventPublisher.publishEvent(new ConcentrationSavedEvent(user.getId()));

return saved;
}

@Transactional
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,7 @@ public interface ConcentrationRepository extends JpaRepository<Concentration, Lo
@Modifying(clearAutomatically = true)
@Query("UPDATE Concentration c SET c.deletedAt = NOW() WHERE c.user = :user")
void deleteAllByUser(@Param("user") User user);

@Query("SELECT COALESCE(SUM(c.targetCount), 0) FROM Concentration c WHERE c.user = :user AND c.deletedAt IS NULL")
long sumTargetCountByUser(@Param("user") User user);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package im.toduck.domain.diary.domain.event;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public class DiaryCreatedEvent {
private final Long userId;
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
import java.util.Optional;
import java.util.stream.Collectors;

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import im.toduck.domain.diary.common.mapper.DiaryImageFileMapper;
import im.toduck.domain.diary.common.mapper.DiaryMapper;
import im.toduck.domain.diary.domain.event.DiaryCreatedEvent;
import im.toduck.domain.diary.persistence.entity.Diary;
import im.toduck.domain.diary.persistence.entity.DiaryImage;
import im.toduck.domain.diary.persistence.repository.DiaryImageRepository;
Expand All @@ -35,14 +37,19 @@
public class DiaryService {
private final DiaryRepository diaryRepository;
private final DiaryImageRepository diaryImageRepository;
private final ApplicationEventPublisher eventPublisher;

@Transactional
public Diary createDiary(
final User user,
final DiaryCreateRequest request
) {
Diary diary = DiaryMapper.toDiary(user, request);
return diaryRepository.save(diary);
Diary savedDiary = diaryRepository.save(diary);

eventPublisher.publishEvent(new DiaryCreatedEvent(user.getId()));

return savedDiary;
}

@Transactional
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,8 @@ public interface DiaryRepository extends JpaRepository<Diary, Long>, DiaryReposi
long countDistinctUsers();

Optional<Diary> getDiaryByUserIdAndId(Long userId, Long diaryId);

@Query("SELECT COUNT(DISTINCT d.date) FROM Diary d WHERE d.user = :user AND d.date BETWEEN :startDate AND :endDate")
long countDistinctDateByUserAndDateBetween(@Param("user") User user, @Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,9 @@ public interface RoutineRepository extends JpaRepository<Routine, Long>, Routine

@Query("SELECT COUNT(DISTINCT r.user) FROM Routine r WHERE r.deletedAt IS NULL")
long countDistinctUsers();

long countByUserAndDeletedAtIsNull(User user);

@Query("SELECT COUNT(DISTINCT r.category) FROM Routine r WHERE r.user = :user AND r.deletedAt IS NULL")
long countDistinctCategoryByUser(@Param("user") User user);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package im.toduck.domain.social.domain.event;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public class SocialCreatedEvent {
private final Long userId;
}
Loading