From dad3be17d1c0721e32ac13ef3be1c7cf1a836c98 Mon Sep 17 00:00:00 2001 From: jyx-07 Date: Wed, 1 Jul 2026 23:25:52 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat=20:=20=EC=9E=90=EC=8A=B5=20=EC=B6=9C?= =?UTF-8?q?=EC=84=9D=20=EC=9D=B4=EB=A0=A5=20=EC=A0=80=EC=9E=A5=20=EB=B0=8F?= =?UTF-8?q?=20=EB=AC=B4=EC=9D=B8=EC=A6=9D=20=EA=B3=B5=EA=B0=9C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entity/StudyAttendanceHistoryJpaEntity.kt | 32 ++++++++++++++++++ .../controller/PublicStudyController.kt | 33 +++++++++++++++++++ .../GetStudyAttendanceListResponse.kt | 8 +++++ .../StudyAttendanceHistoryJpaRepository.kt | 28 ++++++++++++++++ .../GetPublicStudyAttendanceListService.kt | 7 ++++ .../impl/CheckStudyAttendanceServiceImpl.kt | 12 ++++++- ...GetPublicStudyAttendanceListServiceImpl.kt | 26 +++++++++++++++ .../impl/UncheckStudyAttendanceServiceImpl.kt | 4 +++ .../global/security/config/SecurityConfig.kt | 1 + .../UncheckStudyAttendanceServiceTest.kt | 15 ++++++++- 10 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/team/incube/flooding/domain/dormitory/study/entity/StudyAttendanceHistoryJpaEntity.kt create mode 100644 src/main/kotlin/team/incube/flooding/domain/dormitory/study/presentation/controller/PublicStudyController.kt create mode 100644 src/main/kotlin/team/incube/flooding/domain/dormitory/study/presentation/data/response/GetStudyAttendanceListResponse.kt create mode 100644 src/main/kotlin/team/incube/flooding/domain/dormitory/study/repository/StudyAttendanceHistoryJpaRepository.kt create mode 100644 src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/GetPublicStudyAttendanceListService.kt create mode 100644 src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/impl/GetPublicStudyAttendanceListServiceImpl.kt diff --git a/src/main/kotlin/team/incube/flooding/domain/dormitory/study/entity/StudyAttendanceHistoryJpaEntity.kt b/src/main/kotlin/team/incube/flooding/domain/dormitory/study/entity/StudyAttendanceHistoryJpaEntity.kt new file mode 100644 index 00000000..fea65804 --- /dev/null +++ b/src/main/kotlin/team/incube/flooding/domain/dormitory/study/entity/StudyAttendanceHistoryJpaEntity.kt @@ -0,0 +1,32 @@ +package team.incube.flooding.domain.dormitory.study.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint +import team.incube.flooding.domain.user.entity.UserJpaEntity +import java.time.LocalDate + +@Entity +@Table( + name = "tb_study_attendance_history", + uniqueConstraints = [ + UniqueConstraint(columnNames = ["user_id", "attended_date"]), + ], +) +class StudyAttendanceHistoryJpaEntity( + @field:Id + @field:GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0, + @field:ManyToOne(fetch = FetchType.LAZY) + @field:JoinColumn(nullable = false, name = "user_id") + val user: UserJpaEntity, + @field:Column(name = "attended_date", nullable = false) + val attendedDate: LocalDate, +) diff --git a/src/main/kotlin/team/incube/flooding/domain/dormitory/study/presentation/controller/PublicStudyController.kt b/src/main/kotlin/team/incube/flooding/domain/dormitory/study/presentation/controller/PublicStudyController.kt new file mode 100644 index 00000000..5107a880 --- /dev/null +++ b/src/main/kotlin/team/incube/flooding/domain/dormitory/study/presentation/controller/PublicStudyController.kt @@ -0,0 +1,33 @@ +package team.incube.flooding.domain.dormitory.study.presentation.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import team.incube.flooding.domain.dormitory.study.presentation.data.response.GetStudyAttendanceListResponse +import team.incube.flooding.domain.dormitory.study.service.GetPublicStudyAttendanceListService +import team.themoment.sdk.response.CommonApiResponse + +@Tag(name = "자습(공개)", description = "인증 없이 조회 가능한 자습 관련 공개 API") +@RestController +@RequestMapping("public/study") +class PublicStudyController( + private val getPublicStudyAttendanceListService: GetPublicStudyAttendanceListService, +) { + @Operation( + summary = "최근 1주일 자습 출석자 목록 조회 (공개)", + description = + "인증 없이 오늘을 포함한 최근 7일간 날짜별 자습 출석자 이름 리스트를 조회합니다. " + + "학번, 유저 ID 등 개인 식별 정보는 포함되지 않으며 이름만 제공합니다. " + + "스트릭(연속 출석) 계산과 같은 부가 로직은 이 API를 사용하는 클라이언트(서드파티)가 직접 수행해야 합니다.", + ) + @ApiResponses( + ApiResponse(responseCode = "200", description = "조회 성공"), + ) + @GetMapping("/attendances") + fun getAttendances(): CommonApiResponse> = + CommonApiResponse.success("OK", getPublicStudyAttendanceListService.execute()) +} diff --git a/src/main/kotlin/team/incube/flooding/domain/dormitory/study/presentation/data/response/GetStudyAttendanceListResponse.kt b/src/main/kotlin/team/incube/flooding/domain/dormitory/study/presentation/data/response/GetStudyAttendanceListResponse.kt new file mode 100644 index 00000000..b88180e5 --- /dev/null +++ b/src/main/kotlin/team/incube/flooding/domain/dormitory/study/presentation/data/response/GetStudyAttendanceListResponse.kt @@ -0,0 +1,8 @@ +package team.incube.flooding.domain.dormitory.study.presentation.data.response + +import java.time.LocalDate + +data class GetStudyAttendanceListResponse( + val date: LocalDate, + val students: List, +) diff --git a/src/main/kotlin/team/incube/flooding/domain/dormitory/study/repository/StudyAttendanceHistoryJpaRepository.kt b/src/main/kotlin/team/incube/flooding/domain/dormitory/study/repository/StudyAttendanceHistoryJpaRepository.kt new file mode 100644 index 00000000..bd517a8a --- /dev/null +++ b/src/main/kotlin/team/incube/flooding/domain/dormitory/study/repository/StudyAttendanceHistoryJpaRepository.kt @@ -0,0 +1,28 @@ +package team.incube.flooding.domain.dormitory.study.repository + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import team.incube.flooding.domain.dormitory.study.entity.StudyAttendanceHistoryJpaEntity +import java.time.LocalDate + +interface StudyAttendanceHistoryJpaRepository : JpaRepository { + fun existsByUserIdAndAttendedDate( + userId: Long, + attendedDate: LocalDate, + ): Boolean + + fun deleteByUserIdAndAttendedDate( + userId: Long, + attendedDate: LocalDate, + ) + + @Query( + "SELECT h FROM StudyAttendanceHistoryJpaEntity h JOIN FETCH h.user " + + "WHERE h.attendedDate BETWEEN :startDate AND :endDate ORDER BY h.attendedDate ASC", + ) + fun findAllByAttendedDateBetweenOrderByAttendedDateAsc( + @Param("startDate") startDate: LocalDate, + @Param("endDate") endDate: LocalDate, + ): List +} diff --git a/src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/GetPublicStudyAttendanceListService.kt b/src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/GetPublicStudyAttendanceListService.kt new file mode 100644 index 00000000..906729e4 --- /dev/null +++ b/src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/GetPublicStudyAttendanceListService.kt @@ -0,0 +1,7 @@ +package team.incube.flooding.domain.dormitory.study.service + +import team.incube.flooding.domain.dormitory.study.presentation.data.response.GetStudyAttendanceListResponse + +interface GetPublicStudyAttendanceListService { + fun execute(): List +} diff --git a/src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/impl/CheckStudyAttendanceServiceImpl.kt b/src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/impl/CheckStudyAttendanceServiceImpl.kt index 7df32759..459040f3 100644 --- a/src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/impl/CheckStudyAttendanceServiceImpl.kt +++ b/src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/impl/CheckStudyAttendanceServiceImpl.kt @@ -7,17 +7,21 @@ import org.springframework.transaction.annotation.Transactional import team.incube.flooding.domain.dormitory.study.adapter.StudyAttendanceSseEmitterRegistry import team.incube.flooding.domain.dormitory.study.adapter.StudyRedisAdapter import team.incube.flooding.domain.dormitory.study.entity.StudyApplicationStatus +import team.incube.flooding.domain.dormitory.study.entity.StudyAttendanceHistoryJpaEntity import team.incube.flooding.domain.dormitory.study.presentation.data.response.StudyAttendanceEventResponse +import team.incube.flooding.domain.dormitory.study.repository.StudyAttendanceHistoryJpaRepository import team.incube.flooding.domain.dormitory.study.service.CheckStudyAttendanceService import team.incube.flooding.domain.user.repository.UserRepository import team.themoment.sdk.exception.ExpectedException +import java.time.LocalDate @Service -@Transactional(readOnly = true) +@Transactional class CheckStudyAttendanceServiceImpl( private val studyRedisAdapter: StudyRedisAdapter, private val userRepository: UserRepository, private val sseEmitterRegistry: StudyAttendanceSseEmitterRegistry, + private val studyAttendanceHistoryJpaRepository: StudyAttendanceHistoryJpaRepository, ) : CheckStudyAttendanceService { private val log = LoggerFactory.getLogger(javaClass) @@ -36,6 +40,12 @@ class CheckStudyAttendanceServiceImpl( } studyRedisAdapter.checkAttendance(userId) + val today = LocalDate.now() + if (!studyAttendanceHistoryJpaRepository.existsByUserIdAndAttendedDate(userId, today)) { + studyAttendanceHistoryJpaRepository.save( + StudyAttendanceHistoryJpaEntity(user = user, attendedDate = today), + ) + } log.info("checkAttendance Redis 반영 완료, broadcast 호출 직전: userId={}", userId) sseEmitterRegistry.broadcast( StudyAttendanceEventResponse(userId = user.id, name = user.name, studentNumber = user.studentNumber), diff --git a/src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/impl/GetPublicStudyAttendanceListServiceImpl.kt b/src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/impl/GetPublicStudyAttendanceListServiceImpl.kt new file mode 100644 index 00000000..e89efc66 --- /dev/null +++ b/src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/impl/GetPublicStudyAttendanceListServiceImpl.kt @@ -0,0 +1,26 @@ +package team.incube.flooding.domain.dormitory.study.service.impl + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import team.incube.flooding.domain.dormitory.study.presentation.data.response.GetStudyAttendanceListResponse +import team.incube.flooding.domain.dormitory.study.repository.StudyAttendanceHistoryJpaRepository +import team.incube.flooding.domain.dormitory.study.service.GetPublicStudyAttendanceListService +import java.time.LocalDate + +@Service +@Transactional(readOnly = true) +class GetPublicStudyAttendanceListServiceImpl( + private val studyAttendanceHistoryJpaRepository: StudyAttendanceHistoryJpaRepository, +) : GetPublicStudyAttendanceListService { + override fun execute(): List { + val endDate = LocalDate.now() + val startDate = endDate.minusDays(6) + val histories = + studyAttendanceHistoryJpaRepository.findAllByAttendedDateBetweenOrderByAttendedDateAsc(startDate, endDate) + val namesByDate = histories.groupBy({ it.attendedDate }, { it.user.name }) + return generateSequence(startDate) { it.plusDays(1) } + .takeWhile { !it.isAfter(endDate) } + .map { date -> GetStudyAttendanceListResponse(date = date, students = namesByDate[date] ?: emptyList()) } + .toList() + } +} diff --git a/src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/impl/UncheckStudyAttendanceServiceImpl.kt b/src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/impl/UncheckStudyAttendanceServiceImpl.kt index cc61fe94..59ef3555 100644 --- a/src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/impl/UncheckStudyAttendanceServiceImpl.kt +++ b/src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/impl/UncheckStudyAttendanceServiceImpl.kt @@ -7,9 +7,11 @@ import org.springframework.transaction.annotation.Transactional import team.incube.flooding.domain.dormitory.study.adapter.StudyAttendanceSseEmitterRegistry import team.incube.flooding.domain.dormitory.study.adapter.StudyRedisAdapter import team.incube.flooding.domain.dormitory.study.presentation.data.response.StudyAttendanceEventResponse +import team.incube.flooding.domain.dormitory.study.repository.StudyAttendanceHistoryJpaRepository import team.incube.flooding.domain.dormitory.study.service.UncheckStudyAttendanceService import team.incube.flooding.domain.user.repository.UserRepository import team.themoment.sdk.exception.ExpectedException +import java.time.LocalDate @Service @Transactional @@ -17,6 +19,7 @@ class UncheckStudyAttendanceServiceImpl( private val userRepository: UserRepository, private val studyRedisAdapter: StudyRedisAdapter, private val sseEmitterRegistry: StudyAttendanceSseEmitterRegistry, + private val studyAttendanceHistoryJpaRepository: StudyAttendanceHistoryJpaRepository, ) : UncheckStudyAttendanceService { private val log = LoggerFactory.getLogger(javaClass) @@ -31,6 +34,7 @@ class UncheckStudyAttendanceServiceImpl( } studyRedisAdapter.cancelAttendance(userId) + studyAttendanceHistoryJpaRepository.deleteByUserIdAndAttendedDate(userId, LocalDate.now()) log.info("cancelAttendance Redis 반영 완료, broadcastCancel 호출 직전: userId={}", userId) sseEmitterRegistry.broadcastCancel( StudyAttendanceEventResponse(userId = user.id, name = user.name, studentNumber = user.studentNumber), diff --git a/src/main/kotlin/team/incube/flooding/global/security/config/SecurityConfig.kt b/src/main/kotlin/team/incube/flooding/global/security/config/SecurityConfig.kt index c9144e5f..8e9aa12c 100644 --- a/src/main/kotlin/team/incube/flooding/global/security/config/SecurityConfig.kt +++ b/src/main/kotlin/team/incube/flooding/global/security/config/SecurityConfig.kt @@ -60,6 +60,7 @@ class SecurityConfig( it.requestMatchers("/error").permitAll() it.requestMatchers("/auth/signin", "/auth/reissue").permitAll() it.requestMatchers("/v3/api-docs/**", "/swagger-ui/**").permitAll() + it.requestMatchers("/public/**").permitAll() // ai it.requestMatchers(HttpMethod.POST, "/ai/chat").authenticated() it.requestMatchers(HttpMethod.POST, "/ai/song").authenticated() diff --git a/src/test/kotlin/team/incube/flooding/domain/dormitory/study/service/UncheckStudyAttendanceServiceTest.kt b/src/test/kotlin/team/incube/flooding/domain/dormitory/study/service/UncheckStudyAttendanceServiceTest.kt index 248beb05..282c1f76 100644 --- a/src/test/kotlin/team/incube/flooding/domain/dormitory/study/service/UncheckStudyAttendanceServiceTest.kt +++ b/src/test/kotlin/team/incube/flooding/domain/dormitory/study/service/UncheckStudyAttendanceServiceTest.kt @@ -12,12 +12,14 @@ import org.springframework.http.HttpStatus import team.incube.flooding.domain.dormitory.study.adapter.StudyAttendanceSseEmitterRegistry import team.incube.flooding.domain.dormitory.study.adapter.StudyRedisAdapter import team.incube.flooding.domain.dormitory.study.presentation.data.response.StudyAttendanceEventResponse +import team.incube.flooding.domain.dormitory.study.repository.StudyAttendanceHistoryJpaRepository import team.incube.flooding.domain.dormitory.study.service.impl.UncheckStudyAttendanceServiceImpl import team.incube.flooding.domain.user.entity.Role import team.incube.flooding.domain.user.entity.Sex import team.incube.flooding.domain.user.entity.UserJpaEntity import team.incube.flooding.domain.user.repository.UserRepository import team.themoment.sdk.exception.ExpectedException +import java.time.LocalDate import java.util.Optional class UncheckStudyAttendanceServiceTest : @@ -25,7 +27,14 @@ class UncheckStudyAttendanceServiceTest : val userRepository = mockk() val studyRedisAdapter = mockk() val sseEmitterRegistry = mockk() - val service = UncheckStudyAttendanceServiceImpl(userRepository, studyRedisAdapter, sseEmitterRegistry) + val studyAttendanceHistoryJpaRepository = mockk() + val service = + UncheckStudyAttendanceServiceImpl( + userRepository, + studyRedisAdapter, + sseEmitterRegistry, + studyAttendanceHistoryJpaRepository, + ) beforeEach { clearAllMocks() } @@ -74,6 +83,7 @@ class UncheckStudyAttendanceServiceTest : every { userRepository.findById(1L) } returns Optional.of(targetUser) every { studyRedisAdapter.isAttendanceChecked(1L) } returns true justRun { studyRedisAdapter.cancelAttendance(1L) } + justRun { studyAttendanceHistoryJpaRepository.deleteByUserIdAndAttendedDate(1L, LocalDate.now()) } justRun { sseEmitterRegistry.broadcastCancel( StudyAttendanceEventResponse( @@ -87,6 +97,9 @@ class UncheckStudyAttendanceServiceTest : service.execute(1L) verify(exactly = 1) { studyRedisAdapter.cancelAttendance(1L) } + verify(exactly = 1) { + studyAttendanceHistoryJpaRepository.deleteByUserIdAndAttendedDate(1L, LocalDate.now()) + } verify(exactly = 1) { sseEmitterRegistry.broadcastCancel( StudyAttendanceEventResponse( From a42d19d85ba3b765dfbeea5cac59a2b6c51442dd Mon Sep 17 00:00:00 2001 From: jyx-07 Date: Thu, 2 Jul 2026 08:13:53 +0900 Subject: [PATCH 2/2] =?UTF-8?q?update=20:=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20-=20KST=20Clock=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A6=AC=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EB=B0=8D/=EB=82=A0=EC=A7=9C=EB=B2=94=EC=9C=84=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ....kt => StudyAttendanceHistoryRepository.kt} | 2 +- .../impl/CheckStudyAttendanceServiceImpl.kt | 12 +++++++----- .../GetPublicStudyAttendanceListServiceImpl.kt | 18 ++++++++++-------- .../impl/UncheckStudyAttendanceServiceImpl.kt | 8 +++++--- .../UncheckStudyAttendanceServiceTest.kt | 17 ++++++++++++----- 5 files changed, 35 insertions(+), 22 deletions(-) rename src/main/kotlin/team/incube/flooding/domain/dormitory/study/repository/{StudyAttendanceHistoryJpaRepository.kt => StudyAttendanceHistoryRepository.kt} (90%) diff --git a/src/main/kotlin/team/incube/flooding/domain/dormitory/study/repository/StudyAttendanceHistoryJpaRepository.kt b/src/main/kotlin/team/incube/flooding/domain/dormitory/study/repository/StudyAttendanceHistoryRepository.kt similarity index 90% rename from src/main/kotlin/team/incube/flooding/domain/dormitory/study/repository/StudyAttendanceHistoryJpaRepository.kt rename to src/main/kotlin/team/incube/flooding/domain/dormitory/study/repository/StudyAttendanceHistoryRepository.kt index bd517a8a..7e42bcc7 100644 --- a/src/main/kotlin/team/incube/flooding/domain/dormitory/study/repository/StudyAttendanceHistoryJpaRepository.kt +++ b/src/main/kotlin/team/incube/flooding/domain/dormitory/study/repository/StudyAttendanceHistoryRepository.kt @@ -6,7 +6,7 @@ import org.springframework.data.repository.query.Param import team.incube.flooding.domain.dormitory.study.entity.StudyAttendanceHistoryJpaEntity import java.time.LocalDate -interface StudyAttendanceHistoryJpaRepository : JpaRepository { +interface StudyAttendanceHistoryRepository : JpaRepository { fun existsByUserIdAndAttendedDate( userId: Long, attendedDate: LocalDate, diff --git a/src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/impl/CheckStudyAttendanceServiceImpl.kt b/src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/impl/CheckStudyAttendanceServiceImpl.kt index 459040f3..0929868a 100644 --- a/src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/impl/CheckStudyAttendanceServiceImpl.kt +++ b/src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/impl/CheckStudyAttendanceServiceImpl.kt @@ -9,10 +9,11 @@ import team.incube.flooding.domain.dormitory.study.adapter.StudyRedisAdapter import team.incube.flooding.domain.dormitory.study.entity.StudyApplicationStatus import team.incube.flooding.domain.dormitory.study.entity.StudyAttendanceHistoryJpaEntity import team.incube.flooding.domain.dormitory.study.presentation.data.response.StudyAttendanceEventResponse -import team.incube.flooding.domain.dormitory.study.repository.StudyAttendanceHistoryJpaRepository +import team.incube.flooding.domain.dormitory.study.repository.StudyAttendanceHistoryRepository import team.incube.flooding.domain.dormitory.study.service.CheckStudyAttendanceService import team.incube.flooding.domain.user.repository.UserRepository import team.themoment.sdk.exception.ExpectedException +import java.time.Clock import java.time.LocalDate @Service @@ -21,7 +22,8 @@ class CheckStudyAttendanceServiceImpl( private val studyRedisAdapter: StudyRedisAdapter, private val userRepository: UserRepository, private val sseEmitterRegistry: StudyAttendanceSseEmitterRegistry, - private val studyAttendanceHistoryJpaRepository: StudyAttendanceHistoryJpaRepository, + private val studyAttendanceHistoryRepository: StudyAttendanceHistoryRepository, + private val clock: Clock, ) : CheckStudyAttendanceService { private val log = LoggerFactory.getLogger(javaClass) @@ -40,9 +42,9 @@ class CheckStudyAttendanceServiceImpl( } studyRedisAdapter.checkAttendance(userId) - val today = LocalDate.now() - if (!studyAttendanceHistoryJpaRepository.existsByUserIdAndAttendedDate(userId, today)) { - studyAttendanceHistoryJpaRepository.save( + val today = LocalDate.now(clock) + if (!studyAttendanceHistoryRepository.existsByUserIdAndAttendedDate(userId, today)) { + studyAttendanceHistoryRepository.save( StudyAttendanceHistoryJpaEntity(user = user, attendedDate = today), ) } diff --git a/src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/impl/GetPublicStudyAttendanceListServiceImpl.kt b/src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/impl/GetPublicStudyAttendanceListServiceImpl.kt index e89efc66..88f09949 100644 --- a/src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/impl/GetPublicStudyAttendanceListServiceImpl.kt +++ b/src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/impl/GetPublicStudyAttendanceListServiceImpl.kt @@ -3,24 +3,26 @@ package team.incube.flooding.domain.dormitory.study.service.impl import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import team.incube.flooding.domain.dormitory.study.presentation.data.response.GetStudyAttendanceListResponse -import team.incube.flooding.domain.dormitory.study.repository.StudyAttendanceHistoryJpaRepository +import team.incube.flooding.domain.dormitory.study.repository.StudyAttendanceHistoryRepository import team.incube.flooding.domain.dormitory.study.service.GetPublicStudyAttendanceListService +import java.time.Clock import java.time.LocalDate @Service @Transactional(readOnly = true) class GetPublicStudyAttendanceListServiceImpl( - private val studyAttendanceHistoryJpaRepository: StudyAttendanceHistoryJpaRepository, + private val studyAttendanceHistoryRepository: StudyAttendanceHistoryRepository, + private val clock: Clock, ) : GetPublicStudyAttendanceListService { override fun execute(): List { - val endDate = LocalDate.now() + val endDate = LocalDate.now(clock) val startDate = endDate.minusDays(6) val histories = - studyAttendanceHistoryJpaRepository.findAllByAttendedDateBetweenOrderByAttendedDateAsc(startDate, endDate) + studyAttendanceHistoryRepository.findAllByAttendedDateBetweenOrderByAttendedDateAsc(startDate, endDate) val namesByDate = histories.groupBy({ it.attendedDate }, { it.user.name }) - return generateSequence(startDate) { it.plusDays(1) } - .takeWhile { !it.isAfter(endDate) } - .map { date -> GetStudyAttendanceListResponse(date = date, students = namesByDate[date] ?: emptyList()) } - .toList() + return (0..6).map { i -> + val date = startDate.plusDays(i.toLong()) + GetStudyAttendanceListResponse(date = date, students = namesByDate[date] ?: emptyList()) + } } } diff --git a/src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/impl/UncheckStudyAttendanceServiceImpl.kt b/src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/impl/UncheckStudyAttendanceServiceImpl.kt index 59ef3555..8fb5c531 100644 --- a/src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/impl/UncheckStudyAttendanceServiceImpl.kt +++ b/src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/impl/UncheckStudyAttendanceServiceImpl.kt @@ -7,10 +7,11 @@ import org.springframework.transaction.annotation.Transactional import team.incube.flooding.domain.dormitory.study.adapter.StudyAttendanceSseEmitterRegistry import team.incube.flooding.domain.dormitory.study.adapter.StudyRedisAdapter import team.incube.flooding.domain.dormitory.study.presentation.data.response.StudyAttendanceEventResponse -import team.incube.flooding.domain.dormitory.study.repository.StudyAttendanceHistoryJpaRepository +import team.incube.flooding.domain.dormitory.study.repository.StudyAttendanceHistoryRepository import team.incube.flooding.domain.dormitory.study.service.UncheckStudyAttendanceService import team.incube.flooding.domain.user.repository.UserRepository import team.themoment.sdk.exception.ExpectedException +import java.time.Clock import java.time.LocalDate @Service @@ -19,7 +20,8 @@ class UncheckStudyAttendanceServiceImpl( private val userRepository: UserRepository, private val studyRedisAdapter: StudyRedisAdapter, private val sseEmitterRegistry: StudyAttendanceSseEmitterRegistry, - private val studyAttendanceHistoryJpaRepository: StudyAttendanceHistoryJpaRepository, + private val studyAttendanceHistoryRepository: StudyAttendanceHistoryRepository, + private val clock: Clock, ) : UncheckStudyAttendanceService { private val log = LoggerFactory.getLogger(javaClass) @@ -34,7 +36,7 @@ class UncheckStudyAttendanceServiceImpl( } studyRedisAdapter.cancelAttendance(userId) - studyAttendanceHistoryJpaRepository.deleteByUserIdAndAttendedDate(userId, LocalDate.now()) + studyAttendanceHistoryRepository.deleteByUserIdAndAttendedDate(userId, LocalDate.now(clock)) log.info("cancelAttendance Redis 반영 완료, broadcastCancel 호출 직전: userId={}", userId) sseEmitterRegistry.broadcastCancel( StudyAttendanceEventResponse(userId = user.id, name = user.name, studentNumber = user.studentNumber), diff --git a/src/test/kotlin/team/incube/flooding/domain/dormitory/study/service/UncheckStudyAttendanceServiceTest.kt b/src/test/kotlin/team/incube/flooding/domain/dormitory/study/service/UncheckStudyAttendanceServiceTest.kt index 282c1f76..4453202f 100644 --- a/src/test/kotlin/team/incube/flooding/domain/dormitory/study/service/UncheckStudyAttendanceServiceTest.kt +++ b/src/test/kotlin/team/incube/flooding/domain/dormitory/study/service/UncheckStudyAttendanceServiceTest.kt @@ -12,14 +12,17 @@ import org.springframework.http.HttpStatus import team.incube.flooding.domain.dormitory.study.adapter.StudyAttendanceSseEmitterRegistry import team.incube.flooding.domain.dormitory.study.adapter.StudyRedisAdapter import team.incube.flooding.domain.dormitory.study.presentation.data.response.StudyAttendanceEventResponse -import team.incube.flooding.domain.dormitory.study.repository.StudyAttendanceHistoryJpaRepository +import team.incube.flooding.domain.dormitory.study.repository.StudyAttendanceHistoryRepository import team.incube.flooding.domain.dormitory.study.service.impl.UncheckStudyAttendanceServiceImpl import team.incube.flooding.domain.user.entity.Role import team.incube.flooding.domain.user.entity.Sex import team.incube.flooding.domain.user.entity.UserJpaEntity import team.incube.flooding.domain.user.repository.UserRepository import team.themoment.sdk.exception.ExpectedException +import java.time.Clock +import java.time.Instant import java.time.LocalDate +import java.time.ZoneId import java.util.Optional class UncheckStudyAttendanceServiceTest : @@ -27,13 +30,15 @@ class UncheckStudyAttendanceServiceTest : val userRepository = mockk() val studyRedisAdapter = mockk() val sseEmitterRegistry = mockk() - val studyAttendanceHistoryJpaRepository = mockk() + val studyAttendanceHistoryRepository = mockk() + val clock = Clock.fixed(Instant.parse("2026-04-29T12:00:00Z"), ZoneId.of("Asia/Seoul")) val service = UncheckStudyAttendanceServiceImpl( userRepository, studyRedisAdapter, sseEmitterRegistry, - studyAttendanceHistoryJpaRepository, + studyAttendanceHistoryRepository, + clock, ) beforeEach { clearAllMocks() } @@ -83,7 +88,9 @@ class UncheckStudyAttendanceServiceTest : every { userRepository.findById(1L) } returns Optional.of(targetUser) every { studyRedisAdapter.isAttendanceChecked(1L) } returns true justRun { studyRedisAdapter.cancelAttendance(1L) } - justRun { studyAttendanceHistoryJpaRepository.deleteByUserIdAndAttendedDate(1L, LocalDate.now()) } + justRun { + studyAttendanceHistoryRepository.deleteByUserIdAndAttendedDate(1L, LocalDate.now(clock)) + } justRun { sseEmitterRegistry.broadcastCancel( StudyAttendanceEventResponse( @@ -98,7 +105,7 @@ class UncheckStudyAttendanceServiceTest : verify(exactly = 1) { studyRedisAdapter.cancelAttendance(1L) } verify(exactly = 1) { - studyAttendanceHistoryJpaRepository.deleteByUserIdAndAttendedDate(1L, LocalDate.now()) + studyAttendanceHistoryRepository.deleteByUserIdAndAttendedDate(1L, LocalDate.now(clock)) } verify(exactly = 1) { sseEmitterRegistry.broadcastCancel(