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/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..64862afec --- /dev/null +++ b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaEntity.java @@ -0,0 +1,48 @@ +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; +import java.time.LocalDate; + +@Entity +@EntityListeners(AuditingEntityListener.class) +@Table( + name = "user_attendances", + uniqueConstraints = @UniqueConstraint(columnNames = {"fk_user_id", "attendance_date"}) +) +@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 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 new file mode 100644 index 000000000..51e241e23 --- /dev/null +++ b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaMapper.java @@ -0,0 +1,31 @@ +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.attendanceDate(), + userAttendance.isAttended() + ); + } + + public UserAttendance toDomain(UserAttendanceJpaEntity entity) { + return new UserAttendance( + entity.getId(), + entity.getCreatedAt(), + entity.getUpdatedAt(), + entity.getUser().getId(), + entity.getAttendanceDate(), + 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..e610fc660 --- /dev/null +++ b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendanceJpaRepository.java @@ -0,0 +1,50 @@ +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; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@Repository +public interface UserAttendanceJpaRepository extends JpaRepository { + + @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 + FROM users u + 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); + + @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); + + @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 new file mode 100644 index 000000000..0d192432c --- /dev/null +++ b/src/main/java/com/process/clash/adapter/persistence/user/userattendance/UserAttendancePersistenceAdapter.java @@ -0,0 +1,84 @@ +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.time.LocalDate; +import java.util.List; +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 findByUserIdAndAttendanceDate(Long userId, LocalDate attendanceDate) { + return userAttendanceJpaRepository.findByUserIdAndAttendanceDate(userId, attendanceDate) + .map(userAttendanceJpaMapper::toDomain); + } + + @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(); + } + + @Override + 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); + } + + @Override + public List findByUserIdBetweenDates(Long userId, LocalDate start, LocalDate end) { + return userAttendanceJpaRepository.findByUserIdBetweenDates(userId, start, end) + .stream() + .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/scheduler/UserAttendanceInitScheduler.java b/src/main/java/com/process/clash/adapter/scheduler/UserAttendanceInitScheduler.java new file mode 100644 index 000000000..359aa2e02 --- /dev/null +++ b/src/main/java/com/process/clash/adapter/scheduler/UserAttendanceInitScheduler.java @@ -0,0 +1,58 @@ +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; +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; + private final ResetAttendanceStreakService resetAttendanceStreakService; + private final BroadcastAttendanceBoardService broadcastAttendanceBoardService; + + /** + * 매일 KST 06:00에 다음 두 작업을 순서대로 수행한다. + * 1. 전날 출석하지 않은 유저들의 streak 초기화 + 소켓 알림 발송 + * 2. 전체 활성 유저의 오늘 출석 레코드 생성 (이미 존재하면 무시) + */ + @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(); + log.info("출석 초기화 스케줄 실행 완료"); + } catch (Exception e) { + 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/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/docs/controller/UserAttendanceControllerDocument.java b/src/main/java/com/process/clash/adapter/web/user/docs/controller/UserAttendanceControllerDocument.java new file mode 100644 index 000000000..12ca27590 --- /dev/null +++ b/src/main/java/com/process/clash/adapter/web/user/docs/controller/UserAttendanceControllerDocument.java @@ -0,0 +1,94 @@ +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 = """ + 이번 주(일~토) 날짜별 출석 여부와 연속 출석 streak을 조회합니다. + 앱 시작 시 출석판 초기 렌더링에 사용하며, 소켓으로 ATTENDANCE/DATA_CHANGED 이벤트를 수신할 때도 재호출합니다. + - weekNumber: 최초 출석일 기준 몇 번째 주인지 + - currentStreak: 현재 연속 출석일 + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content( + examples = @ExampleObject(value = """ + { + "success": true, + "message": "주간 출석 현황을 성공적으로 조회했습니다.", + "data": { + "weekNumber": 3, + "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 } + ], + "currentStreak": 5 + } + } + """) + )) + }) + com.process.clash.adapter.web.common.ApiResponse getWeeklyAttendance( + @Parameter(hidden = true) Actor actor + ); +} \ 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 new file mode 100644 index 000000000..f666cb70f --- /dev/null +++ b/src/main/java/com/process/clash/adapter/web/user/dto/GetWeeklyAttendanceDto.java @@ -0,0 +1,40 @@ +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( + int weekNumber, + LocalDate weekStart, + LocalDate weekEnd, + List days, + int currentStreak + ) { + public static Response from(GetWeeklyAttendanceData.Result result) { + return new Response( + result.weekNumber(), + result.weekStart(), + result.weekEnd(), + result.days().stream() + .map(AttendanceDayItem::from) + .toList(), + result.currentStreak() + ); + } + } + + 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/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..3e86887c6 --- /dev/null +++ b/src/main/java/com/process/clash/adapter/web/user/dto/MarkAttendanceDto.java @@ -0,0 +1,15 @@ +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, + int earnedCookies + ) { + public static Response from(MarkAttendanceData.Result result) { + return new Response(result.attendanceStreak(), result.earnedCookies()); + } + } +} \ No newline at end of file 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/data/GetWeeklyAttendanceData.java b/src/main/java/com/process/clash/application/user/userattendance/data/GetWeeklyAttendanceData.java new file mode 100644 index 000000000..e05e2ee52 --- /dev/null +++ b/src/main/java/com/process/clash/application/user/userattendance/data/GetWeeklyAttendanceData.java @@ -0,0 +1,26 @@ +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( + int weekNumber, + LocalDate weekStart, + LocalDate weekEnd, + List days, + int currentStreak + ) {} + + 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/data/MarkAttendanceData.java b/src/main/java/com/process/clash/application/user/userattendance/data/MarkAttendanceData.java new file mode 100644 index 000000000..8636f332d --- /dev/null +++ b/src/main/java/com/process/clash/application/user/userattendance/data/MarkAttendanceData.java @@ -0,0 +1,15 @@ +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, + int earnedCookies + ) {} +} \ 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/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/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 new file mode 100644 index 000000000..029db2260 --- /dev/null +++ b/src/main/java/com/process/clash/application/user/userattendance/port/out/UserAttendanceRepositoryPort.java @@ -0,0 +1,30 @@ +package com.process.clash.application.user.userattendance.port.out; + +import com.process.clash.domain.user.userattendance.entity.UserAttendance; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface UserAttendanceRepositoryPort { + + UserAttendance save(UserAttendance userAttendance); + + Optional findByUserIdAndAttendanceDate(Long userId, LocalDate attendanceDate); + + List findAllNonDeletedUserIds(); + + void saveAll(List attendances); + + void deleteAll(); + + List findNotAttendedUserIdsByDate(LocalDate date); + + 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/realtime/AttendanceRefetchNotifier.java b/src/main/java/com/process/clash/application/user/userattendance/realtime/AttendanceRefetchNotifier.java new file mode 100644 index 000000000..d3be91529 --- /dev/null +++ b/src/main/java/com/process/clash/application/user/userattendance/realtime/AttendanceRefetchNotifier.java @@ -0,0 +1,37 @@ +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 + ); + } + + /** + * 전체 활성 유저에게 주간 출석 데이터를 갱신하도록 소켓으로 알립니다. + */ + 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 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..37c379688 --- /dev/null +++ b/src/main/java/com/process/clash/application/user/userattendance/service/GetWeeklyAttendanceService.java @@ -0,0 +1,73 @@ +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; +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.time.temporal.ChronoUnit; +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; + private final UserRepositoryPort userRepositoryPort; + + @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); + + Long userId = command.actor().id(); + + List records = userAttendanceRepositoryPort + .findByUserIdBetweenDates(userId, 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(); + + int weekNumber = calculateWeekNumber(userId, weekStart); + + 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) { + 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 new file mode 100644 index 000000000..195d4f5d2 --- /dev/null +++ b/src/main/java/com/process/clash/application/user/userattendance/service/InitUserAttendanceService.java @@ -0,0 +1,22 @@ +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.time.LocalDate; + +@Service +@RequiredArgsConstructor +public class InitUserAttendanceService { + + private final UserAttendanceRepositoryPort userAttendanceRepositoryPort; + + @Transactional + public void initDailyAttendance() { + LocalDate attendanceDate = UserAttendance.currentAttendanceDate(); + userAttendanceRepositoryPort.initDailyAttendanceForAllUsers(attendanceDate); + } +} \ 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 new file mode 100644 index 000000000..dc84ac206 --- /dev/null +++ b/src/main/java/com/process/clash/application/user/userattendance/service/MarkAttendanceService.java @@ -0,0 +1,68 @@ +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.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.DayOfWeek; +import java.time.LocalDate; + +@Service +@RequiredArgsConstructor +@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; + + @Override + public MarkAttendanceData.Result execute(MarkAttendanceData.Command command) { + Long userId = command.actor().id(); + LocalDate attendanceDate = UserAttendance.currentAttendanceDate(); + + UserAttendance attendance = userAttendanceRepositoryPort + .findByUserIdAndAttendanceDate(userId, attendanceDate) + .orElseGet(() -> userAttendanceRepositoryPort.save(UserAttendance.create(userId, attendanceDate))); + + if (attendance.isAttended()) { + throw new AlreadyAttendedException(); + } + + userAttendanceRepositoryPort.save(attendance.markAttend()); + + User user = userRepositoryPort.findByIdForUpdate(userId) + .orElseThrow(UserNotFoundException::new); + + int earnedCookies = isFullWeekCompleted(userId, attendanceDate) + ? WEEKLY_COOKIE_REWARD + : DAILY_COOKIE_REWARD; + + User updated = user.incrementAttendanceStreak().addCookie(earnedCookies); + userRepositoryPort.save(updated); + + return new MarkAttendanceData.Result(updated.currentAttendanceStreak(), earnedCookies); + } + + private boolean isFullWeekCompleted(Long userId, LocalDate attendanceDate) { + if (attendanceDate.getDayOfWeek() != DayOfWeek.SATURDAY) { + return false; + } + // 토요일(getValue()=6): 6일 빼면 해당 주 일요일 + LocalDate weekSunday = attendanceDate.minusDays(6); + long weeklyCount = userAttendanceRepositoryPort + .countAttendedByUserIdBetween(userId, weekSunday, attendanceDate); + return weeklyCount == DAYS_IN_WEEK; + } +} \ 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..bc7790d1b --- /dev/null +++ b/src/main/java/com/process/clash/application/user/userattendance/service/ResetAttendanceStreakService.java @@ -0,0 +1,45 @@ +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.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; +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; + private final AttendanceRefetchNotifier attendanceRefetchNotifier; + + /** + * 전날 출석하지 않은 유저들의 연속 출석 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; + } + + List updated = userRepositoryPort.findAllByIds(missedUserIds).stream() + .map(User::resetAttendanceStreak) + .toList(); + + userRepositoryPort.saveAll(updated); + + attendanceRefetchNotifier.notifyAttendanceMissed(missedUserIds); + } +} \ 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 e87544abf..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 @@ -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,28 @@ 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 + ); + } + + 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 + ); + } + + 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 ); } } 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..45d7afaf6 --- /dev/null +++ b/src/main/java/com/process/clash/domain/user/userattendance/entity/UserAttendance.java @@ -0,0 +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, LocalDate attendanceDate) { + return new UserAttendance( + null, + null, + null, + userId, + attendanceDate, + false + ); + } + + 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/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; diff --git a/src/main/resources/db/migration/V36__add_user_attendances.sql b/src/main/resources/db/migration/V36__add_user_attendances.sql new file mode 100644 index 000000000..2a07f3b21 --- /dev/null +++ b/src/main/resources/db/migration/V36__add_user_attendances.sql @@ -0,0 +1,18 @@ +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, + attendance_date DATE NOT NULL, + is_attended BOOLEAN NOT NULL, + CONSTRAINT pk_user_attendances PRIMARY KEY (id), + CONSTRAINT uc_user_attendances_user_date UNIQUE (fk_user_id, attendance_date) +); + +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 diff --git a/src/main/resources/db/migration/V37__add_current_attendance_streak_to_users.sql b/src/main/resources/db/migration/V37__add_current_attendance_streak_to_users.sql new file mode 100644 index 000000000..bdb541b69 --- /dev/null +++ b/src/main/resources/db/migration/V37__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/userattendance/service/InitUserAttendanceServiceTest.java b/src/test/java/com/process/clash/application/user/userattendance/service/InitUserAttendanceServiceTest.java new file mode 100644 index 000000000..46b2d5181 --- /dev/null +++ b/src/test/java/com/process/clash/application/user/userattendance/service/InitUserAttendanceServiceTest.java @@ -0,0 +1,37 @@ +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class InitUserAttendanceServiceTest { + + @Mock + private UserAttendanceRepositoryPort userAttendanceRepositoryPort; + + private InitUserAttendanceService initUserAttendanceService; + + @BeforeEach + void setUp() { + initUserAttendanceService = new InitUserAttendanceService(userAttendanceRepositoryPort); + } + + @Test + @DisplayName("오늘 날짜로 모든 활성 유저의 출석 레코드를 생성한다 (이미 존재하면 무시)") + void initDailyAttendance_insertsForAllActiveUsers() { + initUserAttendanceService.initDailyAttendance(); + + verify(userAttendanceRepositoryPort).initDailyAttendanceForAllUsers( + eq(UserAttendance.currentAttendanceDate()) + ); + } +} \ 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..daa27b6bd --- /dev/null +++ b/src/test/java/com/process/clash/application/user/userattendance/service/MarkAttendanceServiceTest.java @@ -0,0 +1,203 @@ +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 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; + +@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("이미 출석한 경우 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); + } + + // 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(1000); + } + } + + @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); + } + + 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/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..6fff388ec --- /dev/null +++ b/src/test/java/com/process/clash/application/user/userattendance/service/ResetAttendanceStreakServiceTest.java @@ -0,0 +1,142 @@ +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.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; +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; + + @Mock + private AttendanceRefetchNotifier attendanceRefetchNotifier; + + private ResetAttendanceStreakService resetAttendanceStreakService; + + private final LocalDate YESTERDAY = UserAttendance.currentAttendanceDate().minusDays(1); + + @BeforeEach + void setUp() { + resetAttendanceStreakService = new ResetAttendanceStreakService( + userAttendanceRepositoryPort, + userRepositoryPort, + attendanceRefetchNotifier + ); + } + + @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()); + verify(attendanceRefetchNotifier, never()).notifyAttendanceMissed(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)); + } + + @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(), + "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 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 ); } } 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