From a64963a97a22cfe2523b080f73a1a9922b41f87e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=A0=95=EB=B9=88?= Date: Tue, 14 Apr 2026 01:09:58 +0900 Subject: [PATCH 01/21] =?UTF-8?q?feat:=20UserAttendance=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserAttendanceJpaEntity.java | 44 +++++++++++++++++++ .../UserAttendanceJpaMapper.java | 29 ++++++++++++ .../UserAttendanceJpaRepository.java | 14 ++++++ .../UserAttendancePersistenceAdapter.java | 39 ++++++++++++++++ .../out/UserAttendanceRepositoryPort.java | 14 ++++++ .../userattendance/entity/UserAttendance.java | 31 +++++++++++++ .../migration/V35__add_user_attendances.sql | 17 +++++++ 7 files changed, 188 insertions(+) create mode 100644 src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaEntity.java create mode 100644 src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaMapper.java create mode 100644 src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java create mode 100644 src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java create mode 100644 src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java create mode 100644 src/main/java/com/process/clash/domain/user/userattendance/entity/UserAttendance.java create mode 100644 src/main/resources/db/migration/V35__add_user_attendances.sql diff --git a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaEntity.java b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaEntity.java new file mode 100644 index 000000000..1049d5796 --- /dev/null +++ b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaEntity.java @@ -0,0 +1,44 @@ +package com.process.clash.adapter.persistence.user.userattendance; + +import com.process.clash.adapter.persistence.user.user.UserJpaEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.Instant; + +@Entity +@EntityListeners(AuditingEntityListener.class) +@Table( + name = "user_attendances", + uniqueConstraints = @UniqueConstraint(columnNames = {"fk_user_id", "is_attended"}) +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class UserAttendanceJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @CreatedDate + @Column(nullable = false) + private Instant createdAt; + + @LastModifiedDate + @Column(nullable = false) + private Instant updatedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fk_user_id", nullable = false) + private UserJpaEntity user; + + @Column(nullable = false) + private boolean isAttended; +} diff --git a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaMapper.java b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaMapper.java new file mode 100644 index 000000000..cb9e0deed --- /dev/null +++ b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaMapper.java @@ -0,0 +1,29 @@ +package com.process.clash.adapter.persistence.user.userattendance; + +import com.process.clash.adapter.persistence.user.user.UserJpaEntity; +import com.process.clash.domain.user.userattendance.entity.UserAttendance; +import org.springframework.stereotype.Component; + +@Component +public class UserAttendanceJpaMapper { + + public UserAttendanceJpaEntity toJpaEntity(UserAttendance userAttendance, UserJpaEntity userJpaEntity) { + return new UserAttendanceJpaEntity( + userAttendance.id(), + userAttendance.createdAt(), + userAttendance.updatedAt(), + userJpaEntity, + userAttendance.isAttended() + ); + } + + public UserAttendance toDomain(UserAttendanceJpaEntity entity) { + return new UserAttendance( + entity.getId(), + entity.getCreatedAt(), + entity.getUpdatedAt(), + entity.getUser().getId(), + entity.isAttended() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java new file mode 100644 index 000000000..054a76804 --- /dev/null +++ b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java @@ -0,0 +1,14 @@ +package com.process.clash.adapter.persistence.user.userattendance; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserAttendanceJpaRepository extends JpaRepository { + + Optional findByUserIdAndIsAttended(Long userId, boolean isAttended); + + boolean existsByUserIdAndIsAttended(Long userId, boolean isAttended); +} \ No newline at end of file diff --git a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java new file mode 100644 index 000000000..31bd4d8ee --- /dev/null +++ b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java @@ -0,0 +1,39 @@ +package com.process.clash.adapter.persistence.user.userattendance; + +import com.process.clash.adapter.persistence.user.user.UserJpaEntity; +import com.process.clash.adapter.persistence.user.user.UserJpaRepository; +import com.process.clash.application.user.userattendance.port.out.UserAttendanceRepositoryPort; +import com.process.clash.domain.user.userattendance.entity.UserAttendance; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class UserAttendancePersistenceAdapter implements UserAttendanceRepositoryPort { + + private final UserAttendanceJpaRepository userAttendanceJpaRepository; + private final UserAttendanceJpaMapper userAttendanceJpaMapper; + private final UserJpaRepository userJpaRepository; + + @Override + public UserAttendance save(UserAttendance userAttendance) { + UserJpaEntity userJpaEntity = userJpaRepository.getReferenceById(userAttendance.userId()); + UserAttendanceJpaEntity savedEntity = userAttendanceJpaRepository.save( + userAttendanceJpaMapper.toJpaEntity(userAttendance, userJpaEntity) + ); + return userAttendanceJpaMapper.toDomain(savedEntity); + } + + @Override + public Optional findByUserIdAndIsAttended(Long userId, boolean isAttended) { + return userAttendanceJpaRepository.findByUserIdAndIsAttended(userId, isAttended) + .map(userAttendanceJpaMapper::toDomain); + } + + @Override + public boolean existsByUserIdAndIsAttended(Long userId, boolean isAttended) { + return userAttendanceJpaRepository.existsByUserIdAndIsAttended(userId, isAttended); + } +} \ No newline at end of file diff --git a/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java b/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java new file mode 100644 index 000000000..c98fc8693 --- /dev/null +++ b/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java @@ -0,0 +1,14 @@ +package com.process.clash.application.user.userattendance.port.out; + +import com.process.clash.domain.user.userattendance.entity.UserAttendance; + +import java.util.Optional; + +public interface UserAttendanceRepositoryPort { + + UserAttendance save(UserAttendance userAttendance); + + Optional findByUserIdAndIsAttended(Long userId, boolean isAttended); + + boolean existsByUserIdAndIsAttended(Long userId, boolean isAttended); +} \ No newline at end of file diff --git a/src/main/java/com/process/clash/domain/user/userattendance/entity/UserAttendance.java b/src/main/java/com/process/clash/domain/user/userattendance/entity/UserAttendance.java new file mode 100644 index 000000000..f9398a1ef --- /dev/null +++ b/src/main/java/com/process/clash/domain/user/userattendance/entity/UserAttendance.java @@ -0,0 +1,31 @@ +package com.process.clash.domain.user.userattendance.entity; + +import java.time.Instant; + +public record UserAttendance( + Long id, + Instant createdAt, + Instant updatedAt, + Long userId, + boolean isAttended +) { + public static UserAttendance create(Long userId) { + return new UserAttendance( + null, + null, + null, + userId, + false + ); + } + + public UserAttendance markAttend(Long id) { + return new UserAttendance( + this.id, + this.createdAt, + null, + this.userId, + true + ); + } +} \ No newline at end of file diff --git a/src/main/resources/db/migration/V35__add_user_attendances.sql b/src/main/resources/db/migration/V35__add_user_attendances.sql new file mode 100644 index 000000000..b7ca3677c --- /dev/null +++ b/src/main/resources/db/migration/V35__add_user_attendances.sql @@ -0,0 +1,17 @@ +CREATE TABLE user_attendances +( + id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + fk_user_id BIGINT NOT NULL, + is_attended BOOLEAN NOT NULL, + CONSTRAINT pk_user_attendances PRIMARY KEY (id), + CONSTRAINT uc_user_attendances_user_attended UNIQUE (fk_user_id, is_attended) +); + +ALTER TABLE user_attendances + ADD CONSTRAINT fk_user_attendances_on_fk_user + FOREIGN KEY (fk_user_id) REFERENCES users (id); + +CREATE INDEX idx_user_attendances_user_id + ON user_attendances (fk_user_id); \ No newline at end of file From 77fbcff0509f6439e3911bb490bf20cf3523a154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=A0=95=EB=B9=88?= Date: Tue, 14 Apr 2026 01:20:37 +0900 Subject: [PATCH 02/21] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=20=EC=B6=9C?= =?UTF-8?q?=EC=84=9D=20=EC=9D=BC=EA=B4=84=20=EC=83=9D=EC=84=B1=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserAttendanceJpaRepository.java | 9 ++++++ .../UserAttendancePersistenceAdapter.java | 21 ++++++++++++++ .../UserAttendanceInitScheduler.java | 29 +++++++++++++++++++ .../out/UserAttendanceRepositoryPort.java | 7 +++++ .../service/InitUserAttendanceService.java | 29 +++++++++++++++++++ 5 files changed, 95 insertions(+) create mode 100644 src/main/java/com/process/clash/adapter/scheduler/UserAttendanceInitScheduler.java create mode 100644 src/main/java/com/process/clash/application/user/userattendance/service/InitUserAttendanceService.java diff --git a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java index 054a76804..c5a055c0d 100644 --- a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java +++ b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java @@ -1,8 +1,10 @@ package com.process.clash.adapter.persistence.user.userattendance; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -11,4 +13,11 @@ public interface UserAttendanceJpaRepository extends JpaRepository findByUserIdAndIsAttended(Long userId, boolean isAttended); boolean existsByUserIdAndIsAttended(Long userId, boolean isAttended); + + @Query(value = """ + SELECT u.id + FROM users u + WHERE u.deleted_at IS NULL + """, nativeQuery = true) + List findAllNonDeletedUserIds(); } \ No newline at end of file diff --git a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java index 31bd4d8ee..361144d6d 100644 --- a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java +++ b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java @@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -36,4 +37,24 @@ public Optional findByUserIdAndIsAttended(Long userId, boolean i public boolean existsByUserIdAndIsAttended(Long userId, boolean isAttended) { return userAttendanceJpaRepository.existsByUserIdAndIsAttended(userId, isAttended); } + + @Override + public List findAllNonDeletedUserIds() { + return userAttendanceJpaRepository.findAllNonDeletedUserIds(); + } + + @Override + public void saveAll(List attendances) { + List entities = attendances.stream() + .map(attendance -> userAttendanceJpaMapper.toJpaEntity( + attendance, userJpaRepository.getReferenceById(attendance.userId()) + )) + .toList(); + userAttendanceJpaRepository.saveAll(entities); + } + + @Override + public void deleteAll() { + userAttendanceJpaRepository.deleteAllInBatch(); + } } \ No newline at end of file diff --git a/src/main/java/com/process/clash/adapter/scheduler/UserAttendanceInitScheduler.java b/src/main/java/com/process/clash/adapter/scheduler/UserAttendanceInitScheduler.java new file mode 100644 index 000000000..0b4b5a01b --- /dev/null +++ b/src/main/java/com/process/clash/adapter/scheduler/UserAttendanceInitScheduler.java @@ -0,0 +1,29 @@ +package com.process.clash.adapter.scheduler; + +import com.process.clash.application.user.userattendance.service.InitUserAttendanceService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class UserAttendanceInitScheduler { + + private final InitUserAttendanceService initUserAttendanceService; + + /** + * 매일 KST 06:00에 전체 활성 유저의 출석 기록을 초기화 (is_attended = false) + */ + @Scheduled(cron = "0 0 6 * * *", zone = "Asia/Seoul") + public void initDailyAttendance() { + try { + log.info("출석 초기화 스케줄 실행 시작"); + initUserAttendanceService.initDailyAttendance(); + log.info("출석 초기화 스케줄 실행 완료"); + } catch (Exception e) { + log.error("출석 초기화 스케줄 실행 실패", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java b/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java index c98fc8693..d8b9e35e4 100644 --- a/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java +++ b/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java @@ -2,6 +2,7 @@ import com.process.clash.domain.user.userattendance.entity.UserAttendance; +import java.util.List; import java.util.Optional; public interface UserAttendanceRepositoryPort { @@ -11,4 +12,10 @@ public interface UserAttendanceRepositoryPort { Optional findByUserIdAndIsAttended(Long userId, boolean isAttended); boolean existsByUserIdAndIsAttended(Long userId, boolean isAttended); + + List findAllNonDeletedUserIds(); + + void saveAll(List attendances); + + void deleteAll(); } \ No newline at end of file diff --git a/src/main/java/com/process/clash/application/user/userattendance/service/InitUserAttendanceService.java b/src/main/java/com/process/clash/application/user/userattendance/service/InitUserAttendanceService.java new file mode 100644 index 000000000..00ecdaf11 --- /dev/null +++ b/src/main/java/com/process/clash/application/user/userattendance/service/InitUserAttendanceService.java @@ -0,0 +1,29 @@ +package com.process.clash.application.user.userattendance.service; + +import com.process.clash.application.user.userattendance.port.out.UserAttendanceRepositoryPort; +import com.process.clash.domain.user.userattendance.entity.UserAttendance; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class InitUserAttendanceService { + + private final UserAttendanceRepositoryPort userAttendanceRepositoryPort; + + @Transactional + public void initDailyAttendance() { + userAttendanceRepositoryPort.deleteAll(); + + List activeUserIds = userAttendanceRepositoryPort.findAllNonDeletedUserIds(); + + List attendances = activeUserIds.stream() + .map(UserAttendance::create) + .toList(); + + userAttendanceRepositoryPort.saveAll(attendances); + } +} \ No newline at end of file From 8935e11b385d892e8a222fec232d25e0f45d1541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=A0=95=EB=B9=88?= Date: Tue, 14 Apr 2026 01:37:47 +0900 Subject: [PATCH 03/21] =?UTF-8?q?feat:=20currentAttendanceStreak=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=97=B0=EA=B4=80=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/user/user/UserJpaEntity.java | 3 ++ .../persistence/user/user/UserJpaMapper.java | 6 ++- .../clash/domain/user/user/entity/User.java | 39 ++++++++++++------- ...add_current_attendance_streak_to_users.sql | 1 + .../UserSectionProgressJpaRepositoryTest.java | 4 +- .../ReSignUpAfterWithdrawIntegrationTest.java | 9 +++-- .../softdelete/SoftDeleteIntegrationTest.java | 3 +- .../UserNoticeJpaRepositoryTest.java | 3 +- .../service/FindAllBattleInfoServiceTest.java | 2 +- .../FindApplyBattleListServiceTest.java | 3 +- .../service/CompareWithRivalsServiceTest.java | 3 +- .../service/FindAllRivalsServiceTest.java | 3 +- .../service/GetMyRivalActingServiceTest.java | 3 +- .../group/service/CreateGroupServiceTest.java | 3 +- .../service/GetGroupActivityServiceTest.java | 3 +- .../service/GetMyProfileServiceTest.java | 3 +- .../GetUserGitHubActivityServiceTest.java | 3 +- .../service/GetUserItemsServiceTest.java | 3 +- .../service/GetUserProfileServiceTest.java | 6 ++- .../service/GetUserRivalsServiceTest.java | 6 ++- .../service/RankingTierUpdateServiceTest.java | 3 +- .../service/CreateSubjectV2ServiceTest.java | 3 +- .../GetActivityStatisticsV2ServiceTest.java | 3 +- .../service/GetTodayRecordV2ServiceTest.java | 3 +- .../v2/service/StartRecordV2ServiceTest.java | 3 +- .../SubmitQuestionV2AnswerServiceTest.java | 3 +- .../service/SeasonFinishServiceTest.java | 3 +- .../service/GithubExpGrantServiceTest.java | 2 +- .../service/StudyTimeExpGrantServiceTest.java | 2 +- .../service/ResetPasswordServiceTest.java | 3 +- .../service/UpdateMyPrivacyServiceTest.java | 3 +- .../UpdateMyProfileImageServiceTest.java | 3 +- .../user/service/WithdrawnUserQueryTest.java | 3 +- .../service/LinkGitHubOAuthServiceTest.java | 3 +- .../domain/user/user/entity/UserTest.java | 6 ++- 35 files changed, 103 insertions(+), 52 deletions(-) create mode 100644 src/main/resources/db/migration/V36__add_current_attendance_streak_to_users.sql diff --git a/src/main/java/com/process/clash/adapter/persistence/user/user/UserJpaEntity.java b/src/main/java/com/process/clash/adapter/persistence/user/user/UserJpaEntity.java index 3a6715084..b49dc8dd8 100644 --- a/src/main/java/com/process/clash/adapter/persistence/user/user/UserJpaEntity.java +++ b/src/main/java/com/process/clash/adapter/persistence/user/user/UserJpaEntity.java @@ -82,4 +82,7 @@ public class UserJpaEntity { @Enumerated(EnumType.STRING) @Column(nullable = false) private ExpTier currentExpTier; + + @Column(nullable = false) + private int currentAttendanceStreak; } diff --git a/src/main/java/com/process/clash/adapter/persistence/user/user/UserJpaMapper.java b/src/main/java/com/process/clash/adapter/persistence/user/user/UserJpaMapper.java index 8aac658a7..eeaaa6cdb 100644 --- a/src/main/java/com/process/clash/adapter/persistence/user/user/UserJpaMapper.java +++ b/src/main/java/com/process/clash/adapter/persistence/user/user/UserJpaMapper.java @@ -24,7 +24,8 @@ public UserJpaEntity toJpaEntity(User user) { user.deletedAt(), user.isPrivate(), user.currentRankTier(), - user.currentExpTier() + user.currentExpTier(), + user.currentAttendanceStreak() ); } @@ -46,7 +47,8 @@ public User toDomain(UserJpaEntity userJpaEntity) { userJpaEntity.getDeletedAt(), userJpaEntity.isPrivate(), userJpaEntity.getCurrentRankTier(), - userJpaEntity.getCurrentExpTier() + userJpaEntity.getCurrentExpTier(), + userJpaEntity.getCurrentAttendanceStreak() ); } } diff --git a/src/main/java/com/process/clash/domain/user/user/entity/User.java b/src/main/java/com/process/clash/domain/user/user/entity/User.java index e87544abf..06e565757 100644 --- a/src/main/java/com/process/clash/domain/user/user/entity/User.java +++ b/src/main/java/com/process/clash/domain/user/user/entity/User.java @@ -25,7 +25,8 @@ public record User( Instant deletedAt, boolean isPrivate, RankTier currentRankTier, - ExpTier currentExpTier + ExpTier currentExpTier, + int currentAttendanceStreak ) { public boolean isDeleted() { return deletedAt != null; @@ -53,7 +54,8 @@ public static User createDefault(String username, String email, String name, Str null, false, RankTier.NONE, - ExpTier.UNRANKED + ExpTier.UNRANKED, + 0 ); } @@ -75,7 +77,8 @@ public User submitMajor(Major major) { this.deletedAt, this.isPrivate, this.currentRankTier, - this.currentExpTier + this.currentExpTier, + this.currentAttendanceStreak ); } @@ -97,7 +100,8 @@ public User active() { this.deletedAt, this.isPrivate, this.currentRankTier, - this.currentExpTier + this.currentExpTier, + this.currentAttendanceStreak ); } @@ -123,7 +127,8 @@ public User updateSignupInfo(String username, String email, String name, String this.deletedAt, this.isPrivate, this.currentRankTier, - this.currentExpTier + this.currentExpTier, + this.currentAttendanceStreak ); } @@ -147,7 +152,8 @@ public User spendCookie(int amount) { this.deletedAt, this.isPrivate, this.currentRankTier, - this.currentExpTier + this.currentExpTier, + this.currentAttendanceStreak ); } @@ -171,7 +177,8 @@ public User addCookie(int amount) { this.deletedAt, this.isPrivate, this.currentRankTier, - this.currentExpTier + this.currentExpTier, + this.currentAttendanceStreak ); } @@ -193,7 +200,8 @@ public User updateProfileImage(String profileImage) { this.deletedAt, this.isPrivate, this.currentRankTier, - this.currentExpTier + this.currentExpTier, + this.currentAttendanceStreak ); } @@ -215,7 +223,8 @@ public User withPassword(String encodedPassword) { this.deletedAt, this.isPrivate, this.currentRankTier, - this.currentExpTier + this.currentExpTier, + this.currentAttendanceStreak ); } @@ -238,7 +247,8 @@ public User addExp(int delta) { this.deletedAt, this.isPrivate, this.currentRankTier, - ExpTier.fromExp(newTotalExp) + ExpTier.fromExp(newTotalExp), + this.currentAttendanceStreak ); } @@ -260,7 +270,8 @@ public User withRankTier(RankTier rankTier) { this.deletedAt, this.isPrivate, rankTier, - this.currentExpTier + this.currentExpTier, + this.currentAttendanceStreak ); } @@ -282,7 +293,8 @@ public User seasonReset(int cookieReward) { this.deletedAt, this.isPrivate, RankTier.NONE, - ExpTier.UNRANKED + ExpTier.UNRANKED, + this.currentAttendanceStreak ); } @@ -291,7 +303,8 @@ public User updatePrivacy(boolean isPrivate) { this.id, this.createdAt, Instant.now(), this.username, this.email, this.name, this.password, this.role, this.profileImage, this.totalExp, this.totalCookie, this.major, - this.userStatus, this.deletedAt, isPrivate, this.currentRankTier, this.currentExpTier + this.userStatus, this.deletedAt, isPrivate, this.currentRankTier, this.currentExpTier, + this.currentAttendanceStreak ); } } diff --git a/src/main/resources/db/migration/V36__add_current_attendance_streak_to_users.sql b/src/main/resources/db/migration/V36__add_current_attendance_streak_to_users.sql new file mode 100644 index 000000000..bdb541b69 --- /dev/null +++ b/src/main/resources/db/migration/V36__add_current_attendance_streak_to_users.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN current_attendance_streak INT NOT NULL DEFAULT 0; \ No newline at end of file diff --git a/src/test/java/com/process/clash/adapter/persistence/roadmap/sectionprogress/UserSectionProgressJpaRepositoryTest.java b/src/test/java/com/process/clash/adapter/persistence/roadmap/sectionprogress/UserSectionProgressJpaRepositoryTest.java index 2a6363ec5..1f1ccb081 100644 --- a/src/test/java/com/process/clash/adapter/persistence/roadmap/sectionprogress/UserSectionProgressJpaRepositoryTest.java +++ b/src/test/java/com/process/clash/adapter/persistence/roadmap/sectionprogress/UserSectionProgressJpaRepositoryTest.java @@ -94,12 +94,12 @@ void beforeEach() { // 4. User 저장 User noSectionProgressUser = new User(null, now, now, "userA", "userA@gmail.com", "유저A", "password", - Role.USER, "", 0, 0, Major.NONE, UserStatus.PENDING, null, false, RankTier.NONE, ExpTier.UNRANKED); + Role.USER, "", 0, 0, Major.NONE, UserStatus.PENDING, null, false, RankTier.NONE, ExpTier.UNRANKED, 0); noSectionProgressUserJpaEntity = userJpaMapper.toJpaEntity(noSectionProgressUser); em.persist(noSectionProgressUserJpaEntity); User sectionProgressUser = new User(null, now, now, "userB", "userB@gmail.com", "유저B", "password", - Role.USER, "", 0, 0, Major.NONE, UserStatus.PENDING, null, false, RankTier.NONE, ExpTier.UNRANKED); + Role.USER, "", 0, 0, Major.NONE, UserStatus.PENDING, null, false, RankTier.NONE, ExpTier.UNRANKED, 0); sectionProgressUserJpaEntity = userJpaMapper.toJpaEntity(sectionProgressUser); em.persist(sectionProgressUserJpaEntity); diff --git a/src/test/java/com/process/clash/adapter/persistence/softdelete/ReSignUpAfterWithdrawIntegrationTest.java b/src/test/java/com/process/clash/adapter/persistence/softdelete/ReSignUpAfterWithdrawIntegrationTest.java index 165f40338..92c41cdc7 100644 --- a/src/test/java/com/process/clash/adapter/persistence/softdelete/ReSignUpAfterWithdrawIntegrationTest.java +++ b/src/test/java/com/process/clash/adapter/persistence/softdelete/ReSignUpAfterWithdrawIntegrationTest.java @@ -72,7 +72,8 @@ void afterWithdraw_canReSignUpWithSameUsernameAndEmail() { UserJpaEntity newEntity = userJpaMapper.toJpaEntity(new User( null, Instant.now(), Instant.now(), "testuser", "test@example.com", "재가입유저", - "pw", Role.USER, "", 0, 0, Major.NONE, UserStatus.ACTIVE, null, false, RankTier.NONE, ExpTier.UNRANKED + "pw", Role.USER, "", 0, 0, Major.NONE, UserStatus.ACTIVE, null, false, RankTier.NONE, ExpTier.UNRANKED, + 0 )); UserJpaEntity saved = userJpaRepository.save(newEntity); em.flush(); @@ -165,7 +166,8 @@ void afterReSignUp_findByUsername_returnsNewUser() { UserJpaEntity newEntity = userJpaMapper.toJpaEntity(new User( null, Instant.now(), Instant.now(), "testuser", "test@example.com", "재가입유저", - "pw", Role.USER, "", 0, 0, Major.NONE, UserStatus.ACTIVE, null, false, RankTier.NONE, ExpTier.UNRANKED + "pw", Role.USER, "", 0, 0, Major.NONE, UserStatus.ACTIVE, null, false, RankTier.NONE, ExpTier.UNRANKED, + 0 )); UserJpaEntity saved = userJpaRepository.save(newEntity); em.flush(); @@ -187,7 +189,8 @@ private UserJpaEntity persistUser(String username, String email) { User user = new User( null, Instant.now(), Instant.now(), username, email, "테스트유저", - "pw", Role.USER, "", 0, 0, Major.NONE, UserStatus.ACTIVE, null, false, RankTier.NONE, ExpTier.UNRANKED + "pw", Role.USER, "", 0, 0, Major.NONE, UserStatus.ACTIVE, null, false, RankTier.NONE, ExpTier.UNRANKED, + 0 ); UserJpaEntity entity = userJpaMapper.toJpaEntity(user); em.persist(entity); diff --git a/src/test/java/com/process/clash/adapter/persistence/softdelete/SoftDeleteIntegrationTest.java b/src/test/java/com/process/clash/adapter/persistence/softdelete/SoftDeleteIntegrationTest.java index 5434d3c4c..b3637ac53 100644 --- a/src/test/java/com/process/clash/adapter/persistence/softdelete/SoftDeleteIntegrationTest.java +++ b/src/test/java/com/process/clash/adapter/persistence/softdelete/SoftDeleteIntegrationTest.java @@ -98,7 +98,8 @@ private UserJpaEntity persistUser(String label) { User user = new User( null, Instant.now(), Instant.now(), "user_" + suffix, suffix + "@test.com", "유저" + label, - "pw", Role.USER, "", 0, 0, Major.NONE, UserStatus.ACTIVE, null, false, RankTier.NONE, ExpTier.UNRANKED + "pw", Role.USER, "", 0, 0, Major.NONE, UserStatus.ACTIVE, null, false, RankTier.NONE, ExpTier.UNRANKED, + 0 ); UserJpaEntity entity = userJpaMapper.toJpaEntity(user); em.persist(entity); diff --git a/src/test/java/com/process/clash/adapter/persistence/user/usernotice/UserNoticeJpaRepositoryTest.java b/src/test/java/com/process/clash/adapter/persistence/user/usernotice/UserNoticeJpaRepositoryTest.java index f01b11a68..a9bd981af 100644 --- a/src/test/java/com/process/clash/adapter/persistence/user/usernotice/UserNoticeJpaRepositoryTest.java +++ b/src/test/java/com/process/clash/adapter/persistence/user/usernotice/UserNoticeJpaRepositoryTest.java @@ -111,7 +111,8 @@ private UserJpaEntity persistUser(String name, String email) { User user = new User( null, null, null, name + counter, email, name, - "pw", Role.USER, "", 0, 0, Major.NONE, UserStatus.ACTIVE, null, false, RankTier.NONE, ExpTier.UNRANKED + "pw", Role.USER, "", 0, 0, Major.NONE, UserStatus.ACTIVE, null, false, RankTier.NONE, ExpTier.UNRANKED, + 0 ); UserJpaEntity entity = userJpaMapper.toJpaEntity(user); em.persist(entity); diff --git a/src/test/java/com/process/clash/application/compete/rival/battle/service/FindAllBattleInfoServiceTest.java b/src/test/java/com/process/clash/application/compete/rival/battle/service/FindAllBattleInfoServiceTest.java index 1eda717c8..43304446e 100644 --- a/src/test/java/com/process/clash/application/compete/rival/battle/service/FindAllBattleInfoServiceTest.java +++ b/src/test/java/com/process/clash/application/compete/rival/battle/service/FindAllBattleInfoServiceTest.java @@ -227,7 +227,7 @@ private void stubCommonDependenciesWithEnemy(RankTier rankTier, ExpTier expTier, User enemyUser = new User(ENEMY_USER_ID, Instant.now(), Instant.now(), "enemy", "enemy@example.com", "Enemy", "", Role.USER, "", 0, 0, Major.NONE, UserStatus.ACTIVE, null, - false, rankTier, expTier); + false, rankTier, expTier, 0); when(battleRepositoryPort.findByUserIdWithOutRejected(CURRENT_USER_ID)).thenReturn(battles); when(rivalRepositoryPort.findByIdIn(Set.of(RIVAL_ID))).thenReturn(List.of(rival)); diff --git a/src/test/java/com/process/clash/application/compete/rival/battle/service/FindApplyBattleListServiceTest.java b/src/test/java/com/process/clash/application/compete/rival/battle/service/FindApplyBattleListServiceTest.java index 022d32539..1e6c26d23 100644 --- a/src/test/java/com/process/clash/application/compete/rival/battle/service/FindApplyBattleListServiceTest.java +++ b/src/test/java/com/process/clash/application/compete/rival/battle/service/FindApplyBattleListServiceTest.java @@ -196,7 +196,8 @@ private User createUser(Long id, String name, String username, String profileIma username, username + "@test.com", name, "password", Role.USER, profileImage, 0, 0, Major.NONE, UserStatus.ACTIVE, - null, false, RankTier.NONE, ExpTier.UNRANKED + null, false, RankTier.NONE, ExpTier.UNRANKED, + 0 ); } } diff --git a/src/test/java/com/process/clash/application/compete/rival/rival/service/CompareWithRivalsServiceTest.java b/src/test/java/com/process/clash/application/compete/rival/rival/service/CompareWithRivalsServiceTest.java index f3e294441..f9339f0d6 100644 --- a/src/test/java/com/process/clash/application/compete/rival/rival/service/CompareWithRivalsServiceTest.java +++ b/src/test/java/com/process/clash/application/compete/rival/rival/service/CompareWithRivalsServiceTest.java @@ -106,7 +106,8 @@ private User createUser(Long id) { id, Instant.now(), Instant.now(), "user" + id, "user" + id + "@test.com", "User" + id, "password", Role.USER, "", 0, 0, - Major.NONE, UserStatus.ACTIVE, null, false, RankTier.NONE, ExpTier.UNRANKED + Major.NONE, UserStatus.ACTIVE, null, false, RankTier.NONE, ExpTier.UNRANKED, + 0 ); } diff --git a/src/test/java/com/process/clash/application/compete/rival/rival/service/FindAllRivalsServiceTest.java b/src/test/java/com/process/clash/application/compete/rival/rival/service/FindAllRivalsServiceTest.java index 800699abb..1e8975af1 100644 --- a/src/test/java/com/process/clash/application/compete/rival/rival/service/FindAllRivalsServiceTest.java +++ b/src/test/java/com/process/clash/application/compete/rival/rival/service/FindAllRivalsServiceTest.java @@ -156,7 +156,8 @@ private User createUser(Long id, String name, RankTier rankTier, ExpTier expTier name, name + "@test.com", name, "password", Role.USER, "", 0, 0, Major.NONE, UserStatus.ACTIVE, - null, false, rankTier, expTier + null, false, rankTier, expTier, + 0 ); } } \ No newline at end of file diff --git a/src/test/java/com/process/clash/application/compete/rival/rival/service/GetMyRivalActingServiceTest.java b/src/test/java/com/process/clash/application/compete/rival/rival/service/GetMyRivalActingServiceTest.java index 703f929c2..d027c018b 100644 --- a/src/test/java/com/process/clash/application/compete/rival/rival/service/GetMyRivalActingServiceTest.java +++ b/src/test/java/com/process/clash/application/compete/rival/rival/service/GetMyRivalActingServiceTest.java @@ -257,7 +257,8 @@ private User createUser(Long id, String username, String name, RankTier rankTier null, false, rankTier, - expTier + expTier, + 0 ); } } diff --git a/src/test/java/com/process/clash/application/group/service/CreateGroupServiceTest.java b/src/test/java/com/process/clash/application/group/service/CreateGroupServiceTest.java index f0322449a..d26981b4f 100644 --- a/src/test/java/com/process/clash/application/group/service/CreateGroupServiceTest.java +++ b/src/test/java/com/process/clash/application/group/service/CreateGroupServiceTest.java @@ -182,7 +182,8 @@ private User createUser(Long id) { id, Instant.now(), Instant.now(), "testuser", "test@example.com", "테스트유저", "password", Role.USER, "", 0, 0, Major.NONE, UserStatus.ACTIVE, null, - false, RankTier.NONE, ExpTier.UNRANKED + false, RankTier.NONE, ExpTier.UNRANKED, + 0 ); } } \ No newline at end of file diff --git a/src/test/java/com/process/clash/application/group/service/GetGroupActivityServiceTest.java b/src/test/java/com/process/clash/application/group/service/GetGroupActivityServiceTest.java index 226243808..c0dcdd9db 100644 --- a/src/test/java/com/process/clash/application/group/service/GetGroupActivityServiceTest.java +++ b/src/test/java/com/process/clash/application/group/service/GetGroupActivityServiceTest.java @@ -129,7 +129,8 @@ private User createUser(Long id, String name) { null, false, RankTier.NONE, - ExpTier.UNRANKED + ExpTier.UNRANKED, + 0 ); } diff --git a/src/test/java/com/process/clash/application/profile/service/GetMyProfileServiceTest.java b/src/test/java/com/process/clash/application/profile/service/GetMyProfileServiceTest.java index 95418f0fe..4d077dba1 100644 --- a/src/test/java/com/process/clash/application/profile/service/GetMyProfileServiceTest.java +++ b/src/test/java/com/process/clash/application/profile/service/GetMyProfileServiceTest.java @@ -135,7 +135,8 @@ private User createUser(Long id, int totalExp, RankTier currentRankTier) { null, false, currentRankTier, - ExpTier.fromExp(totalExp) + ExpTier.fromExp(totalExp), + 0 ); } } diff --git a/src/test/java/com/process/clash/application/profile/service/GetUserGitHubActivityServiceTest.java b/src/test/java/com/process/clash/application/profile/service/GetUserGitHubActivityServiceTest.java index 4db850f4a..c379c2b87 100644 --- a/src/test/java/com/process/clash/application/profile/service/GetUserGitHubActivityServiceTest.java +++ b/src/test/java/com/process/clash/application/profile/service/GetUserGitHubActivityServiceTest.java @@ -99,7 +99,8 @@ private User createUser(Long id, boolean isPrivate) { id, Instant.now(), Instant.now(), "username", "user@example.com", "name", "encoded-password", Role.USER, "", 0, 0, Major.NONE, UserStatus.ACTIVE, - null, isPrivate, RankTier.NONE, ExpTier.UNRANKED + null, isPrivate, RankTier.NONE, ExpTier.UNRANKED, + 0 ); } } diff --git a/src/test/java/com/process/clash/application/profile/service/GetUserItemsServiceTest.java b/src/test/java/com/process/clash/application/profile/service/GetUserItemsServiceTest.java index 5754ff69a..a0f2dc80f 100644 --- a/src/test/java/com/process/clash/application/profile/service/GetUserItemsServiceTest.java +++ b/src/test/java/com/process/clash/application/profile/service/GetUserItemsServiceTest.java @@ -93,7 +93,8 @@ private User createUser(Long id, boolean isPrivate) { id, Instant.now(), Instant.now(), "username", "user@example.com", "name", "encoded-password", Role.USER, "", 0, 0, Major.NONE, UserStatus.ACTIVE, - null, isPrivate, RankTier.NONE, ExpTier.UNRANKED + null, isPrivate, RankTier.NONE, ExpTier.UNRANKED, + 0 ); } } diff --git a/src/test/java/com/process/clash/application/profile/service/GetUserProfileServiceTest.java b/src/test/java/com/process/clash/application/profile/service/GetUserProfileServiceTest.java index 2182b4497..e94610e99 100644 --- a/src/test/java/com/process/clash/application/profile/service/GetUserProfileServiceTest.java +++ b/src/test/java/com/process/clash/application/profile/service/GetUserProfileServiceTest.java @@ -85,7 +85,8 @@ void execute_throwsUserNotFoundException_whenUserDeleted() { targetUserId, Instant.now(), Instant.now(), "username", "user@example.com", "name", "encoded-password", Role.USER, "", 0, 0, Major.NONE, UserStatus.ACTIVE, - Instant.now(), false, RankTier.NONE, ExpTier.UNRANKED + Instant.now(), false, RankTier.NONE, ExpTier.UNRANKED, + 0 ); GetUserProfileData.Command command = new GetUserProfileData.Command(targetUserId); @@ -116,7 +117,8 @@ private User createUser(Long id, boolean isPrivate) { id, Instant.now(), Instant.now(), "username", "user@example.com", "name", "encoded-password", Role.USER, "", 0, 0, Major.NONE, UserStatus.ACTIVE, - null, isPrivate, RankTier.NONE, ExpTier.UNRANKED + null, isPrivate, RankTier.NONE, ExpTier.UNRANKED, + 0 ); } } diff --git a/src/test/java/com/process/clash/application/profile/service/GetUserRivalsServiceTest.java b/src/test/java/com/process/clash/application/profile/service/GetUserRivalsServiceTest.java index 142ba7cbf..e78e62105 100644 --- a/src/test/java/com/process/clash/application/profile/service/GetUserRivalsServiceTest.java +++ b/src/test/java/com/process/clash/application/profile/service/GetUserRivalsServiceTest.java @@ -174,7 +174,8 @@ private User createUser(Long id, boolean isPrivate) { null, isPrivate, RankTier.NONE, - ExpTier.UNRANKED + ExpTier.UNRANKED, + 0 ); } @@ -196,7 +197,8 @@ private User createDeletedUser(Long id) { Instant.now(), false, RankTier.NONE, - ExpTier.UNRANKED + ExpTier.UNRANKED, + 0 ); } } diff --git a/src/test/java/com/process/clash/application/ranking/service/RankingTierUpdateServiceTest.java b/src/test/java/com/process/clash/application/ranking/service/RankingTierUpdateServiceTest.java index a021ee79b..ec27a2f3b 100644 --- a/src/test/java/com/process/clash/application/ranking/service/RankingTierUpdateServiceTest.java +++ b/src/test/java/com/process/clash/application/ranking/service/RankingTierUpdateServiceTest.java @@ -278,7 +278,8 @@ private User createUser(Long id, int totalExp, RankTier currentRankTier) { "user" + id, "user" + id + "@test.com", "User" + id, "pw", Role.USER, "", totalExp, 0, Major.NONE, UserStatus.ACTIVE, null, false, currentRankTier, - ExpTier.fromExp(totalExp) + ExpTier.fromExp(totalExp), + 0 ); } diff --git a/src/test/java/com/process/clash/application/record/v2/service/CreateSubjectV2ServiceTest.java b/src/test/java/com/process/clash/application/record/v2/service/CreateSubjectV2ServiceTest.java index 6911d5675..297343c41 100644 --- a/src/test/java/com/process/clash/application/record/v2/service/CreateSubjectV2ServiceTest.java +++ b/src/test/java/com/process/clash/application/record/v2/service/CreateSubjectV2ServiceTest.java @@ -90,7 +90,8 @@ private User createUser(Long id) { null, false, RankTier.NONE, - ExpTier.UNRANKED + ExpTier.UNRANKED, + 0 ); } } diff --git a/src/test/java/com/process/clash/application/record/v2/service/GetActivityStatisticsV2ServiceTest.java b/src/test/java/com/process/clash/application/record/v2/service/GetActivityStatisticsV2ServiceTest.java index 78152c13a..b26cd4be9 100644 --- a/src/test/java/com/process/clash/application/record/v2/service/GetActivityStatisticsV2ServiceTest.java +++ b/src/test/java/com/process/clash/application/record/v2/service/GetActivityStatisticsV2ServiceTest.java @@ -154,7 +154,8 @@ private User createUser(Long id) { null, false, RankTier.NONE, - ExpTier.UNRANKED + ExpTier.UNRANKED, + 0 ); } } diff --git a/src/test/java/com/process/clash/application/record/v2/service/GetTodayRecordV2ServiceTest.java b/src/test/java/com/process/clash/application/record/v2/service/GetTodayRecordV2ServiceTest.java index 0161f6b31..e0fd6a736 100644 --- a/src/test/java/com/process/clash/application/record/v2/service/GetTodayRecordV2ServiceTest.java +++ b/src/test/java/com/process/clash/application/record/v2/service/GetTodayRecordV2ServiceTest.java @@ -240,7 +240,8 @@ private User createUser(Long id) { null, false, RankTier.NONE, - ExpTier.UNRANKED + ExpTier.UNRANKED, + 0 ); } } diff --git a/src/test/java/com/process/clash/application/record/v2/service/StartRecordV2ServiceTest.java b/src/test/java/com/process/clash/application/record/v2/service/StartRecordV2ServiceTest.java index 68cc861cf..74ffb1b07 100644 --- a/src/test/java/com/process/clash/application/record/v2/service/StartRecordV2ServiceTest.java +++ b/src/test/java/com/process/clash/application/record/v2/service/StartRecordV2ServiceTest.java @@ -533,7 +533,8 @@ private User createUser(Long id) { null, false, RankTier.NONE, - ExpTier.UNRANKED + ExpTier.UNRANKED, + 0 ); } } diff --git a/src/test/java/com/process/clash/application/roadmap/v2/question/service/SubmitQuestionV2AnswerServiceTest.java b/src/test/java/com/process/clash/application/roadmap/v2/question/service/SubmitQuestionV2AnswerServiceTest.java index d230935c4..3880ad996 100644 --- a/src/test/java/com/process/clash/application/roadmap/v2/question/service/SubmitQuestionV2AnswerServiceTest.java +++ b/src/test/java/com/process/clash/application/roadmap/v2/question/service/SubmitQuestionV2AnswerServiceTest.java @@ -167,7 +167,8 @@ private User createUser(Long userId, int totalCookie) { null, false, RankTier.NONE, - ExpTier.UNRANKED + ExpTier.UNRANKED, + 0 ); } } diff --git a/src/test/java/com/process/clash/application/shop/season/service/SeasonFinishServiceTest.java b/src/test/java/com/process/clash/application/shop/season/service/SeasonFinishServiceTest.java index 8520bd4e5..d9ccb6194 100644 --- a/src/test/java/com/process/clash/application/shop/season/service/SeasonFinishServiceTest.java +++ b/src/test/java/com/process/clash/application/shop/season/service/SeasonFinishServiceTest.java @@ -273,7 +273,8 @@ private User createUser(Long id, int totalExp, int totalCookie) { "user" + id, "user" + id + "@test.com", "User" + id, "pw", Role.USER, "", totalExp, totalCookie, Major.NONE, UserStatus.ACTIVE, null, false, RankTier.NONE, - ExpTier.fromExp(totalExp) + ExpTier.fromExp(totalExp), + 0 ); } } \ No newline at end of file diff --git a/src/test/java/com/process/clash/application/user/exp/service/GithubExpGrantServiceTest.java b/src/test/java/com/process/clash/application/user/exp/service/GithubExpGrantServiceTest.java index b51fab414..51435bbcf 100644 --- a/src/test/java/com/process/clash/application/user/exp/service/GithubExpGrantServiceTest.java +++ b/src/test/java/com/process/clash/application/user/exp/service/GithubExpGrantServiceTest.java @@ -282,6 +282,6 @@ private GitHubDailyStats createStats(Long userId, int commits, int prs, int revi private User createUser(Long id, int totalExp) { return new User(id, Instant.now(), Instant.now(), "user" + id, "user@example.com", "name", "pw", Role.USER, "", totalExp, 0, Major.NONE, UserStatus.ACTIVE, null, false, RankTier.NONE, - ExpTier.fromExp(totalExp)); + ExpTier.fromExp(totalExp), 0); } } diff --git a/src/test/java/com/process/clash/application/user/exp/service/StudyTimeExpGrantServiceTest.java b/src/test/java/com/process/clash/application/user/exp/service/StudyTimeExpGrantServiceTest.java index 721b0c196..9b23c3023 100644 --- a/src/test/java/com/process/clash/application/user/exp/service/StudyTimeExpGrantServiceTest.java +++ b/src/test/java/com/process/clash/application/user/exp/service/StudyTimeExpGrantServiceTest.java @@ -278,6 +278,6 @@ void grant_usesTodayAsStudyDateWhenStartedAfterBoundary() { private User createUser(Long id, int totalExp) { return new User(id, Instant.now(), Instant.now(), "user" + id, "user@example.com", "name", "pw", Role.USER, "", totalExp, 0, Major.NONE, UserStatus.ACTIVE, null, false, RankTier.NONE, - ExpTier.fromExp(totalExp)); + ExpTier.fromExp(totalExp), 0); } } diff --git a/src/test/java/com/process/clash/application/user/user/service/ResetPasswordServiceTest.java b/src/test/java/com/process/clash/application/user/user/service/ResetPasswordServiceTest.java index fc9714d34..e6dcfc990 100644 --- a/src/test/java/com/process/clash/application/user/user/service/ResetPasswordServiceTest.java +++ b/src/test/java/com/process/clash/application/user/user/service/ResetPasswordServiceTest.java @@ -111,7 +111,8 @@ private User createUser(Long id, String password) { id, Instant.now(), Instant.now(), "testuser", "test@example.com", "테스트유저", password, Role.USER, "", 0, 0, Major.NONE, UserStatus.ACTIVE, null, - false, RankTier.NONE, ExpTier.UNRANKED + false, RankTier.NONE, ExpTier.UNRANKED, + 0 ); } } diff --git a/src/test/java/com/process/clash/application/user/user/service/UpdateMyPrivacyServiceTest.java b/src/test/java/com/process/clash/application/user/user/service/UpdateMyPrivacyServiceTest.java index a7ddc8b39..72f0df78c 100644 --- a/src/test/java/com/process/clash/application/user/user/service/UpdateMyPrivacyServiceTest.java +++ b/src/test/java/com/process/clash/application/user/user/service/UpdateMyPrivacyServiceTest.java @@ -87,7 +87,8 @@ private User createUser(Long id, boolean isPrivate) { id, Instant.now(), Instant.now(), "username", "user@example.com", "name", "encoded-password", Role.USER, "", 0, 0, Major.NONE, UserStatus.ACTIVE, - null, isPrivate, RankTier.NONE, ExpTier.UNRANKED + null, isPrivate, RankTier.NONE, ExpTier.UNRANKED, + 0 ); } } diff --git a/src/test/java/com/process/clash/application/user/user/service/UpdateMyProfileImageServiceTest.java b/src/test/java/com/process/clash/application/user/user/service/UpdateMyProfileImageServiceTest.java index 96dcf7af0..24bfc4041 100644 --- a/src/test/java/com/process/clash/application/user/user/service/UpdateMyProfileImageServiceTest.java +++ b/src/test/java/com/process/clash/application/user/user/service/UpdateMyProfileImageServiceTest.java @@ -97,7 +97,8 @@ private User createUser(Long id) { null, false, RankTier.NONE, - ExpTier.UNRANKED + ExpTier.UNRANKED, + 0 ); } } diff --git a/src/test/java/com/process/clash/application/user/user/service/WithdrawnUserQueryTest.java b/src/test/java/com/process/clash/application/user/user/service/WithdrawnUserQueryTest.java index 524ed9f48..df92c18a4 100644 --- a/src/test/java/com/process/clash/application/user/user/service/WithdrawnUserQueryTest.java +++ b/src/test/java/com/process/clash/application/user/user/service/WithdrawnUserQueryTest.java @@ -133,7 +133,8 @@ private User withdrawnUser(Long id) { Instant.now(), // deletedAt 설정 → 탈퇴 상태 false, RankTier.NONE, - ExpTier.UNRANKED + ExpTier.UNRANKED, + 0 ); } } diff --git a/src/test/java/com/process/clash/application/user/usergithub/service/LinkGitHubOAuthServiceTest.java b/src/test/java/com/process/clash/application/user/usergithub/service/LinkGitHubOAuthServiceTest.java index bbc9064fb..9f5eea6ca 100644 --- a/src/test/java/com/process/clash/application/user/usergithub/service/LinkGitHubOAuthServiceTest.java +++ b/src/test/java/com/process/clash/application/user/usergithub/service/LinkGitHubOAuthServiceTest.java @@ -149,7 +149,8 @@ private User createUser(Long id) { null, false, RankTier.NONE, - ExpTier.UNRANKED + ExpTier.UNRANKED, + 0 ); } } diff --git a/src/test/java/com/process/clash/domain/user/user/entity/UserTest.java b/src/test/java/com/process/clash/domain/user/user/entity/UserTest.java index 1f6ee971e..08165dbc2 100644 --- a/src/test/java/com/process/clash/domain/user/user/entity/UserTest.java +++ b/src/test/java/com/process/clash/domain/user/user/entity/UserTest.java @@ -48,7 +48,8 @@ private User activeUser() { null, false, RankTier.NONE, - ExpTier.UNRANKED + ExpTier.UNRANKED, + 0 ); } @@ -70,7 +71,8 @@ private User deletedUser() { Instant.now(), false, RankTier.NONE, - ExpTier.UNRANKED + ExpTier.UNRANKED, + 0 ); } } From 6b586fb33ada30f3b24c7ffdaf1789bb9c0e13a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=A0=95=EB=B9=88?= Date: Tue, 14 Apr 2026 09:02:09 +0900 Subject: [PATCH 04/21] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20=EB=B0=8F=20=EC=97=B0=EC=86=8D=20=EC=B6=9C=EC=84=9D?= =?UTF-8?q?=20=EC=A7=91=EA=B3=84=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserAttendanceJpaEntity.java | 8 ++- .../UserAttendanceJpaMapper.java | 2 + .../UserAttendanceJpaRepository.java | 10 ++-- .../UserAttendancePersistenceAdapter.java | 10 ++-- .../web/user/controller/UserController.java | 14 ++++++ .../web/user/dto/MarkAttendanceDto.java | 14 ++++++ .../data/MarkAttendanceData.java | 14 ++++++ .../conflict/AlreadyAttendedException.java | 10 ++++ .../UserAttendanceNotFoundException.java | 10 ++++ .../statuscode/UserAttendanceStatusCode.java | 18 +++++++ .../port/in/MarkAttendanceUseCase.java | 7 +++ .../out/UserAttendanceRepositoryPort.java | 5 +- .../service/InitUserAttendanceService.java | 9 ++-- .../service/MarkAttendanceService.java | 49 +++++++++++++++++++ .../clash/domain/user/user/entity/User.java | 10 ++++ .../userattendance/entity/UserAttendance.java | 20 +++++++- .../migration/V35__add_user_attendances.sql | 13 ++--- 17 files changed, 197 insertions(+), 26 deletions(-) create mode 100644 src/main/java/com/process/clash/adapter/web/user/dto/MarkAttendanceDto.java create mode 100644 src/main/java/com/process/clash/application/user/userattendance/data/MarkAttendanceData.java create mode 100644 src/main/java/com/process/clash/application/user/userattendance/exception/exception/conflict/AlreadyAttendedException.java create mode 100644 src/main/java/com/process/clash/application/user/userattendance/exception/exception/notfound/UserAttendanceNotFoundException.java create mode 100644 src/main/java/com/process/clash/application/user/userattendance/exception/statuscode/UserAttendanceStatusCode.java create mode 100644 src/main/java/com/process/clash/application/user/userattendance/port/in/MarkAttendanceUseCase.java create mode 100644 src/main/java/com/process/clash/application/user/userattendance/service/MarkAttendanceService.java diff --git a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaEntity.java b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaEntity.java index 1049d5796..64862afec 100644 --- a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaEntity.java +++ b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaEntity.java @@ -11,12 +11,13 @@ import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.time.Instant; +import java.time.LocalDate; @Entity @EntityListeners(AuditingEntityListener.class) @Table( name = "user_attendances", - uniqueConstraints = @UniqueConstraint(columnNames = {"fk_user_id", "is_attended"}) + uniqueConstraints = @UniqueConstraint(columnNames = {"fk_user_id", "attendance_date"}) ) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -39,6 +40,9 @@ public class UserAttendanceJpaEntity { @JoinColumn(name = "fk_user_id", nullable = false) private UserJpaEntity user; + @Column(nullable = false) + private LocalDate attendanceDate; + @Column(nullable = false) private boolean isAttended; -} +} \ No newline at end of file diff --git a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaMapper.java b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaMapper.java index cb9e0deed..51e241e23 100644 --- a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaMapper.java +++ b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaMapper.java @@ -13,6 +13,7 @@ public UserAttendanceJpaEntity toJpaEntity(UserAttendance userAttendance, UserJp userAttendance.createdAt(), userAttendance.updatedAt(), userJpaEntity, + userAttendance.attendanceDate(), userAttendance.isAttended() ); } @@ -23,6 +24,7 @@ public UserAttendance toDomain(UserAttendanceJpaEntity entity) { entity.getCreatedAt(), entity.getUpdatedAt(), entity.getUser().getId(), + entity.getAttendanceDate(), entity.isAttended() ); } diff --git a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java index c5a055c0d..50381e77e 100644 --- a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java +++ b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java @@ -2,17 +2,21 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.time.LocalDate; import java.util.List; import java.util.Optional; @Repository public interface UserAttendanceJpaRepository extends JpaRepository { - Optional findByUserIdAndIsAttended(Long userId, boolean isAttended); - - boolean existsByUserIdAndIsAttended(Long userId, boolean isAttended); + @Query("SELECT ua FROM UserAttendanceJpaEntity ua WHERE ua.user.id = :userId AND ua.attendanceDate = :attendanceDate") + Optional findByUserIdAndAttendanceDate( + @Param("userId") Long userId, + @Param("attendanceDate") LocalDate attendanceDate + ); @Query(value = """ SELECT u.id diff --git a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java index 361144d6d..16a726c89 100644 --- a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java +++ b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java @@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.time.LocalDate; import java.util.List; import java.util.Optional; @@ -28,16 +29,11 @@ public UserAttendance save(UserAttendance userAttendance) { } @Override - public Optional findByUserIdAndIsAttended(Long userId, boolean isAttended) { - return userAttendanceJpaRepository.findByUserIdAndIsAttended(userId, isAttended) + public Optional findByUserIdAndAttendanceDate(Long userId, LocalDate attendanceDate) { + return userAttendanceJpaRepository.findByUserIdAndAttendanceDate(userId, attendanceDate) .map(userAttendanceJpaMapper::toDomain); } - @Override - public boolean existsByUserIdAndIsAttended(Long userId, boolean isAttended) { - return userAttendanceJpaRepository.existsByUserIdAndIsAttended(userId, isAttended); - } - @Override public List findAllNonDeletedUserIds() { return userAttendanceJpaRepository.findAllNonDeletedUserIds(); diff --git a/src/main/java/com/process/clash/adapter/web/user/controller/UserController.java b/src/main/java/com/process/clash/adapter/web/user/controller/UserController.java index b2599f1fa..ebd21d18f 100644 --- a/src/main/java/com/process/clash/adapter/web/user/controller/UserController.java +++ b/src/main/java/com/process/clash/adapter/web/user/controller/UserController.java @@ -10,6 +10,7 @@ import com.process.clash.adapter.web.user.dto.GetMyItemsDto; import com.process.clash.adapter.web.user.dto.GetMyProfileDto; import com.process.clash.adapter.web.user.dto.GetUserProfileDto; +import com.process.clash.adapter.web.user.dto.MarkAttendanceDto; import com.process.clash.adapter.web.user.dto.UpdateMyPrivacyDto; import com.process.clash.adapter.web.user.dto.IssueProfileImageUploadUrlDto; import com.process.clash.adapter.web.user.dto.LinkGitHubOAuthDto; @@ -47,6 +48,8 @@ import com.process.clash.application.user.user.port.in.UpdateMyPrivacyUseCase; import com.process.clash.application.user.user.port.in.UpdateMyProfileImageUseCase; import com.process.clash.application.user.user.port.in.WithdrawUseCase; +import com.process.clash.application.user.userattendance.data.MarkAttendanceData; +import com.process.clash.application.user.userattendance.port.in.MarkAttendanceUseCase; import com.process.clash.application.user.usergithub.data.GetMyGitHubLinkStatusData; import com.process.clash.application.user.usergithub.data.LinkGitHubOAuthData; import com.process.clash.application.user.usergithub.port.in.GetMyGitHubLinkStatusUsecase; @@ -90,6 +93,7 @@ public class UserController implements UserControllerDocument { private final GetUserGitHubActivityUsecase getUserGitHubActivityUsecase; private final GetUserRivalsUsecase getUserRivalsUsecase; private final UpdateMyPrivacyUseCase updateMyPrivacyUseCase; + private final MarkAttendanceUseCase markAttendanceUseCase; @DeleteMapping("/me") public ApiResponse withdraw( @@ -264,6 +268,16 @@ public ApiResponse getUserRivals( return ApiResponse.success(response, "유저 라이벌 목록을 성공적으로 조회했습니다."); } + @PostMapping("/me/attendance") + public ApiResponse markAttendance( + @AuthenticatedActor Actor actor + ) { + MarkAttendanceData.Command command = new MarkAttendanceData.Command(actor); + MarkAttendanceData.Result result = markAttendanceUseCase.execute(command); + MarkAttendanceDto.Response response = MarkAttendanceDto.Response.from(result); + return ApiResponse.success(response, "출석이 완료되었습니다."); + } + @PatchMapping("/me/privacy") public ApiResponse updateMyPrivacy( @AuthenticatedActor Actor actor, diff --git a/src/main/java/com/process/clash/adapter/web/user/dto/MarkAttendanceDto.java b/src/main/java/com/process/clash/adapter/web/user/dto/MarkAttendanceDto.java new file mode 100644 index 000000000..02618ff8a --- /dev/null +++ b/src/main/java/com/process/clash/adapter/web/user/dto/MarkAttendanceDto.java @@ -0,0 +1,14 @@ +package com.process.clash.adapter.web.user.dto; + +import com.process.clash.application.user.userattendance.data.MarkAttendanceData; + +public class MarkAttendanceDto { + + public record Response( + int attendanceStreak + ) { + public static Response from(MarkAttendanceData.Result result) { + return new Response(result.attendanceStreak()); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/process/clash/application/user/userattendance/data/MarkAttendanceData.java b/src/main/java/com/process/clash/application/user/userattendance/data/MarkAttendanceData.java new file mode 100644 index 000000000..c15481a1c --- /dev/null +++ b/src/main/java/com/process/clash/application/user/userattendance/data/MarkAttendanceData.java @@ -0,0 +1,14 @@ +package com.process.clash.application.user.userattendance.data; + +import com.process.clash.application.common.actor.Actor; + +public class MarkAttendanceData { + + public record Command( + Actor actor + ) {} + + public record Result( + int attendanceStreak + ) {} +} \ No newline at end of file diff --git a/src/main/java/com/process/clash/application/user/userattendance/exception/exception/conflict/AlreadyAttendedException.java b/src/main/java/com/process/clash/application/user/userattendance/exception/exception/conflict/AlreadyAttendedException.java new file mode 100644 index 000000000..da46f3614 --- /dev/null +++ b/src/main/java/com/process/clash/application/user/userattendance/exception/exception/conflict/AlreadyAttendedException.java @@ -0,0 +1,10 @@ +package com.process.clash.application.user.userattendance.exception.exception.conflict; + +import com.process.clash.application.common.exception.exception.ConflictException; +import com.process.clash.application.user.userattendance.exception.statuscode.UserAttendanceStatusCode; + +public class AlreadyAttendedException extends ConflictException { + public AlreadyAttendedException() { + super(UserAttendanceStatusCode.ALREADY_ATTENDED); + } +} \ No newline at end of file diff --git a/src/main/java/com/process/clash/application/user/userattendance/exception/exception/notfound/UserAttendanceNotFoundException.java b/src/main/java/com/process/clash/application/user/userattendance/exception/exception/notfound/UserAttendanceNotFoundException.java new file mode 100644 index 000000000..6ac0a98e9 --- /dev/null +++ b/src/main/java/com/process/clash/application/user/userattendance/exception/exception/notfound/UserAttendanceNotFoundException.java @@ -0,0 +1,10 @@ +package com.process.clash.application.user.userattendance.exception.exception.notfound; + +import com.process.clash.application.common.exception.exception.NotFoundException; +import com.process.clash.application.user.userattendance.exception.statuscode.UserAttendanceStatusCode; + +public class UserAttendanceNotFoundException extends NotFoundException { + public UserAttendanceNotFoundException() { + super(UserAttendanceStatusCode.USER_ATTENDANCE_NOT_FOUND); + } +} \ No newline at end of file diff --git a/src/main/java/com/process/clash/application/user/userattendance/exception/statuscode/UserAttendanceStatusCode.java b/src/main/java/com/process/clash/application/user/userattendance/exception/statuscode/UserAttendanceStatusCode.java new file mode 100644 index 000000000..567a8bd16 --- /dev/null +++ b/src/main/java/com/process/clash/application/user/userattendance/exception/statuscode/UserAttendanceStatusCode.java @@ -0,0 +1,18 @@ +package com.process.clash.application.user.userattendance.exception.statuscode; + +import com.process.clash.application.common.exception.statuscode.StatusCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum UserAttendanceStatusCode implements StatusCode { + ALREADY_ATTENDED("ALREADY_ATTENDED", "오늘 이미 출석하였습니다.", HttpStatus.CONFLICT), + USER_ATTENDANCE_NOT_FOUND("USER_ATTENDANCE_NOT_FOUND", "출석 정보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + ; + + private final String code; + private final String message; + private final HttpStatus httpStatus; +} \ No newline at end of file diff --git a/src/main/java/com/process/clash/application/user/userattendance/port/in/MarkAttendanceUseCase.java b/src/main/java/com/process/clash/application/user/userattendance/port/in/MarkAttendanceUseCase.java new file mode 100644 index 000000000..58fadb053 --- /dev/null +++ b/src/main/java/com/process/clash/application/user/userattendance/port/in/MarkAttendanceUseCase.java @@ -0,0 +1,7 @@ +package com.process.clash.application.user.userattendance.port.in; + +import com.process.clash.application.user.userattendance.data.MarkAttendanceData; + +public interface MarkAttendanceUseCase { + MarkAttendanceData.Result execute(MarkAttendanceData.Command command); +} \ No newline at end of file diff --git a/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java b/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java index d8b9e35e4..089940235 100644 --- a/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java +++ b/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java @@ -2,6 +2,7 @@ import com.process.clash.domain.user.userattendance.entity.UserAttendance; +import java.time.LocalDate; import java.util.List; import java.util.Optional; @@ -9,9 +10,7 @@ public interface UserAttendanceRepositoryPort { UserAttendance save(UserAttendance userAttendance); - Optional findByUserIdAndIsAttended(Long userId, boolean isAttended); - - boolean existsByUserIdAndIsAttended(Long userId, boolean isAttended); + Optional findByUserIdAndAttendanceDate(Long userId, LocalDate attendanceDate); List findAllNonDeletedUserIds(); diff --git a/src/main/java/com/process/clash/application/user/userattendance/service/InitUserAttendanceService.java b/src/main/java/com/process/clash/application/user/userattendance/service/InitUserAttendanceService.java index 00ecdaf11..9d93d0751 100644 --- a/src/main/java/com/process/clash/application/user/userattendance/service/InitUserAttendanceService.java +++ b/src/main/java/com/process/clash/application/user/userattendance/service/InitUserAttendanceService.java @@ -6,6 +6,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; import java.util.List; @Service @@ -16,12 +17,14 @@ public class InitUserAttendanceService { @Transactional public void initDailyAttendance() { + LocalDate attendanceDate = UserAttendance.currentAttendanceDate(); + userAttendanceRepositoryPort.deleteAll(); - List activeUserIds = userAttendanceRepositoryPort.findAllNonDeletedUserIds(); + List userIds = userAttendanceRepositoryPort.findAllNonDeletedUserIds(); - List attendances = activeUserIds.stream() - .map(UserAttendance::create) + List attendances = userIds.stream() + .map(userId -> UserAttendance.create(userId, attendanceDate)) .toList(); userAttendanceRepositoryPort.saveAll(attendances); diff --git a/src/main/java/com/process/clash/application/user/userattendance/service/MarkAttendanceService.java b/src/main/java/com/process/clash/application/user/userattendance/service/MarkAttendanceService.java new file mode 100644 index 000000000..5ded234ae --- /dev/null +++ b/src/main/java/com/process/clash/application/user/userattendance/service/MarkAttendanceService.java @@ -0,0 +1,49 @@ +package com.process.clash.application.user.userattendance.service; + +import com.process.clash.application.user.user.exception.exception.notfound.UserNotFoundException; +import com.process.clash.application.user.user.port.out.UserRepositoryPort; +import com.process.clash.application.user.userattendance.data.MarkAttendanceData; +import com.process.clash.application.user.userattendance.exception.exception.conflict.AlreadyAttendedException; +import com.process.clash.application.user.userattendance.exception.exception.notfound.UserAttendanceNotFoundException; +import com.process.clash.application.user.userattendance.port.in.MarkAttendanceUseCase; +import com.process.clash.application.user.userattendance.port.out.UserAttendanceRepositoryPort; +import com.process.clash.domain.user.user.entity.User; +import com.process.clash.domain.user.userattendance.entity.UserAttendance; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +@Service +@RequiredArgsConstructor +@Transactional +public class MarkAttendanceService implements MarkAttendanceUseCase { + + private final UserAttendanceRepositoryPort userAttendanceRepositoryPort; + private final UserRepositoryPort userRepositoryPort; + + @Override + public MarkAttendanceData.Result execute(MarkAttendanceData.Command command) { + Long userId = command.actor().id(); + LocalDate attendanceDate = UserAttendance.currentAttendanceDate(); + + UserAttendance attendance = userAttendanceRepositoryPort + .findByUserIdAndAttendanceDate(userId, attendanceDate) + .orElseThrow(UserAttendanceNotFoundException::new); + + if (attendance.isAttended()) { + throw new AlreadyAttendedException(); + } + + userAttendanceRepositoryPort.save(attendance.markAttend()); + + User user = userRepositoryPort.findByIdForUpdate(userId) + .orElseThrow(UserNotFoundException::new); + + User updated = user.incrementAttendanceStreak(); + userRepositoryPort.save(updated); + + return new MarkAttendanceData.Result(updated.currentAttendanceStreak()); + } +} \ No newline at end of file diff --git a/src/main/java/com/process/clash/domain/user/user/entity/User.java b/src/main/java/com/process/clash/domain/user/user/entity/User.java index 06e565757..29bc6a105 100644 --- a/src/main/java/com/process/clash/domain/user/user/entity/User.java +++ b/src/main/java/com/process/clash/domain/user/user/entity/User.java @@ -307,4 +307,14 @@ public User updatePrivacy(boolean isPrivate) { this.currentAttendanceStreak ); } + + public User incrementAttendanceStreak() { + return new User( + this.id, this.createdAt, Instant.now(), + this.username, this.email, this.name, this.password, this.role, + this.profileImage, this.totalExp, this.totalCookie, this.major, + this.userStatus, this.deletedAt, this.isPrivate, this.currentRankTier, this.currentExpTier, + this.currentAttendanceStreak + 1 + ); + } } diff --git a/src/main/java/com/process/clash/domain/user/userattendance/entity/UserAttendance.java b/src/main/java/com/process/clash/domain/user/userattendance/entity/UserAttendance.java index f9398a1ef..45d7afaf6 100644 --- a/src/main/java/com/process/clash/domain/user/userattendance/entity/UserAttendance.java +++ b/src/main/java/com/process/clash/domain/user/userattendance/entity/UserAttendance.java @@ -1,31 +1,47 @@ package com.process.clash.domain.user.userattendance.entity; import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; public record UserAttendance( Long id, Instant createdAt, Instant updatedAt, Long userId, + LocalDate attendanceDate, boolean isAttended ) { - public static UserAttendance create(Long userId) { + public static UserAttendance create(Long userId, LocalDate attendanceDate) { return new UserAttendance( null, null, null, userId, + attendanceDate, false ); } - public UserAttendance markAttend(Long id) { + public UserAttendance markAttend() { return new UserAttendance( this.id, this.createdAt, null, this.userId, + this.attendanceDate, true ); } + + /** + * KST 06:00을 자정으로 하는 출석 날짜를 반환합니다. + * - 06:00 이후 → 오늘 날짜 + * - 06:00 미만 → 전날 날짜 + */ + public static LocalDate currentAttendanceDate() { + ZonedDateTime kstNow = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); + return kstNow.minusHours(6).toLocalDate(); + } } \ No newline at end of file diff --git a/src/main/resources/db/migration/V35__add_user_attendances.sql b/src/main/resources/db/migration/V35__add_user_attendances.sql index b7ca3677c..2a07f3b21 100644 --- a/src/main/resources/db/migration/V35__add_user_attendances.sql +++ b/src/main/resources/db/migration/V35__add_user_attendances.sql @@ -1,12 +1,13 @@ CREATE TABLE user_attendances ( - id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL, - fk_user_id BIGINT NOT NULL, - is_attended BOOLEAN NOT NULL, + id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + fk_user_id BIGINT NOT NULL, + attendance_date DATE NOT NULL, + is_attended BOOLEAN NOT NULL, CONSTRAINT pk_user_attendances PRIMARY KEY (id), - CONSTRAINT uc_user_attendances_user_attended UNIQUE (fk_user_id, is_attended) + CONSTRAINT uc_user_attendances_user_date UNIQUE (fk_user_id, attendance_date) ); ALTER TABLE user_attendances From 250b848e05c5cb767381778a1ab3edd6cf635f6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=A0=95=EB=B9=88?= Date: Tue, 14 Apr 2026 09:07:41 +0900 Subject: [PATCH 05/21] =?UTF-8?q?test:=20=EC=B6=9C=EC=84=9D=20=EB=B6=80?= =?UTF-8?q?=EB=B6=84=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InitUserAttendanceServiceTest.java | 86 +++++++++++ .../service/MarkAttendanceServiceTest.java | 139 ++++++++++++++++++ .../entity/UserAttendanceTest.java | 80 ++++++++++ 3 files changed, 305 insertions(+) create mode 100644 src/test/java/com/process/clash/application/user/userattendance/service/InitUserAttendanceServiceTest.java create mode 100644 src/test/java/com/process/clash/application/user/userattendance/service/MarkAttendanceServiceTest.java create mode 100644 src/test/java/com/process/clash/domain/user/userattendance/entity/UserAttendanceTest.java diff --git a/src/test/java/com/process/clash/application/user/userattendance/service/InitUserAttendanceServiceTest.java b/src/test/java/com/process/clash/application/user/userattendance/service/InitUserAttendanceServiceTest.java new file mode 100644 index 000000000..d77bca7bd --- /dev/null +++ b/src/test/java/com/process/clash/application/user/userattendance/service/InitUserAttendanceServiceTest.java @@ -0,0 +1,86 @@ +package com.process.clash.application.user.userattendance.service; + +import com.process.clash.application.user.userattendance.port.out.UserAttendanceRepositoryPort; +import com.process.clash.domain.user.userattendance.entity.UserAttendance; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InitUserAttendanceServiceTest { + + @Mock + private UserAttendanceRepositoryPort userAttendanceRepositoryPort; + + private InitUserAttendanceService initUserAttendanceService; + + @BeforeEach + void setUp() { + initUserAttendanceService = new InitUserAttendanceService(userAttendanceRepositoryPort); + } + + @Test + @DisplayName("기존 출석 레코드를 전부 삭제한 후 재생성한다") + void initDailyAttendance_deletesBeforeSaving() { + when(userAttendanceRepositoryPort.findAllNonDeletedUserIds()).thenReturn(List.of(1L)); + + initUserAttendanceService.initDailyAttendance(); + + verify(userAttendanceRepositoryPort).deleteAll(); + verify(userAttendanceRepositoryPort).saveAll(org.mockito.ArgumentMatchers.anyList()); + } + + @Test + @DisplayName("모든 탈퇴하지 않은 유저에 대해 isAttended=false 레코드를 생성한다") + void initDailyAttendance_createsNotAttendedRecordForAllUsers() { + List userIds = List.of(1L, 2L, 3L); + when(userAttendanceRepositoryPort.findAllNonDeletedUserIds()).thenReturn(userIds); + + initUserAttendanceService.initDailyAttendance(); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(userAttendanceRepositoryPort).saveAll(captor.capture()); + + List saved = captor.getValue(); + assertThat(saved).hasSize(3); + assertThat(saved).allMatch(a -> !a.isAttended()); + assertThat(saved).extracting(UserAttendance::userId) + .containsExactlyInAnyOrder(1L, 2L, 3L); + } + + @Test + @DisplayName("모든 레코드의 attendanceDate는 currentAttendanceDate와 동일하다") + void initDailyAttendance_setsCorrectAttendanceDate() { + when(userAttendanceRepositoryPort.findAllNonDeletedUserIds()).thenReturn(List.of(1L, 2L)); + + initUserAttendanceService.initDailyAttendance(); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(userAttendanceRepositoryPort).saveAll(captor.capture()); + + captor.getValue().forEach(a -> + assertThat(a.attendanceDate()).isEqualTo(UserAttendance.currentAttendanceDate()) + ); + } + + @Test + @DisplayName("탈퇴하지 않은 유저가 없으면 빈 목록으로 saveAll을 호출한다") + void initDailyAttendance_savesEmptyList_whenNoUsers() { + when(userAttendanceRepositoryPort.findAllNonDeletedUserIds()).thenReturn(List.of()); + + initUserAttendanceService.initDailyAttendance(); + + verify(userAttendanceRepositoryPort).deleteAll(); + verify(userAttendanceRepositoryPort).saveAll(List.of()); + } +} \ No newline at end of file diff --git a/src/test/java/com/process/clash/application/user/userattendance/service/MarkAttendanceServiceTest.java b/src/test/java/com/process/clash/application/user/userattendance/service/MarkAttendanceServiceTest.java new file mode 100644 index 000000000..935448b12 --- /dev/null +++ b/src/test/java/com/process/clash/application/user/userattendance/service/MarkAttendanceServiceTest.java @@ -0,0 +1,139 @@ +package com.process.clash.application.user.userattendance.service; + +import com.process.clash.application.common.actor.Actor; +import com.process.clash.application.user.user.exception.exception.notfound.UserNotFoundException; +import com.process.clash.application.user.user.port.out.UserRepositoryPort; +import com.process.clash.application.user.userattendance.data.MarkAttendanceData; +import com.process.clash.application.user.userattendance.exception.exception.conflict.AlreadyAttendedException; +import com.process.clash.application.user.userattendance.exception.exception.notfound.UserAttendanceNotFoundException; +import com.process.clash.application.user.userattendance.port.out.UserAttendanceRepositoryPort; +import com.process.clash.domain.common.enums.Major; +import com.process.clash.domain.user.user.entity.User; +import com.process.clash.domain.user.user.enums.Role; +import com.process.clash.domain.user.user.enums.UserStatus; +import com.process.clash.domain.user.userattendance.entity.UserAttendance; +import com.process.clash.domain.user.userrankhistory.enums.ExpTier; +import com.process.clash.domain.user.userrankhistory.enums.RankTier; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MarkAttendanceServiceTest { + + @Mock + private UserAttendanceRepositoryPort userAttendanceRepositoryPort; + + @Mock + private UserRepositoryPort userRepositoryPort; + + private MarkAttendanceService markAttendanceService; + + private final Long USER_ID = 1L; + private final LocalDate ATTENDANCE_DATE = UserAttendance.currentAttendanceDate(); + + @BeforeEach + void setUp() { + markAttendanceService = new MarkAttendanceService(userAttendanceRepositoryPort, userRepositoryPort); + } + + @Test + @DisplayName("오늘 출석 레코드가 없으면 UserAttendanceNotFoundException을 던진다") + void execute_throwsUserAttendanceNotFoundException_whenNoRecord() { + MarkAttendanceData.Command command = new MarkAttendanceData.Command(new Actor(USER_ID)); + when(userAttendanceRepositoryPort.findByUserIdAndAttendanceDate(USER_ID, ATTENDANCE_DATE)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> markAttendanceService.execute(command)) + .isInstanceOf(UserAttendanceNotFoundException.class); + } + + @Test + @DisplayName("이미 출석한 경우 AlreadyAttendedException을 던진다") + void execute_throwsAlreadyAttendedException_whenAlreadyAttended() { + MarkAttendanceData.Command command = new MarkAttendanceData.Command(new Actor(USER_ID)); + UserAttendance alreadyAttended = createAttendance(true); + when(userAttendanceRepositoryPort.findByUserIdAndAttendanceDate(USER_ID, ATTENDANCE_DATE)) + .thenReturn(Optional.of(alreadyAttended)); + + assertThatThrownBy(() -> markAttendanceService.execute(command)) + .isInstanceOf(AlreadyAttendedException.class); + } + + @Test + @DisplayName("출석 성공 시 isAttended=true로 저장된다") + void execute_savesAttendanceAsTrue_whenSuccessful() { + MarkAttendanceData.Command command = new MarkAttendanceData.Command(new Actor(USER_ID)); + UserAttendance notAttended = createAttendance(false); + User user = createUser(USER_ID, 3); + + when(userAttendanceRepositoryPort.findByUserIdAndAttendanceDate(USER_ID, ATTENDANCE_DATE)) + .thenReturn(Optional.of(notAttended)); + when(userRepositoryPort.findByIdForUpdate(USER_ID)) + .thenReturn(Optional.of(user)); + when(userRepositoryPort.save(argThat(u -> u.currentAttendanceStreak() == 4))) + .thenReturn(user.incrementAttendanceStreak()); + + markAttendanceService.execute(command); + + verify(userAttendanceRepositoryPort).save(argThat(UserAttendance::isAttended)); + } + + @Test + @DisplayName("출석 성공 시 streak이 1 증가한 값을 반환한다") + void execute_returnsIncrementedStreak_whenSuccessful() { + MarkAttendanceData.Command command = new MarkAttendanceData.Command(new Actor(USER_ID)); + User user = createUser(USER_ID, 3); + + when(userAttendanceRepositoryPort.findByUserIdAndAttendanceDate(USER_ID, ATTENDANCE_DATE)) + .thenReturn(Optional.of(createAttendance(false))); + when(userRepositoryPort.findByIdForUpdate(USER_ID)) + .thenReturn(Optional.of(user)); + when(userRepositoryPort.save(argThat(u -> u.currentAttendanceStreak() == 4))) + .thenReturn(user.incrementAttendanceStreak()); + + MarkAttendanceData.Result result = markAttendanceService.execute(command); + + assertThat(result.attendanceStreak()).isEqualTo(4); + } + + @Test + @DisplayName("유저가 존재하지 않으면 UserNotFoundException을 던진다") + void execute_throwsUserNotFoundException_whenUserNotFound() { + MarkAttendanceData.Command command = new MarkAttendanceData.Command(new Actor(USER_ID)); + when(userAttendanceRepositoryPort.findByUserIdAndAttendanceDate(USER_ID, ATTENDANCE_DATE)) + .thenReturn(Optional.of(createAttendance(false))); + when(userRepositoryPort.findByIdForUpdate(USER_ID)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> markAttendanceService.execute(command)) + .isInstanceOf(UserNotFoundException.class); + } + + private UserAttendance createAttendance(boolean isAttended) { + return new UserAttendance(1L, Instant.now(), Instant.now(), USER_ID, ATTENDANCE_DATE, isAttended); + } + + private User createUser(Long id, int attendanceStreak) { + return new User( + id, Instant.now(), Instant.now(), + "username", "user@example.com", "name", "encoded-password", + Role.USER, "", 0, 0, Major.NONE, UserStatus.ACTIVE, + null, false, RankTier.NONE, ExpTier.UNRANKED, + attendanceStreak + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/process/clash/domain/user/userattendance/entity/UserAttendanceTest.java b/src/test/java/com/process/clash/domain/user/userattendance/entity/UserAttendanceTest.java new file mode 100644 index 000000000..770fe73ca --- /dev/null +++ b/src/test/java/com/process/clash/domain/user/userattendance/entity/UserAttendanceTest.java @@ -0,0 +1,80 @@ +package com.process.clash.domain.user.userattendance.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; + +import static org.assertj.core.api.Assertions.assertThat; + +class UserAttendanceTest { + + @Test + @DisplayName("create는 isAttended=false인 출석 레코드를 생성한다") + void create_createsNotAttendedRecord() { + LocalDate date = LocalDate.of(2026, 4, 14); + + UserAttendance attendance = UserAttendance.create(1L, date); + + assertThat(attendance.id()).isNull(); + assertThat(attendance.createdAt()).isNull(); + assertThat(attendance.updatedAt()).isNull(); + assertThat(attendance.userId()).isEqualTo(1L); + assertThat(attendance.attendanceDate()).isEqualTo(date); + assertThat(attendance.isAttended()).isFalse(); + } + + @Test + @DisplayName("markAttend는 동일한 id/userId/attendanceDate/createdAt을 유지하며 isAttended=true로 변경한다") + void markAttend_returnsAttendedRecord_withSameIdentityFields() { + LocalDate date = LocalDate.of(2026, 4, 14); + Instant createdAt = Instant.now(); + UserAttendance attendance = new UserAttendance(1L, createdAt, Instant.now(), 2L, date, false); + + UserAttendance marked = attendance.markAttend(); + + assertThat(marked.id()).isEqualTo(1L); + assertThat(marked.userId()).isEqualTo(2L); + assertThat(marked.attendanceDate()).isEqualTo(date); + assertThat(marked.createdAt()).isEqualTo(createdAt); + assertThat(marked.updatedAt()).isNull(); + assertThat(marked.isAttended()).isTrue(); + } + + @Test + @DisplayName("이미 isAttended=true인 레코드에 markAttend를 호출해도 isAttended=true를 유지한다") + void markAttend_remainsTrue_whenAlreadyAttended() { + LocalDate date = LocalDate.of(2026, 4, 14); + UserAttendance attendance = new UserAttendance(1L, Instant.now(), Instant.now(), 1L, date, true); + + UserAttendance marked = attendance.markAttend(); + + assertThat(marked.isAttended()).isTrue(); + } + + @Test + @DisplayName("currentAttendanceDate는 KST 오늘 또는 전날 날짜를 반환한다") + void currentAttendanceDate_returnsKstTodayOrYesterday() { + LocalDate kstToday = LocalDate.now(ZoneId.of("Asia/Seoul")); + LocalDate kstYesterday = kstToday.minusDays(1); + + LocalDate result = UserAttendance.currentAttendanceDate(); + + assertThat(result).isIn(kstToday, kstYesterday); + } + + @Test + @DisplayName("currentAttendanceDate는 KST 06:00 이전이면 전날, 이후면 오늘 날짜를 반환한다") + void currentAttendanceDate_shiftsByMinusHours6FromKst() { + // KST 현재 시간에서 6시간을 뺀 날짜와 동일해야 한다 + LocalDate expected = java.time.ZonedDateTime.now(ZoneId.of("Asia/Seoul")) + .minusHours(6) + .toLocalDate(); + + LocalDate result = UserAttendance.currentAttendanceDate(); + + assertThat(result).isEqualTo(expected); + } +} \ No newline at end of file From a00d52780b00c6c35432adc746e7fd419088ee8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=A0=95=EB=B9=88?= Date: Tue, 14 Apr 2026 09:19:48 +0900 Subject: [PATCH 06/21] =?UTF-8?q?feat:=20=EC=97=B0=EC=86=8D=20=EC=B6=9C?= =?UTF-8?q?=EC=84=9D=20=EC=A4=91=EB=8B=A8=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/user/UserJpaRepository.java | 5 +++ .../user/user/UserPersistenceAdapter.java | 8 +++++ .../UserAttendanceJpaRepository.java | 3 ++ .../UserAttendancePersistenceAdapter.java | 5 +++ .../UserAttendanceInitScheduler.java | 16 ++++++++- .../user/port/out/UserRepositoryPort.java | 2 ++ .../out/UserAttendanceRepositoryPort.java | 2 ++ .../service/ResetAttendanceStreakService.java | 35 +++++++++++++++++++ 8 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/process/clash/application/user/userattendance/service/ResetAttendanceStreakService.java diff --git a/src/main/java/com/process/clash/adapter/persistence/user/user/UserJpaRepository.java b/src/main/java/com/process/clash/adapter/persistence/user/user/UserJpaRepository.java index c50ac9231..1ac9d47d7 100644 --- a/src/main/java/com/process/clash/adapter/persistence/user/user/UserJpaRepository.java +++ b/src/main/java/com/process/clash/adapter/persistence/user/user/UserJpaRepository.java @@ -5,6 +5,7 @@ import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -52,4 +53,8 @@ List findAbleRivalsWithUserInfo( Optional findByIdForUpdate(@Param("id") Long id); List findAllByUserStatusOrderByTotalExpDesc(UserStatus userStatus); + + @Modifying + @Query("UPDATE UserJpaEntity u SET u.currentAttendanceStreak = 0 WHERE u.id IN :userIds") + void resetAttendanceStreakByIds(@Param("userIds") List userIds); } diff --git a/src/main/java/com/process/clash/adapter/persistence/user/user/UserPersistenceAdapter.java b/src/main/java/com/process/clash/adapter/persistence/user/user/UserPersistenceAdapter.java index 8c8b250ef..867af6df2 100644 --- a/src/main/java/com/process/clash/adapter/persistence/user/user/UserPersistenceAdapter.java +++ b/src/main/java/com/process/clash/adapter/persistence/user/user/UserPersistenceAdapter.java @@ -112,4 +112,12 @@ public List findAllOrderByTotalExpDesc() { return userJpaRepository.findAllByUserStatusOrderByTotalExpDesc(UserStatus.ACTIVE) .stream().map(userJpaMapper::toDomain).toList(); } + + @Override + public void resetAttendanceStreakByIds(List userIds) { + if (userIds.isEmpty()) { + return; + } + userJpaRepository.resetAttendanceStreakByIds(userIds); + } } \ No newline at end of file diff --git a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java index 50381e77e..e7181855f 100644 --- a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java +++ b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java @@ -24,4 +24,7 @@ Optional findByUserIdAndAttendanceDate( WHERE u.deleted_at IS NULL """, nativeQuery = true) List findAllNonDeletedUserIds(); + + @Query("SELECT ua.user.id FROM UserAttendanceJpaEntity ua WHERE ua.attendanceDate = :date AND ua.isAttended = false") + List findNotAttendedUserIdsByDate(@Param("date") LocalDate date); } \ No newline at end of file diff --git a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java index 16a726c89..f9888908f 100644 --- a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java +++ b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java @@ -53,4 +53,9 @@ public void saveAll(List attendances) { public void deleteAll() { userAttendanceJpaRepository.deleteAllInBatch(); } + + @Override + public List findNotAttendedUserIdsByDate(LocalDate date) { + return userAttendanceJpaRepository.findNotAttendedUserIdsByDate(date); + } } \ No newline at end of file diff --git a/src/main/java/com/process/clash/adapter/scheduler/UserAttendanceInitScheduler.java b/src/main/java/com/process/clash/adapter/scheduler/UserAttendanceInitScheduler.java index 0b4b5a01b..78cd35d8f 100644 --- a/src/main/java/com/process/clash/adapter/scheduler/UserAttendanceInitScheduler.java +++ b/src/main/java/com/process/clash/adapter/scheduler/UserAttendanceInitScheduler.java @@ -1,6 +1,7 @@ package com.process.clash.adapter.scheduler; import com.process.clash.application.user.userattendance.service.InitUserAttendanceService; +import com.process.clash.application.user.userattendance.service.ResetAttendanceStreakService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; @@ -12,12 +13,25 @@ public class UserAttendanceInitScheduler { private final InitUserAttendanceService initUserAttendanceService; + private final ResetAttendanceStreakService resetAttendanceStreakService; /** - * 매일 KST 06:00에 전체 활성 유저의 출석 기록을 초기화 (is_attended = false) + * 매일 KST 06:00에 다음 두 작업을 순서대로 수행한다. + * 1. 전날 출석하지 않은 유저들의 연속 출석 streak을 0으로 초기화 + * 2. 전체 활성 유저의 출석 기록을 오늘 날짜 기준으로 새로 생성 (is_attended = false) + * + * streak 초기화가 반드시 deleteAll() 이전에 실행되어야 하므로 같은 스케줄 메서드 안에서 순서를 보장한다. */ @Scheduled(cron = "0 0 6 * * *", zone = "Asia/Seoul") public void initDailyAttendance() { + try { + log.info("연속 출석 streak 초기화 스케줄 실행 시작"); + resetAttendanceStreakService.resetStreakForMissedAttendance(); + log.info("연속 출석 streak 초기화 스케줄 실행 완료"); + } catch (Exception e) { + log.error("연속 출석 streak 초기화 스케줄 실행 실패", e); + } + try { log.info("출석 초기화 스케줄 실행 시작"); initUserAttendanceService.initDailyAttendance(); diff --git a/src/main/java/com/process/clash/application/user/user/port/out/UserRepositoryPort.java b/src/main/java/com/process/clash/application/user/user/port/out/UserRepositoryPort.java index b9f9ee339..c63eb20f6 100644 --- a/src/main/java/com/process/clash/application/user/user/port/out/UserRepositoryPort.java +++ b/src/main/java/com/process/clash/application/user/user/port/out/UserRepositoryPort.java @@ -30,4 +30,6 @@ public interface UserRepositoryPort { void deleteById(Long id); Optional findByIdIncludingDeleted(Long id); List findAllOrderByTotalExpDesc(); + + void resetAttendanceStreakByIds(List userIds); } diff --git a/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java b/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java index 089940235..cf7320015 100644 --- a/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java +++ b/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java @@ -17,4 +17,6 @@ public interface UserAttendanceRepositoryPort { void saveAll(List attendances); void deleteAll(); + + List findNotAttendedUserIdsByDate(LocalDate date); } \ No newline at end of file diff --git a/src/main/java/com/process/clash/application/user/userattendance/service/ResetAttendanceStreakService.java b/src/main/java/com/process/clash/application/user/userattendance/service/ResetAttendanceStreakService.java new file mode 100644 index 000000000..47c3a85ed --- /dev/null +++ b/src/main/java/com/process/clash/application/user/userattendance/service/ResetAttendanceStreakService.java @@ -0,0 +1,35 @@ +package com.process.clash.application.user.userattendance.service; + +import com.process.clash.application.user.user.port.out.UserRepositoryPort; +import com.process.clash.application.user.userattendance.port.out.UserAttendanceRepositoryPort; +import com.process.clash.domain.user.userattendance.entity.UserAttendance; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ResetAttendanceStreakService { + + private final UserAttendanceRepositoryPort userAttendanceRepositoryPort; + private final UserRepositoryPort userRepositoryPort; + + /** + * 전날 출석하지 않은 유저들의 연속 출석 streak을 0으로 초기화한다. + * KST 06:00 기준으로 하루가 바뀌므로, currentAttendanceDate()에서 하루를 빼면 전날 날짜가 된다. + */ + @Transactional + public void resetStreakForMissedAttendance() { + LocalDate yesterday = UserAttendance.currentAttendanceDate().minusDays(1); + List missedUserIds = userAttendanceRepositoryPort.findNotAttendedUserIdsByDate(yesterday); + + if (missedUserIds.isEmpty()) { + return; + } + + userRepositoryPort.resetAttendanceStreakByIds(missedUserIds); + } +} \ No newline at end of file From d3d22d3cb6ec47ccc4ac96df1e5830192e85c703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=A0=95=EB=B9=88?= Date: Tue, 14 Apr 2026 09:23:58 +0900 Subject: [PATCH 07/21] =?UTF-8?q?refactor:=20Modifying=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=EB=8C=80=EC=8B=A0=20=EB=8B=A8=EC=88=9C=20=EB=B0=98?= =?UTF-8?q?=EB=B3=B5=EB=AC=B8=20=EC=82=AC=EC=9A=A9=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20=EC=93=B0=EA=B8=B0=20?= =?UTF-8?q?=ED=8A=B8=EB=9E=9C=EC=A0=9D=EC=85=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/user/user/UserJpaRepository.java | 5 ----- .../persistence/user/user/UserPersistenceAdapter.java | 8 -------- .../user/user/port/out/UserRepositoryPort.java | 2 -- .../service/ResetAttendanceStreakService.java | 7 ++++++- .../process/clash/domain/user/user/entity/User.java | 10 ++++++++++ 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/process/clash/adapter/persistence/user/user/UserJpaRepository.java b/src/main/java/com/process/clash/adapter/persistence/user/user/UserJpaRepository.java index 1ac9d47d7..c50ac9231 100644 --- a/src/main/java/com/process/clash/adapter/persistence/user/user/UserJpaRepository.java +++ b/src/main/java/com/process/clash/adapter/persistence/user/user/UserJpaRepository.java @@ -5,7 +5,6 @@ import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; -import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -53,8 +52,4 @@ List findAbleRivalsWithUserInfo( Optional findByIdForUpdate(@Param("id") Long id); List findAllByUserStatusOrderByTotalExpDesc(UserStatus userStatus); - - @Modifying - @Query("UPDATE UserJpaEntity u SET u.currentAttendanceStreak = 0 WHERE u.id IN :userIds") - void resetAttendanceStreakByIds(@Param("userIds") List userIds); } diff --git a/src/main/java/com/process/clash/adapter/persistence/user/user/UserPersistenceAdapter.java b/src/main/java/com/process/clash/adapter/persistence/user/user/UserPersistenceAdapter.java index 867af6df2..8c8b250ef 100644 --- a/src/main/java/com/process/clash/adapter/persistence/user/user/UserPersistenceAdapter.java +++ b/src/main/java/com/process/clash/adapter/persistence/user/user/UserPersistenceAdapter.java @@ -112,12 +112,4 @@ public List findAllOrderByTotalExpDesc() { return userJpaRepository.findAllByUserStatusOrderByTotalExpDesc(UserStatus.ACTIVE) .stream().map(userJpaMapper::toDomain).toList(); } - - @Override - public void resetAttendanceStreakByIds(List userIds) { - if (userIds.isEmpty()) { - return; - } - userJpaRepository.resetAttendanceStreakByIds(userIds); - } } \ No newline at end of file diff --git a/src/main/java/com/process/clash/application/user/user/port/out/UserRepositoryPort.java b/src/main/java/com/process/clash/application/user/user/port/out/UserRepositoryPort.java index c63eb20f6..b9f9ee339 100644 --- a/src/main/java/com/process/clash/application/user/user/port/out/UserRepositoryPort.java +++ b/src/main/java/com/process/clash/application/user/user/port/out/UserRepositoryPort.java @@ -30,6 +30,4 @@ public interface UserRepositoryPort { void deleteById(Long id); Optional findByIdIncludingDeleted(Long id); List findAllOrderByTotalExpDesc(); - - void resetAttendanceStreakByIds(List userIds); } diff --git a/src/main/java/com/process/clash/application/user/userattendance/service/ResetAttendanceStreakService.java b/src/main/java/com/process/clash/application/user/userattendance/service/ResetAttendanceStreakService.java index 47c3a85ed..484549b2f 100644 --- a/src/main/java/com/process/clash/application/user/userattendance/service/ResetAttendanceStreakService.java +++ b/src/main/java/com/process/clash/application/user/userattendance/service/ResetAttendanceStreakService.java @@ -2,6 +2,7 @@ import com.process.clash.application.user.user.port.out.UserRepositoryPort; import com.process.clash.application.user.userattendance.port.out.UserAttendanceRepositoryPort; +import com.process.clash.domain.user.user.entity.User; import com.process.clash.domain.user.userattendance.entity.UserAttendance; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -30,6 +31,10 @@ public void resetStreakForMissedAttendance() { return; } - userRepositoryPort.resetAttendanceStreakByIds(missedUserIds); + List updated = userRepositoryPort.findAllByIds(missedUserIds).stream() + .map(User::resetAttendanceStreak) + .toList(); + + userRepositoryPort.saveAll(updated); } } \ No newline at end of file diff --git a/src/main/java/com/process/clash/domain/user/user/entity/User.java b/src/main/java/com/process/clash/domain/user/user/entity/User.java index 29bc6a105..0abfeacc3 100644 --- a/src/main/java/com/process/clash/domain/user/user/entity/User.java +++ b/src/main/java/com/process/clash/domain/user/user/entity/User.java @@ -317,4 +317,14 @@ public User incrementAttendanceStreak() { this.currentAttendanceStreak + 1 ); } + + public User resetAttendanceStreak() { + return new User( + this.id, this.createdAt, Instant.now(), + this.username, this.email, this.name, this.password, this.role, + this.profileImage, this.totalExp, this.totalCookie, this.major, + this.userStatus, this.deletedAt, this.isPrivate, this.currentRankTier, this.currentExpTier, + 0 + ); + } } From 04614f2b0fa8b2abdb928222b3d576ccb81568c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=A0=95=EB=B9=88?= Date: Tue, 14 Apr 2026 09:25:46 +0900 Subject: [PATCH 08/21] =?UTF-8?q?test:=20=EC=97=B0=EC=86=8D=20=EC=B6=9C?= =?UTF-8?q?=EC=84=9D=20=EC=A2=85=EB=A3=8C=20=EA=B4=80=EB=A0=A8=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ResetAttendanceStreakServiceTest.java | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 src/test/java/com/process/clash/application/user/userattendance/service/ResetAttendanceStreakServiceTest.java diff --git a/src/test/java/com/process/clash/application/user/userattendance/service/ResetAttendanceStreakServiceTest.java b/src/test/java/com/process/clash/application/user/userattendance/service/ResetAttendanceStreakServiceTest.java new file mode 100644 index 000000000..11fde78e5 --- /dev/null +++ b/src/test/java/com/process/clash/application/user/userattendance/service/ResetAttendanceStreakServiceTest.java @@ -0,0 +1,122 @@ +package com.process.clash.application.user.userattendance.service; + +import com.process.clash.application.user.user.port.out.UserRepositoryPort; +import com.process.clash.application.user.userattendance.port.out.UserAttendanceRepositoryPort; +import com.process.clash.domain.common.enums.Major; +import com.process.clash.domain.user.user.entity.User; +import com.process.clash.domain.user.user.enums.Role; +import com.process.clash.domain.user.user.enums.UserStatus; +import com.process.clash.domain.user.userattendance.entity.UserAttendance; +import com.process.clash.domain.user.userrankhistory.enums.ExpTier; +import com.process.clash.domain.user.userrankhistory.enums.RankTier; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ResetAttendanceStreakServiceTest { + + @Mock + private UserAttendanceRepositoryPort userAttendanceRepositoryPort; + + @Mock + private UserRepositoryPort userRepositoryPort; + + private ResetAttendanceStreakService resetAttendanceStreakService; + + private final LocalDate YESTERDAY = UserAttendance.currentAttendanceDate().minusDays(1); + + @BeforeEach + void setUp() { + resetAttendanceStreakService = new ResetAttendanceStreakService( + userAttendanceRepositoryPort, + userRepositoryPort + ); + } + + @Test + @DisplayName("전날 미출석 유저가 없으면 saveAll을 호출하지 않는다") + void resetStreakForMissedAttendance_doesNothing_whenNoMissedUsers() { + when(userAttendanceRepositoryPort.findNotAttendedUserIdsByDate(YESTERDAY)) + .thenReturn(List.of()); + + resetAttendanceStreakService.resetStreakForMissedAttendance(); + + verify(userRepositoryPort, never()).findAllByIds(any()); + verify(userRepositoryPort, never()).saveAll(any()); + } + + @Test + @DisplayName("전날 미출석 유저들의 streak이 모두 0으로 저장된다") + void resetStreakForMissedAttendance_resetsStreakToZero_forMissedUsers() { + List missedIds = List.of(1L, 2L); + when(userAttendanceRepositoryPort.findNotAttendedUserIdsByDate(YESTERDAY)) + .thenReturn(missedIds); + when(userRepositoryPort.findAllByIds(missedIds)) + .thenReturn(List.of(createUser(1L, 5), createUser(2L, 3))); + + resetAttendanceStreakService.resetStreakForMissedAttendance(); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(userRepositoryPort).saveAll(captor.capture()); + + assertThat(captor.getValue()) + .allMatch(u -> u.currentAttendanceStreak() == 0); + } + + @Test + @DisplayName("미출석 유저 수만큼 saveAll에 전달된다") + void resetStreakForMissedAttendance_savesAllMissedUsers() { + List missedIds = List.of(1L, 2L, 3L); + when(userAttendanceRepositoryPort.findNotAttendedUserIdsByDate(YESTERDAY)) + .thenReturn(missedIds); + when(userRepositoryPort.findAllByIds(missedIds)) + .thenReturn(List.of(createUser(1L, 7), createUser(2L, 1), createUser(3L, 10))); + + resetAttendanceStreakService.resetStreakForMissedAttendance(); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(userRepositoryPort).saveAll(captor.capture()); + + assertThat(captor.getValue()).hasSize(3); + } + + @Test + @DisplayName("어제 날짜(currentAttendanceDate - 1)로 미출석 유저를 조회한다") + void resetStreakForMissedAttendance_queriesYesterdayDate() { + when(userAttendanceRepositoryPort.findNotAttendedUserIdsByDate(any())) + .thenReturn(List.of()); + + resetAttendanceStreakService.resetStreakForMissedAttendance(); + + ArgumentCaptor dateCaptor = ArgumentCaptor.forClass(LocalDate.class); + verify(userAttendanceRepositoryPort).findNotAttendedUserIdsByDate(dateCaptor.capture()); + + assertThat(dateCaptor.getValue()) + .isEqualTo(UserAttendance.currentAttendanceDate().minusDays(1)); + } + + private User createUser(Long id, int attendanceStreak) { + return new User( + id, Instant.now(), Instant.now(), + "username" + id, "user" + id + "@example.com", "name", "encoded-password", + Role.USER, "", 0, 0, Major.NONE, UserStatus.ACTIVE, + null, false, RankTier.NONE, ExpTier.UNRANKED, + attendanceStreak + ); + } +} \ No newline at end of file From 1b287fc35ae458b80a170b9e1819d8f6512d4d94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=A0=95=EB=B9=88?= Date: Tue, 14 Apr 2026 09:49:51 +0900 Subject: [PATCH 09/21] =?UTF-8?q?feat:=20=EC=BF=A0=ED=82=A4=20=EC=A7=80?= =?UTF-8?q?=EA=B8=89=20NoticeCategory=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../clash/domain/user/usernotice/enums/NoticeCategory.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/process/clash/domain/user/usernotice/enums/NoticeCategory.java b/src/main/java/com/process/clash/domain/user/usernotice/enums/NoticeCategory.java index c40e755e9..58cbf08f1 100644 --- a/src/main/java/com/process/clash/domain/user/usernotice/enums/NoticeCategory.java +++ b/src/main/java/com/process/clash/domain/user/usernotice/enums/NoticeCategory.java @@ -9,7 +9,9 @@ public enum NoticeCategory { ACCEPT_BATTLE("상대방이 배틀을 수락했어요.", false), REJECT_BATTLE("상대방이 배틀을 거절했어요.", false), CANCEL_BATTLE("상대방이 배틀 신청을 취소했어요.", false), - GLOBAL_NOTICE("전체 공지사항이 도착했어요.", false); + GLOBAL_NOTICE("전체 공지사항이 도착했어요.", false), + NORMAL_ATTENDANCE_COOKIE("300 쿠키가 지급 되었어요. (일일 출석 보상)", false), + STREAK_ATTENDANCE_COOKIE("1000 쿠키가 지급 되었어요. (7일 연속 출석 보상)", false); private final String message; private final boolean requiresAction; From a13ae4977bc73fe65d8698e0963dfce89efa20b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=A0=95=EB=B9=88?= Date: Tue, 14 Apr 2026 10:05:04 +0900 Subject: [PATCH 10/21] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=A5=B8=20=EC=BF=A0=ED=82=A4=20=EC=A7=80=EA=B8=89=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserAttendanceJpaRepository.java | 3 + .../UserAttendancePersistenceAdapter.java | 5 ++ .../web/user/dto/MarkAttendanceDto.java | 5 +- .../data/MarkAttendanceData.java | 3 +- .../out/UserAttendanceRepositoryPort.java | 2 + .../service/MarkAttendanceService.java | 22 +++++- .../service/MarkAttendanceServiceTest.java | 75 +++++++++++++++++++ 7 files changed, 110 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java index e7181855f..28c487cec 100644 --- a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java +++ b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java @@ -27,4 +27,7 @@ Optional findByUserIdAndAttendanceDate( @Query("SELECT ua.user.id FROM UserAttendanceJpaEntity ua WHERE ua.attendanceDate = :date AND ua.isAttended = false") List findNotAttendedUserIdsByDate(@Param("date") LocalDate date); + + @Query("SELECT COUNT(ua) FROM UserAttendanceJpaEntity ua WHERE ua.user.id = :userId AND ua.attendanceDate BETWEEN :start AND :end AND ua.isAttended = true") + long countAttendedByUserIdBetween(@Param("userId") Long userId, @Param("start") LocalDate start, @Param("end") LocalDate end); } \ No newline at end of file diff --git a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java index f9888908f..9868790a1 100644 --- a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java +++ b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java @@ -58,4 +58,9 @@ public void deleteAll() { public List findNotAttendedUserIdsByDate(LocalDate date) { return userAttendanceJpaRepository.findNotAttendedUserIdsByDate(date); } + + @Override + public long countAttendedByUserIdBetween(Long userId, LocalDate start, LocalDate end) { + return userAttendanceJpaRepository.countAttendedByUserIdBetween(userId, start, end); + } } \ No newline at end of file diff --git a/src/main/java/com/process/clash/adapter/web/user/dto/MarkAttendanceDto.java b/src/main/java/com/process/clash/adapter/web/user/dto/MarkAttendanceDto.java index 02618ff8a..3e86887c6 100644 --- a/src/main/java/com/process/clash/adapter/web/user/dto/MarkAttendanceDto.java +++ b/src/main/java/com/process/clash/adapter/web/user/dto/MarkAttendanceDto.java @@ -5,10 +5,11 @@ public class MarkAttendanceDto { public record Response( - int attendanceStreak + int attendanceStreak, + int earnedCookies ) { public static Response from(MarkAttendanceData.Result result) { - return new Response(result.attendanceStreak()); + return new Response(result.attendanceStreak(), result.earnedCookies()); } } } \ No newline at end of file diff --git a/src/main/java/com/process/clash/application/user/userattendance/data/MarkAttendanceData.java b/src/main/java/com/process/clash/application/user/userattendance/data/MarkAttendanceData.java index c15481a1c..8636f332d 100644 --- a/src/main/java/com/process/clash/application/user/userattendance/data/MarkAttendanceData.java +++ b/src/main/java/com/process/clash/application/user/userattendance/data/MarkAttendanceData.java @@ -9,6 +9,7 @@ public record Command( ) {} public record Result( - int attendanceStreak + int attendanceStreak, + int earnedCookies ) {} } \ No newline at end of file diff --git a/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java b/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java index cf7320015..787a77447 100644 --- a/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java +++ b/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java @@ -19,4 +19,6 @@ public interface UserAttendanceRepositoryPort { void deleteAll(); List findNotAttendedUserIdsByDate(LocalDate date); + + long countAttendedByUserIdBetween(Long userId, LocalDate start, LocalDate end); } \ No newline at end of file diff --git a/src/main/java/com/process/clash/application/user/userattendance/service/MarkAttendanceService.java b/src/main/java/com/process/clash/application/user/userattendance/service/MarkAttendanceService.java index 5ded234ae..53c263b29 100644 --- a/src/main/java/com/process/clash/application/user/userattendance/service/MarkAttendanceService.java +++ b/src/main/java/com/process/clash/application/user/userattendance/service/MarkAttendanceService.java @@ -13,6 +13,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.DayOfWeek; import java.time.LocalDate; @Service @@ -20,6 +21,10 @@ @Transactional public class MarkAttendanceService implements MarkAttendanceUseCase { + private static final int DAILY_COOKIE_REWARD = 300; + private static final int WEEKLY_COOKIE_REWARD = 1000; + private static final int DAYS_IN_WEEK = 7; + private final UserAttendanceRepositoryPort userAttendanceRepositoryPort; private final UserRepositoryPort userRepositoryPort; @@ -41,9 +46,22 @@ public MarkAttendanceData.Result execute(MarkAttendanceData.Command command) { User user = userRepositoryPort.findByIdForUpdate(userId) .orElseThrow(UserNotFoundException::new); - User updated = user.incrementAttendanceStreak(); + int earnedCookies = DAILY_COOKIE_REWARD + calculateWeeklyCookieBonus(userId, attendanceDate); + + User updated = user.incrementAttendanceStreak().addCookie(earnedCookies); userRepositoryPort.save(updated); - return new MarkAttendanceData.Result(updated.currentAttendanceStreak()); + return new MarkAttendanceData.Result(updated.currentAttendanceStreak(), earnedCookies); + } + + private int calculateWeeklyCookieBonus(Long userId, LocalDate attendanceDate) { + if (attendanceDate.getDayOfWeek() != DayOfWeek.SATURDAY) { + return 0; + } + // 토요일(getValue()=6): 6일 빼면 해당 주 일요일 + LocalDate weekSunday = attendanceDate.minusDays(6); + long weeklyCount = userAttendanceRepositoryPort + .countAttendedByUserIdBetween(userId, weekSunday, attendanceDate); + return weeklyCount == DAYS_IN_WEEK ? WEEKLY_COOKIE_REWARD : 0; } } \ No newline at end of file diff --git a/src/test/java/com/process/clash/application/user/userattendance/service/MarkAttendanceServiceTest.java b/src/test/java/com/process/clash/application/user/userattendance/service/MarkAttendanceServiceTest.java index 935448b12..2856d8963 100644 --- a/src/test/java/com/process/clash/application/user/userattendance/service/MarkAttendanceServiceTest.java +++ b/src/test/java/com/process/clash/application/user/userattendance/service/MarkAttendanceServiceTest.java @@ -25,9 +25,12 @@ import java.time.LocalDate; import java.util.Optional; +import org.mockito.MockedStatic; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -123,6 +126,78 @@ void execute_throwsUserNotFoundException_whenUserNotFound() { .isInstanceOf(UserNotFoundException.class); } + // 2026-04-06(월) ~ 2026-04-12(토) 주의 특정 날짜 (결정론적 테스트를 위해 고정) + private static final LocalDate FIXED_MONDAY = LocalDate.of(2026, 4, 6); + private static final LocalDate FIXED_SATURDAY = LocalDate.of(2026, 4, 11); + private static final LocalDate FIXED_SUNDAY = LocalDate.of(2026, 4, 5); + + @Test + @DisplayName("출석 성공 시 일일 쿠키 300개를 지급한다") + void execute_givesDailyCookies_whenSuccessful() { + try (MockedStatic mockedStatic = mockStatic(UserAttendance.class)) { + mockedStatic.when(UserAttendance::currentAttendanceDate).thenReturn(FIXED_MONDAY); + + MarkAttendanceData.Command command = new MarkAttendanceData.Command(new Actor(USER_ID)); + User user = createUser(USER_ID, 0); + UserAttendance notAttended = new UserAttendance(1L, Instant.now(), Instant.now(), USER_ID, FIXED_MONDAY, false); + + when(userAttendanceRepositoryPort.findByUserIdAndAttendanceDate(USER_ID, FIXED_MONDAY)) + .thenReturn(Optional.of(notAttended)); + when(userRepositoryPort.findByIdForUpdate(USER_ID)) + .thenReturn(Optional.of(user)); + + MarkAttendanceData.Result result = markAttendanceService.execute(command); + + assertThat(result.earnedCookies()).isEqualTo(300); + } + } + + @Test + @DisplayName("토요일에 이번 주 7일 전부 출석 시 일일 300 + 주간 1000 쿠키를 지급한다") + void execute_givesWeeklyBonusCookies_whenFullWeekCompleted() { + try (MockedStatic mockedStatic = mockStatic(UserAttendance.class)) { + mockedStatic.when(UserAttendance::currentAttendanceDate).thenReturn(FIXED_SATURDAY); + + MarkAttendanceData.Command command = new MarkAttendanceData.Command(new Actor(USER_ID)); + User user = createUser(USER_ID, 6); + UserAttendance notAttended = new UserAttendance(1L, Instant.now(), Instant.now(), USER_ID, FIXED_SATURDAY, false); + + when(userAttendanceRepositoryPort.findByUserIdAndAttendanceDate(USER_ID, FIXED_SATURDAY)) + .thenReturn(Optional.of(notAttended)); + when(userRepositoryPort.findByIdForUpdate(USER_ID)) + .thenReturn(Optional.of(user)); + when(userAttendanceRepositoryPort.countAttendedByUserIdBetween(USER_ID, FIXED_SUNDAY, FIXED_SATURDAY)) + .thenReturn(7L); + + MarkAttendanceData.Result result = markAttendanceService.execute(command); + + assertThat(result.earnedCookies()).isEqualTo(1300); + } + } + + @Test + @DisplayName("토요일에 이번 주 7일 미달 출석 시 일일 300 쿠키만 지급한다") + void execute_givesOnlyDailyCookies_whenWeekNotFullyCompleted() { + try (MockedStatic mockedStatic = mockStatic(UserAttendance.class)) { + mockedStatic.when(UserAttendance::currentAttendanceDate).thenReturn(FIXED_SATURDAY); + + MarkAttendanceData.Command command = new MarkAttendanceData.Command(new Actor(USER_ID)); + User user = createUser(USER_ID, 4); + UserAttendance notAttended = new UserAttendance(1L, Instant.now(), Instant.now(), USER_ID, FIXED_SATURDAY, false); + + when(userAttendanceRepositoryPort.findByUserIdAndAttendanceDate(USER_ID, FIXED_SATURDAY)) + .thenReturn(Optional.of(notAttended)); + when(userRepositoryPort.findByIdForUpdate(USER_ID)) + .thenReturn(Optional.of(user)); + when(userAttendanceRepositoryPort.countAttendedByUserIdBetween(USER_ID, FIXED_SUNDAY, FIXED_SATURDAY)) + .thenReturn(5L); + + MarkAttendanceData.Result result = markAttendanceService.execute(command); + + assertThat(result.earnedCookies()).isEqualTo(300); + } + } + private UserAttendance createAttendance(boolean isAttended) { return new UserAttendance(1L, Instant.now(), Instant.now(), USER_ID, ATTENDANCE_DATE, isAttended); } From 23ac85c5c4c85efd38c0d6274bc1cd22603a0454 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=A0=95=EB=B9=88?= Date: Tue, 14 Apr 2026 15:41:21 +0900 Subject: [PATCH 11/21] =?UTF-8?q?feat:=20=EC=A3=BC=EA=B0=84=20=EC=B6=9C?= =?UTF-8?q?=EC=84=9D=20=EC=A1=B0=ED=9A=8C=20api=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserAttendanceJpaRepository.java | 3 ++ .../UserAttendancePersistenceAdapter.java | 8 +++ .../web/user/controller/UserController.java | 14 ++++++ .../web/user/dto/GetWeeklyAttendanceDto.java | 36 +++++++++++++ .../data/GetWeeklyAttendanceData.java | 24 +++++++++ .../port/in/GetWeeklyAttendanceUseCase.java | 7 +++ .../out/UserAttendanceRepositoryPort.java | 2 + .../service/GetWeeklyAttendanceService.java | 50 +++++++++++++++++++ 8 files changed, 144 insertions(+) create mode 100644 src/main/java/com/process/clash/adapter/web/user/dto/GetWeeklyAttendanceDto.java create mode 100644 src/main/java/com/process/clash/application/user/userattendance/data/GetWeeklyAttendanceData.java create mode 100644 src/main/java/com/process/clash/application/user/userattendance/port/in/GetWeeklyAttendanceUseCase.java create mode 100644 src/main/java/com/process/clash/application/user/userattendance/service/GetWeeklyAttendanceService.java diff --git a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java index 28c487cec..4a1ff421b 100644 --- a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java +++ b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java @@ -30,4 +30,7 @@ Optional findByUserIdAndAttendanceDate( @Query("SELECT COUNT(ua) FROM UserAttendanceJpaEntity ua WHERE ua.user.id = :userId AND ua.attendanceDate BETWEEN :start AND :end AND ua.isAttended = true") long countAttendedByUserIdBetween(@Param("userId") Long userId, @Param("start") LocalDate start, @Param("end") LocalDate end); + + @Query("SELECT ua FROM UserAttendanceJpaEntity ua WHERE ua.user.id = :userId AND ua.attendanceDate BETWEEN :start AND :end") + List findByUserIdBetweenDates(@Param("userId") Long userId, @Param("start") LocalDate start, @Param("end") LocalDate end); } \ No newline at end of file diff --git a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java index 9868790a1..a450ddb18 100644 --- a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java +++ b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java @@ -63,4 +63,12 @@ public List findNotAttendedUserIdsByDate(LocalDate date) { public long countAttendedByUserIdBetween(Long userId, LocalDate start, LocalDate end) { return userAttendanceJpaRepository.countAttendedByUserIdBetween(userId, start, end); } + + @Override + public List findByUserIdBetweenDates(Long userId, LocalDate start, LocalDate end) { + return userAttendanceJpaRepository.findByUserIdBetweenDates(userId, start, end) + .stream() + .map(userAttendanceJpaMapper::toDomain) + .toList(); + } } \ No newline at end of file diff --git a/src/main/java/com/process/clash/adapter/web/user/controller/UserController.java b/src/main/java/com/process/clash/adapter/web/user/controller/UserController.java index ebd21d18f..a85514bc6 100644 --- a/src/main/java/com/process/clash/adapter/web/user/controller/UserController.java +++ b/src/main/java/com/process/clash/adapter/web/user/controller/UserController.java @@ -10,6 +10,7 @@ import com.process.clash.adapter.web.user.dto.GetMyItemsDto; import com.process.clash.adapter.web.user.dto.GetMyProfileDto; import com.process.clash.adapter.web.user.dto.GetUserProfileDto; +import com.process.clash.adapter.web.user.dto.GetWeeklyAttendanceDto; import com.process.clash.adapter.web.user.dto.MarkAttendanceDto; import com.process.clash.adapter.web.user.dto.UpdateMyPrivacyDto; import com.process.clash.adapter.web.user.dto.IssueProfileImageUploadUrlDto; @@ -48,7 +49,9 @@ import com.process.clash.application.user.user.port.in.UpdateMyPrivacyUseCase; import com.process.clash.application.user.user.port.in.UpdateMyProfileImageUseCase; import com.process.clash.application.user.user.port.in.WithdrawUseCase; +import com.process.clash.application.user.userattendance.data.GetWeeklyAttendanceData; import com.process.clash.application.user.userattendance.data.MarkAttendanceData; +import com.process.clash.application.user.userattendance.port.in.GetWeeklyAttendanceUseCase; import com.process.clash.application.user.userattendance.port.in.MarkAttendanceUseCase; import com.process.clash.application.user.usergithub.data.GetMyGitHubLinkStatusData; import com.process.clash.application.user.usergithub.data.LinkGitHubOAuthData; @@ -94,6 +97,7 @@ public class UserController implements UserControllerDocument { private final GetUserRivalsUsecase getUserRivalsUsecase; private final UpdateMyPrivacyUseCase updateMyPrivacyUseCase; private final MarkAttendanceUseCase markAttendanceUseCase; + private final GetWeeklyAttendanceUseCase getWeeklyAttendanceUseCase; @DeleteMapping("/me") public ApiResponse withdraw( @@ -268,6 +272,16 @@ public ApiResponse getUserRivals( return ApiResponse.success(response, "유저 라이벌 목록을 성공적으로 조회했습니다."); } + @GetMapping("/me/attendance/weekly") + public ApiResponse getWeeklyAttendance( + @AuthenticatedActor Actor actor + ) { + GetWeeklyAttendanceData.Command command = new GetWeeklyAttendanceData.Command(actor); + GetWeeklyAttendanceData.Result result = getWeeklyAttendanceUseCase.execute(command); + GetWeeklyAttendanceDto.Response response = GetWeeklyAttendanceDto.Response.from(result); + return ApiResponse.success(response, "주간 출석 현황을 성공적으로 조회했습니다."); + } + @PostMapping("/me/attendance") public ApiResponse markAttendance( @AuthenticatedActor Actor actor diff --git a/src/main/java/com/process/clash/adapter/web/user/dto/GetWeeklyAttendanceDto.java b/src/main/java/com/process/clash/adapter/web/user/dto/GetWeeklyAttendanceDto.java new file mode 100644 index 000000000..a36ee23cc --- /dev/null +++ b/src/main/java/com/process/clash/adapter/web/user/dto/GetWeeklyAttendanceDto.java @@ -0,0 +1,36 @@ +package com.process.clash.adapter.web.user.dto; + +import com.process.clash.application.user.userattendance.data.GetWeeklyAttendanceData; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.List; + +public class GetWeeklyAttendanceDto { + + public record Response( + LocalDate weekStart, + LocalDate weekEnd, + List days + ) { + public static Response from(GetWeeklyAttendanceData.Result result) { + return new Response( + result.weekStart(), + result.weekEnd(), + result.days().stream() + .map(AttendanceDayItem::from) + .toList() + ); + } + } + + public record AttendanceDayItem( + LocalDate date, + DayOfWeek dayOfWeek, + boolean isAttended + ) { + public static AttendanceDayItem from(GetWeeklyAttendanceData.AttendanceDay day) { + return new AttendanceDayItem(day.date(), day.dayOfWeek(), day.isAttended()); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/process/clash/application/user/userattendance/data/GetWeeklyAttendanceData.java b/src/main/java/com/process/clash/application/user/userattendance/data/GetWeeklyAttendanceData.java new file mode 100644 index 000000000..ad53b51d9 --- /dev/null +++ b/src/main/java/com/process/clash/application/user/userattendance/data/GetWeeklyAttendanceData.java @@ -0,0 +1,24 @@ +package com.process.clash.application.user.userattendance.data; + +import com.process.clash.application.common.actor.Actor; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.List; + +public class GetWeeklyAttendanceData { + + public record Command(Actor actor) {} + + public record Result( + LocalDate weekStart, + LocalDate weekEnd, + List days + ) {} + + public record AttendanceDay( + LocalDate date, + DayOfWeek dayOfWeek, + boolean isAttended + ) {} +} \ No newline at end of file diff --git a/src/main/java/com/process/clash/application/user/userattendance/port/in/GetWeeklyAttendanceUseCase.java b/src/main/java/com/process/clash/application/user/userattendance/port/in/GetWeeklyAttendanceUseCase.java new file mode 100644 index 000000000..9049721df --- /dev/null +++ b/src/main/java/com/process/clash/application/user/userattendance/port/in/GetWeeklyAttendanceUseCase.java @@ -0,0 +1,7 @@ +package com.process.clash.application.user.userattendance.port.in; + +import com.process.clash.application.user.userattendance.data.GetWeeklyAttendanceData; + +public interface GetWeeklyAttendanceUseCase { + GetWeeklyAttendanceData.Result execute(GetWeeklyAttendanceData.Command command); +} \ No newline at end of file diff --git a/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java b/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java index 787a77447..75d3eab9d 100644 --- a/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java +++ b/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java @@ -21,4 +21,6 @@ public interface UserAttendanceRepositoryPort { List findNotAttendedUserIdsByDate(LocalDate date); long countAttendedByUserIdBetween(Long userId, LocalDate start, LocalDate end); + + List findByUserIdBetweenDates(Long userId, LocalDate start, LocalDate end); } \ No newline at end of file diff --git a/src/main/java/com/process/clash/application/user/userattendance/service/GetWeeklyAttendanceService.java b/src/main/java/com/process/clash/application/user/userattendance/service/GetWeeklyAttendanceService.java new file mode 100644 index 000000000..051f08d22 --- /dev/null +++ b/src/main/java/com/process/clash/application/user/userattendance/service/GetWeeklyAttendanceService.java @@ -0,0 +1,50 @@ +package com.process.clash.application.user.userattendance.service; + +import com.process.clash.application.user.userattendance.data.GetWeeklyAttendanceData; +import com.process.clash.application.user.userattendance.port.in.GetWeeklyAttendanceUseCase; +import com.process.clash.application.user.userattendance.port.out.UserAttendanceRepositoryPort; +import com.process.clash.domain.user.userattendance.entity.UserAttendance; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GetWeeklyAttendanceService implements GetWeeklyAttendanceUseCase { + + private final UserAttendanceRepositoryPort userAttendanceRepositoryPort; + + @Override + public GetWeeklyAttendanceData.Result execute(GetWeeklyAttendanceData.Command command) { + LocalDate today = UserAttendance.currentAttendanceDate(); + + // 이번 주 일요일(주 시작) ~ 토요일(주 끝) + // DayOfWeek: MONDAY=1 ... SUNDAY=7, 7 % 7 = 0 + LocalDate weekStart = today.minusDays(today.getDayOfWeek().getValue() % 7); + LocalDate weekEnd = weekStart.plusDays(6); + + List records = userAttendanceRepositoryPort + .findByUserIdBetweenDates(command.actor().id(), weekStart, weekEnd); + + Map attendanceByDate = records.stream() + .collect(Collectors.toMap(UserAttendance::attendanceDate, UserAttendance::isAttended)); + + List days = Stream.iterate(weekStart, d -> d.plusDays(1)) + .limit(7) + .map(date -> new GetWeeklyAttendanceData.AttendanceDay( + date, + date.getDayOfWeek(), + attendanceByDate.getOrDefault(date, false) + )) + .toList(); + + return new GetWeeklyAttendanceData.Result(weekStart, weekEnd, days); + } +} \ No newline at end of file From 3ecc4bfa895f3abcd2893160044720dd87210fcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=A0=95=EB=B9=88?= Date: Tue, 14 Apr 2026 15:46:26 +0900 Subject: [PATCH 12/21] =?UTF-8?q?refactor:=20UserAttendanceController?= =?UTF-8?q?=EB=A1=9C=20API=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/UserAttendanceController.java | 46 ++++++++++ .../web/user/controller/UserController.java | 28 ------- .../UserAttendanceControllerDocument.java | 84 +++++++++++++++++++ 3 files changed, 130 insertions(+), 28 deletions(-) create mode 100644 src/main/java/com/process/clash/adapter/web/user/controller/UserAttendanceController.java create mode 100644 src/main/java/com/process/clash/adapter/web/user/docs/controller/UserAttendanceControllerDocument.java diff --git a/src/main/java/com/process/clash/adapter/web/user/controller/UserAttendanceController.java b/src/main/java/com/process/clash/adapter/web/user/controller/UserAttendanceController.java new file mode 100644 index 000000000..374abffb8 --- /dev/null +++ b/src/main/java/com/process/clash/adapter/web/user/controller/UserAttendanceController.java @@ -0,0 +1,46 @@ +package com.process.clash.adapter.web.user.controller; + +import com.process.clash.adapter.web.common.ApiResponse; +import com.process.clash.adapter.web.security.AuthenticatedActor; +import com.process.clash.adapter.web.user.docs.controller.UserAttendanceControllerDocument; +import com.process.clash.adapter.web.user.dto.GetWeeklyAttendanceDto; +import com.process.clash.adapter.web.user.dto.MarkAttendanceDto; +import com.process.clash.application.common.actor.Actor; +import com.process.clash.application.user.userattendance.data.GetWeeklyAttendanceData; +import com.process.clash.application.user.userattendance.data.MarkAttendanceData; +import com.process.clash.application.user.userattendance.port.in.GetWeeklyAttendanceUseCase; +import com.process.clash.application.user.userattendance.port.in.MarkAttendanceUseCase; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/users/me/attendance") +@RequiredArgsConstructor +public class UserAttendanceController implements UserAttendanceControllerDocument { + + private final MarkAttendanceUseCase markAttendanceUseCase; + private final GetWeeklyAttendanceUseCase getWeeklyAttendanceUseCase; + + @PostMapping + public ApiResponse markAttendance( + @AuthenticatedActor Actor actor + ) { + MarkAttendanceData.Command command = new MarkAttendanceData.Command(actor); + MarkAttendanceData.Result result = markAttendanceUseCase.execute(command); + MarkAttendanceDto.Response response = MarkAttendanceDto.Response.from(result); + return ApiResponse.success(response, "출석이 완료되었습니다."); + } + + @GetMapping("/weekly") + public ApiResponse getWeeklyAttendance( + @AuthenticatedActor Actor actor + ) { + GetWeeklyAttendanceData.Command command = new GetWeeklyAttendanceData.Command(actor); + GetWeeklyAttendanceData.Result result = getWeeklyAttendanceUseCase.execute(command); + GetWeeklyAttendanceDto.Response response = GetWeeklyAttendanceDto.Response.from(result); + return ApiResponse.success(response, "주간 출석 현황을 성공적으로 조회했습니다."); + } +} \ No newline at end of file diff --git a/src/main/java/com/process/clash/adapter/web/user/controller/UserController.java b/src/main/java/com/process/clash/adapter/web/user/controller/UserController.java index a85514bc6..b2599f1fa 100644 --- a/src/main/java/com/process/clash/adapter/web/user/controller/UserController.java +++ b/src/main/java/com/process/clash/adapter/web/user/controller/UserController.java @@ -10,8 +10,6 @@ import com.process.clash.adapter.web.user.dto.GetMyItemsDto; import com.process.clash.adapter.web.user.dto.GetMyProfileDto; import com.process.clash.adapter.web.user.dto.GetUserProfileDto; -import com.process.clash.adapter.web.user.dto.GetWeeklyAttendanceDto; -import com.process.clash.adapter.web.user.dto.MarkAttendanceDto; import com.process.clash.adapter.web.user.dto.UpdateMyPrivacyDto; import com.process.clash.adapter.web.user.dto.IssueProfileImageUploadUrlDto; import com.process.clash.adapter.web.user.dto.LinkGitHubOAuthDto; @@ -49,10 +47,6 @@ import com.process.clash.application.user.user.port.in.UpdateMyPrivacyUseCase; import com.process.clash.application.user.user.port.in.UpdateMyProfileImageUseCase; import com.process.clash.application.user.user.port.in.WithdrawUseCase; -import com.process.clash.application.user.userattendance.data.GetWeeklyAttendanceData; -import com.process.clash.application.user.userattendance.data.MarkAttendanceData; -import com.process.clash.application.user.userattendance.port.in.GetWeeklyAttendanceUseCase; -import com.process.clash.application.user.userattendance.port.in.MarkAttendanceUseCase; import com.process.clash.application.user.usergithub.data.GetMyGitHubLinkStatusData; import com.process.clash.application.user.usergithub.data.LinkGitHubOAuthData; import com.process.clash.application.user.usergithub.port.in.GetMyGitHubLinkStatusUsecase; @@ -96,8 +90,6 @@ public class UserController implements UserControllerDocument { private final GetUserGitHubActivityUsecase getUserGitHubActivityUsecase; private final GetUserRivalsUsecase getUserRivalsUsecase; private final UpdateMyPrivacyUseCase updateMyPrivacyUseCase; - private final MarkAttendanceUseCase markAttendanceUseCase; - private final GetWeeklyAttendanceUseCase getWeeklyAttendanceUseCase; @DeleteMapping("/me") public ApiResponse withdraw( @@ -272,26 +264,6 @@ public ApiResponse getUserRivals( return ApiResponse.success(response, "유저 라이벌 목록을 성공적으로 조회했습니다."); } - @GetMapping("/me/attendance/weekly") - public ApiResponse getWeeklyAttendance( - @AuthenticatedActor Actor actor - ) { - GetWeeklyAttendanceData.Command command = new GetWeeklyAttendanceData.Command(actor); - GetWeeklyAttendanceData.Result result = getWeeklyAttendanceUseCase.execute(command); - GetWeeklyAttendanceDto.Response response = GetWeeklyAttendanceDto.Response.from(result); - return ApiResponse.success(response, "주간 출석 현황을 성공적으로 조회했습니다."); - } - - @PostMapping("/me/attendance") - public ApiResponse markAttendance( - @AuthenticatedActor Actor actor - ) { - MarkAttendanceData.Command command = new MarkAttendanceData.Command(actor); - MarkAttendanceData.Result result = markAttendanceUseCase.execute(command); - MarkAttendanceDto.Response response = MarkAttendanceDto.Response.from(result); - return ApiResponse.success(response, "출석이 완료되었습니다."); - } - @PatchMapping("/me/privacy") public ApiResponse updateMyPrivacy( @AuthenticatedActor Actor actor, diff --git a/src/main/java/com/process/clash/adapter/web/user/docs/controller/UserAttendanceControllerDocument.java b/src/main/java/com/process/clash/adapter/web/user/docs/controller/UserAttendanceControllerDocument.java new file mode 100644 index 000000000..44020d34c --- /dev/null +++ b/src/main/java/com/process/clash/adapter/web/user/docs/controller/UserAttendanceControllerDocument.java @@ -0,0 +1,84 @@ +package com.process.clash.adapter.web.user.docs.controller; + +import com.process.clash.adapter.web.user.dto.GetWeeklyAttendanceDto; +import com.process.clash.adapter.web.user.dto.MarkAttendanceDto; +import com.process.clash.application.common.actor.Actor; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "출석 API", description = "출석 체크 및 주간 출석 현황 조회") +public interface UserAttendanceControllerDocument { + + @Operation(summary = "출석 체크", description = "오늘 출석을 기록하고 쿠키를 지급합니다. 일일 300 쿠키, 일~토 주간 개근 시 추가 1000 쿠키를 지급합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "출석 성공", + content = @Content( + examples = @ExampleObject(value = """ + { + "success": true, + "message": "출석이 완료되었습니다.", + "data": { + "attendanceStreak": 5, + "earnedCookies": 300 + } + } + """) + )), + @ApiResponse(responseCode = "404", description = "오늘 출석 레코드 없음", + content = @Content( + examples = @ExampleObject(value = """ + { + "success": false, + "message": "출석 정보를 찾을 수 없습니다." + } + """) + )), + @ApiResponse(responseCode = "409", description = "이미 출석 완료", + content = @Content( + examples = @ExampleObject(value = """ + { + "success": false, + "message": "이미 출석했습니다." + } + """) + )) + }) + com.process.clash.adapter.web.common.ApiResponse markAttendance( + @Parameter(hidden = true) Actor actor + ); + + @Operation(summary = "주간 출석 현황 조회", description = "이번 주(일~토) 날짜별 출석 여부를 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content( + examples = @ExampleObject(value = """ + { + "success": true, + "message": "주간 출석 현황을 성공적으로 조회했습니다.", + "data": { + "weekStart": "2026-04-12", + "weekEnd": "2026-04-18", + "days": [ + { "date": "2026-04-12", "dayOfWeek": "SUNDAY", "isAttended": true }, + { "date": "2026-04-13", "dayOfWeek": "MONDAY", "isAttended": true }, + { "date": "2026-04-14", "dayOfWeek": "TUESDAY", "isAttended": false }, + { "date": "2026-04-15", "dayOfWeek": "WEDNESDAY", "isAttended": false }, + { "date": "2026-04-16", "dayOfWeek": "THURSDAY", "isAttended": false }, + { "date": "2026-04-17", "dayOfWeek": "FRIDAY", "isAttended": false }, + { "date": "2026-04-18", "dayOfWeek": "SATURDAY", "isAttended": false } + ] + } + } + """) + )) + }) + com.process.clash.adapter.web.common.ApiResponse getWeeklyAttendance( + @Parameter(hidden = true) Actor actor + ); +} \ No newline at end of file From ac70d582aab5d3810ae94214249e469c00373be4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=A0=95=EB=B9=88?= Date: Tue, 14 Apr 2026 16:22:05 +0900 Subject: [PATCH 13/21] =?UTF-8?q?feat:=20=EC=B5=9C=EA=B7=BC=20=EC=A3=BC?= =?UTF-8?q?=EC=B0=A8=20=EC=B6=9C=EC=84=9D=20=EC=A1=B0=ED=9A=8C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EC=84=9C=20N=EC=A3=BC=EC=B0=A8=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=EB=A1=9C=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserAttendanceJpaRepository.java | 14 +++++ .../UserAttendancePersistenceAdapter.java | 10 ++++ .../web/user/dto/GetWeeklyAttendanceDto.java | 2 + .../data/GetWeeklyAttendanceData.java | 1 + .../out/UserAttendanceRepositoryPort.java | 4 ++ .../service/GetWeeklyAttendanceService.java | 20 ++++++- .../service/InitUserAttendanceService.java | 12 +--- .../InitUserAttendanceServiceTest.java | 59 ++----------------- 8 files changed, 55 insertions(+), 67 deletions(-) diff --git a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java index 4a1ff421b..e610fc660 100644 --- a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java +++ b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java @@ -1,6 +1,7 @@ package com.process.clash.adapter.persistence.user.userattendance; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -33,4 +34,17 @@ Optional findByUserIdAndAttendanceDate( @Query("SELECT ua FROM UserAttendanceJpaEntity ua WHERE ua.user.id = :userId AND ua.attendanceDate BETWEEN :start AND :end") List findByUserIdBetweenDates(@Param("userId") Long userId, @Param("start") LocalDate start, @Param("end") LocalDate end); + + @Modifying + @Query(value = """ + INSERT INTO user_attendances (fk_user_id, attendance_date, is_attended, created_at, updated_at) + SELECT id, :date, false, NOW(), NOW() + FROM users + WHERE deleted_at IS NULL + ON CONFLICT (fk_user_id, attendance_date) DO NOTHING + """, nativeQuery = true) + void initDailyAttendanceForAllUsers(@Param("date") LocalDate date); + + @Query("SELECT MIN(ua.attendanceDate) FROM UserAttendanceJpaEntity ua WHERE ua.user.id = :userId") + Optional findEarliestAttendanceDateByUserId(@Param("userId") Long userId); } \ No newline at end of file diff --git a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java index a450ddb18..0d192432c 100644 --- a/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java +++ b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java @@ -71,4 +71,14 @@ public List findByUserIdBetweenDates(Long userId, LocalDate star .map(userAttendanceJpaMapper::toDomain) .toList(); } + + @Override + public void initDailyAttendanceForAllUsers(LocalDate date) { + userAttendanceJpaRepository.initDailyAttendanceForAllUsers(date); + } + + @Override + public Optional findEarliestAttendanceDateByUserId(Long userId) { + return userAttendanceJpaRepository.findEarliestAttendanceDateByUserId(userId); + } } \ No newline at end of file diff --git a/src/main/java/com/process/clash/adapter/web/user/dto/GetWeeklyAttendanceDto.java b/src/main/java/com/process/clash/adapter/web/user/dto/GetWeeklyAttendanceDto.java index a36ee23cc..952dc1266 100644 --- a/src/main/java/com/process/clash/adapter/web/user/dto/GetWeeklyAttendanceDto.java +++ b/src/main/java/com/process/clash/adapter/web/user/dto/GetWeeklyAttendanceDto.java @@ -9,12 +9,14 @@ public class GetWeeklyAttendanceDto { public record Response( + int weekNumber, LocalDate weekStart, LocalDate weekEnd, List days ) { public static Response from(GetWeeklyAttendanceData.Result result) { return new Response( + result.weekNumber(), result.weekStart(), result.weekEnd(), result.days().stream() diff --git a/src/main/java/com/process/clash/application/user/userattendance/data/GetWeeklyAttendanceData.java b/src/main/java/com/process/clash/application/user/userattendance/data/GetWeeklyAttendanceData.java index ad53b51d9..e061864ca 100644 --- a/src/main/java/com/process/clash/application/user/userattendance/data/GetWeeklyAttendanceData.java +++ b/src/main/java/com/process/clash/application/user/userattendance/data/GetWeeklyAttendanceData.java @@ -11,6 +11,7 @@ public class GetWeeklyAttendanceData { public record Command(Actor actor) {} public record Result( + int weekNumber, LocalDate weekStart, LocalDate weekEnd, List days diff --git a/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java b/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java index 75d3eab9d..029db2260 100644 --- a/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java +++ b/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java @@ -23,4 +23,8 @@ public interface UserAttendanceRepositoryPort { long countAttendedByUserIdBetween(Long userId, LocalDate start, LocalDate end); List findByUserIdBetweenDates(Long userId, LocalDate start, LocalDate end); + + void initDailyAttendanceForAllUsers(LocalDate date); + + Optional findEarliestAttendanceDateByUserId(Long userId); } \ No newline at end of file diff --git a/src/main/java/com/process/clash/application/user/userattendance/service/GetWeeklyAttendanceService.java b/src/main/java/com/process/clash/application/user/userattendance/service/GetWeeklyAttendanceService.java index 051f08d22..8ff14c66d 100644 --- a/src/main/java/com/process/clash/application/user/userattendance/service/GetWeeklyAttendanceService.java +++ b/src/main/java/com/process/clash/application/user/userattendance/service/GetWeeklyAttendanceService.java @@ -9,6 +9,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; +import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -30,8 +31,10 @@ public GetWeeklyAttendanceData.Result execute(GetWeeklyAttendanceData.Command co LocalDate weekStart = today.minusDays(today.getDayOfWeek().getValue() % 7); LocalDate weekEnd = weekStart.plusDays(6); + Long userId = command.actor().id(); + List records = userAttendanceRepositoryPort - .findByUserIdBetweenDates(command.actor().id(), weekStart, weekEnd); + .findByUserIdBetweenDates(userId, weekStart, weekEnd); Map attendanceByDate = records.stream() .collect(Collectors.toMap(UserAttendance::attendanceDate, UserAttendance::isAttended)); @@ -45,6 +48,19 @@ public GetWeeklyAttendanceData.Result execute(GetWeeklyAttendanceData.Command co )) .toList(); - return new GetWeeklyAttendanceData.Result(weekStart, weekEnd, days); + int weekNumber = calculateWeekNumber(userId, weekStart); + + return new GetWeeklyAttendanceData.Result(weekNumber, weekStart, weekEnd, days); + } + + private int calculateWeekNumber(Long userId, LocalDate currentWeekStart) { + LocalDate firstRecordDate = userAttendanceRepositoryPort + .findEarliestAttendanceDateByUserId(userId) + .orElse(currentWeekStart); + + // 첫 레코드가 속한 주의 일요일 + LocalDate firstWeekStart = firstRecordDate.minusDays(firstRecordDate.getDayOfWeek().getValue() % 7); + + return (int) ChronoUnit.WEEKS.between(firstWeekStart, currentWeekStart) + 1; } } \ No newline at end of file diff --git a/src/main/java/com/process/clash/application/user/userattendance/service/InitUserAttendanceService.java b/src/main/java/com/process/clash/application/user/userattendance/service/InitUserAttendanceService.java index 9d93d0751..195d4f5d2 100644 --- a/src/main/java/com/process/clash/application/user/userattendance/service/InitUserAttendanceService.java +++ b/src/main/java/com/process/clash/application/user/userattendance/service/InitUserAttendanceService.java @@ -7,7 +7,6 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; -import java.util.List; @Service @RequiredArgsConstructor @@ -18,15 +17,6 @@ public class InitUserAttendanceService { @Transactional public void initDailyAttendance() { LocalDate attendanceDate = UserAttendance.currentAttendanceDate(); - - userAttendanceRepositoryPort.deleteAll(); - - List userIds = userAttendanceRepositoryPort.findAllNonDeletedUserIds(); - - List attendances = userIds.stream() - .map(userId -> UserAttendance.create(userId, attendanceDate)) - .toList(); - - userAttendanceRepositoryPort.saveAll(attendances); + userAttendanceRepositoryPort.initDailyAttendanceForAllUsers(attendanceDate); } } \ No newline at end of file diff --git a/src/test/java/com/process/clash/application/user/userattendance/service/InitUserAttendanceServiceTest.java b/src/test/java/com/process/clash/application/user/userattendance/service/InitUserAttendanceServiceTest.java index d77bca7bd..46b2d5181 100644 --- a/src/test/java/com/process/clash/application/user/userattendance/service/InitUserAttendanceServiceTest.java +++ b/src/test/java/com/process/clash/application/user/userattendance/service/InitUserAttendanceServiceTest.java @@ -6,15 +6,11 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class InitUserAttendanceServiceTest { @@ -30,57 +26,12 @@ void setUp() { } @Test - @DisplayName("기존 출석 레코드를 전부 삭제한 후 재생성한다") - void initDailyAttendance_deletesBeforeSaving() { - when(userAttendanceRepositoryPort.findAllNonDeletedUserIds()).thenReturn(List.of(1L)); - - initUserAttendanceService.initDailyAttendance(); - - verify(userAttendanceRepositoryPort).deleteAll(); - verify(userAttendanceRepositoryPort).saveAll(org.mockito.ArgumentMatchers.anyList()); - } - - @Test - @DisplayName("모든 탈퇴하지 않은 유저에 대해 isAttended=false 레코드를 생성한다") - void initDailyAttendance_createsNotAttendedRecordForAllUsers() { - List userIds = List.of(1L, 2L, 3L); - when(userAttendanceRepositoryPort.findAllNonDeletedUserIds()).thenReturn(userIds); - - initUserAttendanceService.initDailyAttendance(); - - ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); - verify(userAttendanceRepositoryPort).saveAll(captor.capture()); - - List saved = captor.getValue(); - assertThat(saved).hasSize(3); - assertThat(saved).allMatch(a -> !a.isAttended()); - assertThat(saved).extracting(UserAttendance::userId) - .containsExactlyInAnyOrder(1L, 2L, 3L); - } - - @Test - @DisplayName("모든 레코드의 attendanceDate는 currentAttendanceDate와 동일하다") - void initDailyAttendance_setsCorrectAttendanceDate() { - when(userAttendanceRepositoryPort.findAllNonDeletedUserIds()).thenReturn(List.of(1L, 2L)); - + @DisplayName("오늘 날짜로 모든 활성 유저의 출석 레코드를 생성한다 (이미 존재하면 무시)") + void initDailyAttendance_insertsForAllActiveUsers() { initUserAttendanceService.initDailyAttendance(); - ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); - verify(userAttendanceRepositoryPort).saveAll(captor.capture()); - - captor.getValue().forEach(a -> - assertThat(a.attendanceDate()).isEqualTo(UserAttendance.currentAttendanceDate()) + verify(userAttendanceRepositoryPort).initDailyAttendanceForAllUsers( + eq(UserAttendance.currentAttendanceDate()) ); } - - @Test - @DisplayName("탈퇴하지 않은 유저가 없으면 빈 목록으로 saveAll을 호출한다") - void initDailyAttendance_savesEmptyList_whenNoUsers() { - when(userAttendanceRepositoryPort.findAllNonDeletedUserIds()).thenReturn(List.of()); - - initUserAttendanceService.initDailyAttendance(); - - verify(userAttendanceRepositoryPort).deleteAll(); - verify(userAttendanceRepositoryPort).saveAll(List.of()); - } } \ No newline at end of file From 1eaaf2b052aadb55065f5e6aaae10c3b7a5576b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=A0=95=EB=B9=88?= Date: Tue, 14 Apr 2026 16:37:53 +0900 Subject: [PATCH 14/21] =?UTF-8?q?feat:=20=ED=95=B4=EB=8B=B9=20=EC=A3=BC=20?= =?UTF-8?q?=EC=B6=9C=EC=84=9D=20=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=97=90=20=EC=86=8C=EC=BC=93=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserAttendanceInitScheduler.java | 6 ++--- .../realtime/data/ChangeDomain.java | 3 ++- .../realtime/AttendanceRefetchNotifier.java | 27 +++++++++++++++++++ .../service/ResetAttendanceStreakService.java | 7 ++++- .../ResetAttendanceStreakServiceTest.java | 24 +++++++++++++++-- 5 files changed, 59 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/process/clash/application/user/userattendance/realtime/AttendanceRefetchNotifier.java diff --git a/src/main/java/com/process/clash/adapter/scheduler/UserAttendanceInitScheduler.java b/src/main/java/com/process/clash/adapter/scheduler/UserAttendanceInitScheduler.java index 78cd35d8f..f65fd4a0a 100644 --- a/src/main/java/com/process/clash/adapter/scheduler/UserAttendanceInitScheduler.java +++ b/src/main/java/com/process/clash/adapter/scheduler/UserAttendanceInitScheduler.java @@ -17,10 +17,8 @@ public class UserAttendanceInitScheduler { /** * 매일 KST 06:00에 다음 두 작업을 순서대로 수행한다. - * 1. 전날 출석하지 않은 유저들의 연속 출석 streak을 0으로 초기화 - * 2. 전체 활성 유저의 출석 기록을 오늘 날짜 기준으로 새로 생성 (is_attended = false) - * - * streak 초기화가 반드시 deleteAll() 이전에 실행되어야 하므로 같은 스케줄 메서드 안에서 순서를 보장한다. + * 1. 전날 출석하지 않은 유저들의 streak 초기화 + 소켓 알림 발송 + * 2. 전체 활성 유저의 오늘 출석 레코드 생성 (이미 존재하면 무시) */ @Scheduled(cron = "0 0 6 * * *", zone = "Asia/Seoul") public void initDailyAttendance() { diff --git a/src/main/java/com/process/clash/application/realtime/data/ChangeDomain.java b/src/main/java/com/process/clash/application/realtime/data/ChangeDomain.java index a11b1583b..c78da6f79 100644 --- a/src/main/java/com/process/clash/application/realtime/data/ChangeDomain.java +++ b/src/main/java/com/process/clash/application/realtime/data/ChangeDomain.java @@ -4,5 +4,6 @@ public enum ChangeDomain { GROUP, COMPETE, USER, - RECORD + RECORD, + ATTENDANCE } diff --git a/src/main/java/com/process/clash/application/user/userattendance/realtime/AttendanceRefetchNotifier.java b/src/main/java/com/process/clash/application/user/userattendance/realtime/AttendanceRefetchNotifier.java new file mode 100644 index 000000000..340b5cccd --- /dev/null +++ b/src/main/java/com/process/clash/application/user/userattendance/realtime/AttendanceRefetchNotifier.java @@ -0,0 +1,27 @@ +package com.process.clash.application.user.userattendance.realtime; + +import com.process.clash.application.realtime.data.ChangeDomain; +import com.process.clash.application.realtime.data.ChangeType; +import com.process.clash.application.realtime.data.RefetchNotice; +import com.process.clash.application.realtime.service.RefetchEventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Collection; + +@Component +@RequiredArgsConstructor +public class AttendanceRefetchNotifier { + + private final RefetchEventPublisher refetchEventPublisher; + + /** + * 전날 출석을 놓친 유저들에게 주간 출석 데이터가 변경됐음을 소켓으로 알립니다. + */ + public void notifyAttendanceMissed(Collection userIds) { + refetchEventPublisher.publish( + RefetchNotice.of(ChangeDomain.ATTENDANCE, ChangeType.DATA_CHANGED), + userIds + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/process/clash/application/user/userattendance/service/ResetAttendanceStreakService.java b/src/main/java/com/process/clash/application/user/userattendance/service/ResetAttendanceStreakService.java index 484549b2f..bc7790d1b 100644 --- a/src/main/java/com/process/clash/application/user/userattendance/service/ResetAttendanceStreakService.java +++ b/src/main/java/com/process/clash/application/user/userattendance/service/ResetAttendanceStreakService.java @@ -2,6 +2,7 @@ import com.process.clash.application.user.user.port.out.UserRepositoryPort; import com.process.clash.application.user.userattendance.port.out.UserAttendanceRepositoryPort; +import com.process.clash.application.user.userattendance.realtime.AttendanceRefetchNotifier; import com.process.clash.domain.user.user.entity.User; import com.process.clash.domain.user.userattendance.entity.UserAttendance; import lombok.RequiredArgsConstructor; @@ -17,9 +18,11 @@ public class ResetAttendanceStreakService { private final UserAttendanceRepositoryPort userAttendanceRepositoryPort; private final UserRepositoryPort userRepositoryPort; + private final AttendanceRefetchNotifier attendanceRefetchNotifier; /** - * 전날 출석하지 않은 유저들의 연속 출석 streak을 0으로 초기화한다. + * 전날 출석하지 않은 유저들의 연속 출석 streak을 0으로 초기화하고, + * 해당 유저들에게 소켓으로 주간 출석 데이터 변경을 알린다. * KST 06:00 기준으로 하루가 바뀌므로, currentAttendanceDate()에서 하루를 빼면 전날 날짜가 된다. */ @Transactional @@ -36,5 +39,7 @@ public void resetStreakForMissedAttendance() { .toList(); userRepositoryPort.saveAll(updated); + + attendanceRefetchNotifier.notifyAttendanceMissed(missedUserIds); } } \ No newline at end of file diff --git a/src/test/java/com/process/clash/application/user/userattendance/service/ResetAttendanceStreakServiceTest.java b/src/test/java/com/process/clash/application/user/userattendance/service/ResetAttendanceStreakServiceTest.java index 11fde78e5..6fff388ec 100644 --- a/src/test/java/com/process/clash/application/user/userattendance/service/ResetAttendanceStreakServiceTest.java +++ b/src/test/java/com/process/clash/application/user/userattendance/service/ResetAttendanceStreakServiceTest.java @@ -2,6 +2,7 @@ import com.process.clash.application.user.user.port.out.UserRepositoryPort; import com.process.clash.application.user.userattendance.port.out.UserAttendanceRepositoryPort; +import com.process.clash.application.user.userattendance.realtime.AttendanceRefetchNotifier; import com.process.clash.domain.common.enums.Major; import com.process.clash.domain.user.user.entity.User; import com.process.clash.domain.user.user.enums.Role; @@ -36,6 +37,9 @@ class ResetAttendanceStreakServiceTest { @Mock private UserRepositoryPort userRepositoryPort; + @Mock + private AttendanceRefetchNotifier attendanceRefetchNotifier; + private ResetAttendanceStreakService resetAttendanceStreakService; private final LocalDate YESTERDAY = UserAttendance.currentAttendanceDate().minusDays(1); @@ -44,12 +48,13 @@ class ResetAttendanceStreakServiceTest { void setUp() { resetAttendanceStreakService = new ResetAttendanceStreakService( userAttendanceRepositoryPort, - userRepositoryPort + userRepositoryPort, + attendanceRefetchNotifier ); } @Test - @DisplayName("전날 미출석 유저가 없으면 saveAll을 호출하지 않는다") + @DisplayName("전날 미출석 유저가 없으면 saveAll과 소켓 알림을 호출하지 않는다") void resetStreakForMissedAttendance_doesNothing_whenNoMissedUsers() { when(userAttendanceRepositoryPort.findNotAttendedUserIdsByDate(YESTERDAY)) .thenReturn(List.of()); @@ -58,6 +63,7 @@ void resetStreakForMissedAttendance_doesNothing_whenNoMissedUsers() { verify(userRepositoryPort, never()).findAllByIds(any()); verify(userRepositoryPort, never()).saveAll(any()); + verify(attendanceRefetchNotifier, never()).notifyAttendanceMissed(any()); } @Test @@ -110,6 +116,20 @@ void resetStreakForMissedAttendance_queriesYesterdayDate() { .isEqualTo(UserAttendance.currentAttendanceDate().minusDays(1)); } + @Test + @DisplayName("미출석 유저가 있으면 streak 초기화 후 소켓 알림을 발송한다") + void resetStreakForMissedAttendance_notifiesViaSocket_whenMissedUsersExist() { + List missedIds = List.of(1L, 2L); + when(userAttendanceRepositoryPort.findNotAttendedUserIdsByDate(YESTERDAY)) + .thenReturn(missedIds); + when(userRepositoryPort.findAllByIds(missedIds)) + .thenReturn(List.of(createUser(1L, 3), createUser(2L, 5))); + + resetAttendanceStreakService.resetStreakForMissedAttendance(); + + verify(attendanceRefetchNotifier).notifyAttendanceMissed(missedIds); + } + private User createUser(Long id, int attendanceStreak) { return new User( id, Instant.now(), Instant.now(), From 0262fa386b628aec88f1e02c38748c0d6c9afd79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=A0=95=EB=B9=88?= Date: Tue, 14 Apr 2026 16:50:40 +0900 Subject: [PATCH 15/21] =?UTF-8?q?fix:=207=EC=9D=BC=20=EC=97=B0=EC=86=8D=20?= =?UTF-8?q?=EC=B6=9C=EC=84=9D=EC=8B=9C=201000=EC=BF=A0=ED=82=A4=EB=A5=BC?= =?UTF-8?q?=20=EC=A7=80=EA=B8=89=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/UserAttendanceControllerDocument.java | 2 +- .../userattendance/service/MarkAttendanceService.java | 10 ++++++---- .../service/MarkAttendanceServiceTest.java | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/process/clash/adapter/web/user/docs/controller/UserAttendanceControllerDocument.java b/src/main/java/com/process/clash/adapter/web/user/docs/controller/UserAttendanceControllerDocument.java index 44020d34c..e4d9824f2 100644 --- a/src/main/java/com/process/clash/adapter/web/user/docs/controller/UserAttendanceControllerDocument.java +++ b/src/main/java/com/process/clash/adapter/web/user/docs/controller/UserAttendanceControllerDocument.java @@ -15,7 +15,7 @@ @Tag(name = "출석 API", description = "출석 체크 및 주간 출석 현황 조회") public interface UserAttendanceControllerDocument { - @Operation(summary = "출석 체크", description = "오늘 출석을 기록하고 쿠키를 지급합니다. 일일 300 쿠키, 일~토 주간 개근 시 추가 1000 쿠키를 지급합니다.") + @Operation(summary = "출석 체크", description = "오늘 출석을 기록하고 쿠키를 지급합니다. 일일 300 쿠키, 일~토 주간 개근 완료 시 해당 날(토요일)에는 1000 쿠키를 지급합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "출석 성공", content = @Content( diff --git a/src/main/java/com/process/clash/application/user/userattendance/service/MarkAttendanceService.java b/src/main/java/com/process/clash/application/user/userattendance/service/MarkAttendanceService.java index 53c263b29..c6785e7b3 100644 --- a/src/main/java/com/process/clash/application/user/userattendance/service/MarkAttendanceService.java +++ b/src/main/java/com/process/clash/application/user/userattendance/service/MarkAttendanceService.java @@ -46,7 +46,9 @@ public MarkAttendanceData.Result execute(MarkAttendanceData.Command command) { User user = userRepositoryPort.findByIdForUpdate(userId) .orElseThrow(UserNotFoundException::new); - int earnedCookies = DAILY_COOKIE_REWARD + calculateWeeklyCookieBonus(userId, attendanceDate); + int earnedCookies = isFullWeekCompleted(userId, attendanceDate) + ? WEEKLY_COOKIE_REWARD + : DAILY_COOKIE_REWARD; User updated = user.incrementAttendanceStreak().addCookie(earnedCookies); userRepositoryPort.save(updated); @@ -54,14 +56,14 @@ public MarkAttendanceData.Result execute(MarkAttendanceData.Command command) { return new MarkAttendanceData.Result(updated.currentAttendanceStreak(), earnedCookies); } - private int calculateWeeklyCookieBonus(Long userId, LocalDate attendanceDate) { + private boolean isFullWeekCompleted(Long userId, LocalDate attendanceDate) { if (attendanceDate.getDayOfWeek() != DayOfWeek.SATURDAY) { - return 0; + return false; } // 토요일(getValue()=6): 6일 빼면 해당 주 일요일 LocalDate weekSunday = attendanceDate.minusDays(6); long weeklyCount = userAttendanceRepositoryPort .countAttendedByUserIdBetween(userId, weekSunday, attendanceDate); - return weeklyCount == DAYS_IN_WEEK ? WEEKLY_COOKIE_REWARD : 0; + return weeklyCount == DAYS_IN_WEEK; } } \ No newline at end of file diff --git a/src/test/java/com/process/clash/application/user/userattendance/service/MarkAttendanceServiceTest.java b/src/test/java/com/process/clash/application/user/userattendance/service/MarkAttendanceServiceTest.java index 2856d8963..3801965a2 100644 --- a/src/test/java/com/process/clash/application/user/userattendance/service/MarkAttendanceServiceTest.java +++ b/src/test/java/com/process/clash/application/user/userattendance/service/MarkAttendanceServiceTest.java @@ -171,7 +171,7 @@ void execute_givesWeeklyBonusCookies_whenFullWeekCompleted() { MarkAttendanceData.Result result = markAttendanceService.execute(command); - assertThat(result.earnedCookies()).isEqualTo(1300); + assertThat(result.earnedCookies()).isEqualTo(1000); } } From 534978bd33d3eb7a4803fcc3edfd8b7840b2c053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=A0=95=EB=B9=88?= Date: Tue, 14 Apr 2026 17:19:17 +0900 Subject: [PATCH 16/21] =?UTF-8?q?feat:=20=ED=95=B4=EB=8B=B9=EC=A3=BC=20?= =?UTF-8?q?=EC=B6=9C=EC=84=9D=20=EC=A0=95=EB=B3=B4=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=EC=9D=98=20=EC=B4=9D=20=EC=97=B0=EC=86=8D=20?= =?UTF-8?q?=EC=B6=9C=EC=84=9D=EC=9D=BC=EC=9D=84=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/web/user/dto/GetWeeklyAttendanceDto.java | 6 ++++-- .../userattendance/data/GetWeeklyAttendanceData.java | 3 ++- .../service/GetWeeklyAttendanceService.java | 9 ++++++++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/process/clash/adapter/web/user/dto/GetWeeklyAttendanceDto.java b/src/main/java/com/process/clash/adapter/web/user/dto/GetWeeklyAttendanceDto.java index 952dc1266..f666cb70f 100644 --- a/src/main/java/com/process/clash/adapter/web/user/dto/GetWeeklyAttendanceDto.java +++ b/src/main/java/com/process/clash/adapter/web/user/dto/GetWeeklyAttendanceDto.java @@ -12,7 +12,8 @@ public record Response( int weekNumber, LocalDate weekStart, LocalDate weekEnd, - List days + List days, + int currentStreak ) { public static Response from(GetWeeklyAttendanceData.Result result) { return new Response( @@ -21,7 +22,8 @@ public static Response from(GetWeeklyAttendanceData.Result result) { result.weekEnd(), result.days().stream() .map(AttendanceDayItem::from) - .toList() + .toList(), + result.currentStreak() ); } } diff --git a/src/main/java/com/process/clash/application/user/userattendance/data/GetWeeklyAttendanceData.java b/src/main/java/com/process/clash/application/user/userattendance/data/GetWeeklyAttendanceData.java index e061864ca..e05e2ee52 100644 --- a/src/main/java/com/process/clash/application/user/userattendance/data/GetWeeklyAttendanceData.java +++ b/src/main/java/com/process/clash/application/user/userattendance/data/GetWeeklyAttendanceData.java @@ -14,7 +14,8 @@ public record Result( int weekNumber, LocalDate weekStart, LocalDate weekEnd, - List days + List days, + int currentStreak ) {} public record AttendanceDay( diff --git a/src/main/java/com/process/clash/application/user/userattendance/service/GetWeeklyAttendanceService.java b/src/main/java/com/process/clash/application/user/userattendance/service/GetWeeklyAttendanceService.java index 8ff14c66d..37c379688 100644 --- a/src/main/java/com/process/clash/application/user/userattendance/service/GetWeeklyAttendanceService.java +++ b/src/main/java/com/process/clash/application/user/userattendance/service/GetWeeklyAttendanceService.java @@ -1,5 +1,7 @@ package com.process.clash.application.user.userattendance.service; +import com.process.clash.application.user.user.exception.exception.notfound.UserNotFoundException; +import com.process.clash.application.user.user.port.out.UserRepositoryPort; import com.process.clash.application.user.userattendance.data.GetWeeklyAttendanceData; import com.process.clash.application.user.userattendance.port.in.GetWeeklyAttendanceUseCase; import com.process.clash.application.user.userattendance.port.out.UserAttendanceRepositoryPort; @@ -21,6 +23,7 @@ public class GetWeeklyAttendanceService implements GetWeeklyAttendanceUseCase { private final UserAttendanceRepositoryPort userAttendanceRepositoryPort; + private final UserRepositoryPort userRepositoryPort; @Override public GetWeeklyAttendanceData.Result execute(GetWeeklyAttendanceData.Command command) { @@ -50,7 +53,11 @@ public GetWeeklyAttendanceData.Result execute(GetWeeklyAttendanceData.Command co int weekNumber = calculateWeekNumber(userId, weekStart); - return new GetWeeklyAttendanceData.Result(weekNumber, weekStart, weekEnd, days); + int currentStreak = userRepositoryPort.findById(userId) + .orElseThrow(UserNotFoundException::new) + .currentAttendanceStreak(); + + return new GetWeeklyAttendanceData.Result(weekNumber, weekStart, weekEnd, days, currentStreak); } private int calculateWeekNumber(Long userId, LocalDate currentWeekStart) { From dd550af63b6831dbbf2c14da29e072f3f9d043ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=A0=95=EB=B9=88?= Date: Tue, 14 Apr 2026 17:49:22 +0900 Subject: [PATCH 17/21] =?UTF-8?q?fix:=20broadcastAttendanceBoard=20?= =?UTF-8?q?=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=EC=9D=84=2007:00-05:00=EB=A1=9C=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserAttendanceInitScheduler.java | 17 ++++++++++++ .../realtime/AttendanceRefetchNotifier.java | 10 +++++++ .../BroadcastAttendanceBoardService.java | 27 +++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 src/main/java/com/process/clash/application/user/userattendance/service/BroadcastAttendanceBoardService.java diff --git a/src/main/java/com/process/clash/adapter/scheduler/UserAttendanceInitScheduler.java b/src/main/java/com/process/clash/adapter/scheduler/UserAttendanceInitScheduler.java index f65fd4a0a..359aa2e02 100644 --- a/src/main/java/com/process/clash/adapter/scheduler/UserAttendanceInitScheduler.java +++ b/src/main/java/com/process/clash/adapter/scheduler/UserAttendanceInitScheduler.java @@ -1,5 +1,6 @@ package com.process.clash.adapter.scheduler; +import com.process.clash.application.user.userattendance.service.BroadcastAttendanceBoardService; import com.process.clash.application.user.userattendance.service.InitUserAttendanceService; import com.process.clash.application.user.userattendance.service.ResetAttendanceStreakService; import lombok.RequiredArgsConstructor; @@ -14,6 +15,7 @@ public class UserAttendanceInitScheduler { private final InitUserAttendanceService initUserAttendanceService; private final ResetAttendanceStreakService resetAttendanceStreakService; + private final BroadcastAttendanceBoardService broadcastAttendanceBoardService; /** * 매일 KST 06:00에 다음 두 작업을 순서대로 수행한다. @@ -38,4 +40,19 @@ public void initDailyAttendance() { log.error("출석 초기화 스케줄 실행 실패", e); } } + + /** + * 매일 KST 07:00 ~ 05:00 매 정시마다 전체 활성 유저에게 주간 출석판 갱신 알림을 전송한다. + * 06:00 이후에 앱을 실행한 유저도 최대 1시간 이내에 출석판을 확인할 수 있도록 한다. + */ + @Scheduled(cron = "0 0 0-5,7-23 * * *", zone = "Asia/Seoul") + public void broadcastAttendanceBoard() { + try { + log.info("출석판 브로드캐스트 스케줄 실행 시작"); + broadcastAttendanceBoardService.broadcastAttendanceBoard(); + log.info("출석판 브로드캐스트 스케줄 실행 완료"); + } catch (Exception e) { + log.error("출석판 브로드캐스트 스케줄 실행 실패", e); + } + } } \ No newline at end of file diff --git a/src/main/java/com/process/clash/application/user/userattendance/realtime/AttendanceRefetchNotifier.java b/src/main/java/com/process/clash/application/user/userattendance/realtime/AttendanceRefetchNotifier.java index 340b5cccd..d3be91529 100644 --- a/src/main/java/com/process/clash/application/user/userattendance/realtime/AttendanceRefetchNotifier.java +++ b/src/main/java/com/process/clash/application/user/userattendance/realtime/AttendanceRefetchNotifier.java @@ -24,4 +24,14 @@ public void notifyAttendanceMissed(Collection userIds) { userIds ); } + + /** + * 전체 활성 유저에게 주간 출석 데이터를 갱신하도록 소켓으로 알립니다. + */ + public void notifyAttendanceBoard(Collection userIds) { + refetchEventPublisher.publish( + RefetchNotice.of(ChangeDomain.ATTENDANCE, ChangeType.DATA_CHANGED), + userIds + ); + } } \ No newline at end of file diff --git a/src/main/java/com/process/clash/application/user/userattendance/service/BroadcastAttendanceBoardService.java b/src/main/java/com/process/clash/application/user/userattendance/service/BroadcastAttendanceBoardService.java new file mode 100644 index 000000000..ea3d3caeb --- /dev/null +++ b/src/main/java/com/process/clash/application/user/userattendance/service/BroadcastAttendanceBoardService.java @@ -0,0 +1,27 @@ +package com.process.clash.application.user.userattendance.service; + +import com.process.clash.application.user.userattendance.port.out.UserAttendanceRepositoryPort; +import com.process.clash.application.user.userattendance.realtime.AttendanceRefetchNotifier; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class BroadcastAttendanceBoardService { + + private final UserAttendanceRepositoryPort userAttendanceRepositoryPort; + private final AttendanceRefetchNotifier attendanceRefetchNotifier; + + /** + * 전체 활성 유저에게 주간 출석판 갱신 알림을 소켓으로 전송한다. + */ + public void broadcastAttendanceBoard() { + List userIds = userAttendanceRepositoryPort.findAllNonDeletedUserIds(); + if (userIds.isEmpty()) { + return; + } + attendanceRefetchNotifier.notifyAttendanceBoard(userIds); + } +} \ No newline at end of file From 57d54eade9e23a5c65c4f5225b2cc14cf64bd0b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=A0=95=EB=B9=88?= Date: Tue, 14 Apr 2026 17:56:41 +0900 Subject: [PATCH 18/21] =?UTF-8?q?docs:=20=EC=9C=A0=EC=A0=80=20=EC=B6=9C?= =?UTF-8?q?=EC=84=9D=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserAttendanceControllerDocument.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/process/clash/adapter/web/user/docs/controller/UserAttendanceControllerDocument.java b/src/main/java/com/process/clash/adapter/web/user/docs/controller/UserAttendanceControllerDocument.java index e4d9824f2..12ca27590 100644 --- a/src/main/java/com/process/clash/adapter/web/user/docs/controller/UserAttendanceControllerDocument.java +++ b/src/main/java/com/process/clash/adapter/web/user/docs/controller/UserAttendanceControllerDocument.java @@ -53,7 +53,15 @@ com.process.clash.adapter.web.common.ApiResponse mar @Parameter(hidden = true) Actor actor ); - @Operation(summary = "주간 출석 현황 조회", description = "이번 주(일~토) 날짜별 출석 여부를 조회합니다.") + @Operation( + summary = "주간 출석 현황 조회", + description = """ + 이번 주(일~토) 날짜별 출석 여부와 연속 출석 streak을 조회합니다. + 앱 시작 시 출석판 초기 렌더링에 사용하며, 소켓으로 ATTENDANCE/DATA_CHANGED 이벤트를 수신할 때도 재호출합니다. + - weekNumber: 최초 출석일 기준 몇 번째 주인지 + - currentStreak: 현재 연속 출석일 + """ + ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content( @@ -62,6 +70,7 @@ com.process.clash.adapter.web.common.ApiResponse mar "success": true, "message": "주간 출석 현황을 성공적으로 조회했습니다.", "data": { + "weekNumber": 3, "weekStart": "2026-04-12", "weekEnd": "2026-04-18", "days": [ @@ -72,7 +81,8 @@ com.process.clash.adapter.web.common.ApiResponse mar { "date": "2026-04-16", "dayOfWeek": "THURSDAY", "isAttended": false }, { "date": "2026-04-17", "dayOfWeek": "FRIDAY", "isAttended": false }, { "date": "2026-04-18", "dayOfWeek": "SATURDAY", "isAttended": false } - ] + ], + "currentStreak": 5 } } """) From 3703606952fe1462cf2c7b7d2e501866661119a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=A0=95=EB=B9=88?= Date: Tue, 14 Apr 2026 17:59:07 +0900 Subject: [PATCH 19/21] =?UTF-8?q?fix:=20flyway=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=20=EB=B2=84=EC=A0=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...35__add_user_attendances.sql => V36__add_user_attendances.sql} | 0 ..._users.sql => V37__add_current_attendance_streak_to_users.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/main/resources/db/migration/{V35__add_user_attendances.sql => V36__add_user_attendances.sql} (100%) rename src/main/resources/db/migration/{V36__add_current_attendance_streak_to_users.sql => V37__add_current_attendance_streak_to_users.sql} (100%) diff --git a/src/main/resources/db/migration/V35__add_user_attendances.sql b/src/main/resources/db/migration/V36__add_user_attendances.sql similarity index 100% rename from src/main/resources/db/migration/V35__add_user_attendances.sql rename to src/main/resources/db/migration/V36__add_user_attendances.sql diff --git a/src/main/resources/db/migration/V36__add_current_attendance_streak_to_users.sql b/src/main/resources/db/migration/V37__add_current_attendance_streak_to_users.sql similarity index 100% rename from src/main/resources/db/migration/V36__add_current_attendance_streak_to_users.sql rename to src/main/resources/db/migration/V37__add_current_attendance_streak_to_users.sql From 4a28e5813b8cd219473f5673c425f94302f2a6a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=A0=95=EB=B9=88?= Date: Tue, 14 Apr 2026 18:09:10 +0900 Subject: [PATCH 20/21] =?UTF-8?q?fix:=2006=EC=8B=9C=20=EC=9D=B4=ED=9B=84?= =?UTF-8?q?=20=EA=B0=80=EC=9E=85=ED=95=9C=20=EC=8B=A0=EA=B7=9C=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=20=EC=B6=9C=EC=84=9D=20=EC=8B=9C=20=EB=A0=88=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=90=EB=8F=99=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/userattendance/service/MarkAttendanceService.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/com/process/clash/application/user/userattendance/service/MarkAttendanceService.java b/src/main/java/com/process/clash/application/user/userattendance/service/MarkAttendanceService.java index c6785e7b3..dc84ac206 100644 --- a/src/main/java/com/process/clash/application/user/userattendance/service/MarkAttendanceService.java +++ b/src/main/java/com/process/clash/application/user/userattendance/service/MarkAttendanceService.java @@ -4,7 +4,6 @@ import com.process.clash.application.user.user.port.out.UserRepositoryPort; import com.process.clash.application.user.userattendance.data.MarkAttendanceData; import com.process.clash.application.user.userattendance.exception.exception.conflict.AlreadyAttendedException; -import com.process.clash.application.user.userattendance.exception.exception.notfound.UserAttendanceNotFoundException; import com.process.clash.application.user.userattendance.port.in.MarkAttendanceUseCase; import com.process.clash.application.user.userattendance.port.out.UserAttendanceRepositoryPort; import com.process.clash.domain.user.user.entity.User; @@ -35,7 +34,7 @@ public MarkAttendanceData.Result execute(MarkAttendanceData.Command command) { UserAttendance attendance = userAttendanceRepositoryPort .findByUserIdAndAttendanceDate(userId, attendanceDate) - .orElseThrow(UserAttendanceNotFoundException::new); + .orElseGet(() -> userAttendanceRepositoryPort.save(UserAttendance.create(userId, attendanceDate))); if (attendance.isAttended()) { throw new AlreadyAttendedException(); From 7d367e7cbcf07a6a121aa63f59f793998cae9805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=A0=95=EB=B9=88?= Date: Tue, 14 Apr 2026 18:10:17 +0900 Subject: [PATCH 21/21] =?UTF-8?q?test:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/MarkAttendanceServiceTest.java | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/test/java/com/process/clash/application/user/userattendance/service/MarkAttendanceServiceTest.java b/src/test/java/com/process/clash/application/user/userattendance/service/MarkAttendanceServiceTest.java index 3801965a2..daa27b6bd 100644 --- a/src/test/java/com/process/clash/application/user/userattendance/service/MarkAttendanceServiceTest.java +++ b/src/test/java/com/process/clash/application/user/userattendance/service/MarkAttendanceServiceTest.java @@ -53,17 +53,6 @@ void setUp() { markAttendanceService = new MarkAttendanceService(userAttendanceRepositoryPort, userRepositoryPort); } - @Test - @DisplayName("오늘 출석 레코드가 없으면 UserAttendanceNotFoundException을 던진다") - void execute_throwsUserAttendanceNotFoundException_whenNoRecord() { - MarkAttendanceData.Command command = new MarkAttendanceData.Command(new Actor(USER_ID)); - when(userAttendanceRepositoryPort.findByUserIdAndAttendanceDate(USER_ID, ATTENDANCE_DATE)) - .thenReturn(Optional.empty()); - - assertThatThrownBy(() -> markAttendanceService.execute(command)) - .isInstanceOf(UserAttendanceNotFoundException.class); - } - @Test @DisplayName("이미 출석한 경우 AlreadyAttendedException을 던진다") void execute_throwsAlreadyAttendedException_whenAlreadyAttended() {