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/StudyAttendanceHistoryRepository.kt b/src/main/kotlin/team/incube/flooding/domain/dormitory/study/repository/StudyAttendanceHistoryRepository.kt new file mode 100644 index 00000000..7e42bcc7 --- /dev/null +++ b/src/main/kotlin/team/incube/flooding/domain/dormitory/study/repository/StudyAttendanceHistoryRepository.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 StudyAttendanceHistoryRepository : 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..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 @@ -7,17 +7,23 @@ 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.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 -@Transactional(readOnly = true) +@Transactional class CheckStudyAttendanceServiceImpl( private val studyRedisAdapter: StudyRedisAdapter, private val userRepository: UserRepository, private val sseEmitterRegistry: StudyAttendanceSseEmitterRegistry, + private val studyAttendanceHistoryRepository: StudyAttendanceHistoryRepository, + private val clock: Clock, ) : CheckStudyAttendanceService { private val log = LoggerFactory.getLogger(javaClass) @@ -36,6 +42,12 @@ class CheckStudyAttendanceServiceImpl( } studyRedisAdapter.checkAttendance(userId) + val today = LocalDate.now(clock) + if (!studyAttendanceHistoryRepository.existsByUserIdAndAttendedDate(userId, today)) { + studyAttendanceHistoryRepository.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..88f09949 --- /dev/null +++ b/src/main/kotlin/team/incube/flooding/domain/dormitory/study/service/impl/GetPublicStudyAttendanceListServiceImpl.kt @@ -0,0 +1,28 @@ +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.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 studyAttendanceHistoryRepository: StudyAttendanceHistoryRepository, + private val clock: Clock, +) : GetPublicStudyAttendanceListService { + override fun execute(): List { + val endDate = LocalDate.now(clock) + val startDate = endDate.minusDays(6) + val histories = + studyAttendanceHistoryRepository.findAllByAttendedDateBetweenOrderByAttendedDateAsc(startDate, endDate) + val namesByDate = histories.groupBy({ it.attendedDate }, { it.user.name }) + 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 cc61fe94..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,9 +7,12 @@ 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.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 @Transactional @@ -17,6 +20,8 @@ class UncheckStudyAttendanceServiceImpl( private val userRepository: UserRepository, private val studyRedisAdapter: StudyRedisAdapter, private val sseEmitterRegistry: StudyAttendanceSseEmitterRegistry, + private val studyAttendanceHistoryRepository: StudyAttendanceHistoryRepository, + private val clock: Clock, ) : UncheckStudyAttendanceService { private val log = LoggerFactory.getLogger(javaClass) @@ -31,6 +36,7 @@ class UncheckStudyAttendanceServiceImpl( } studyRedisAdapter.cancelAttendance(userId) + 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/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..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,12 +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.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 : @@ -25,7 +30,16 @@ class UncheckStudyAttendanceServiceTest : val userRepository = mockk() val studyRedisAdapter = mockk() val sseEmitterRegistry = mockk() - val service = UncheckStudyAttendanceServiceImpl(userRepository, studyRedisAdapter, sseEmitterRegistry) + val studyAttendanceHistoryRepository = mockk() + val clock = Clock.fixed(Instant.parse("2026-04-29T12:00:00Z"), ZoneId.of("Asia/Seoul")) + val service = + UncheckStudyAttendanceServiceImpl( + userRepository, + studyRedisAdapter, + sseEmitterRegistry, + studyAttendanceHistoryRepository, + clock, + ) beforeEach { clearAllMocks() } @@ -74,6 +88,9 @@ class UncheckStudyAttendanceServiceTest : every { userRepository.findById(1L) } returns Optional.of(targetUser) every { studyRedisAdapter.isAttendanceChecked(1L) } returns true justRun { studyRedisAdapter.cancelAttendance(1L) } + justRun { + studyAttendanceHistoryRepository.deleteByUserIdAndAttendedDate(1L, LocalDate.now(clock)) + } justRun { sseEmitterRegistry.broadcastCancel( StudyAttendanceEventResponse( @@ -87,6 +104,9 @@ class UncheckStudyAttendanceServiceTest : service.execute(1L) verify(exactly = 1) { studyRedisAdapter.cancelAttendance(1L) } + verify(exactly = 1) { + studyAttendanceHistoryRepository.deleteByUserIdAndAttendedDate(1L, LocalDate.now(clock)) + } verify(exactly = 1) { sseEmitterRegistry.broadcastCancel( StudyAttendanceEventResponse(