Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,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,
)
Original file line number Diff line number Diff line change
@@ -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<List<GetStudyAttendanceListResponse>> =
CommonApiResponse.success("OK", getPublicStudyAttendanceListService.execute())
Comment thread
jyx-07 marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -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<String>,
)
Original file line number Diff line number Diff line change
@@ -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<StudyAttendanceHistoryJpaEntity, Long> {
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<StudyAttendanceHistoryJpaEntity>
}
Original file line number Diff line number Diff line change
@@ -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<GetStudyAttendanceListResponse>
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<GetStudyAttendanceListResponse> {
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())
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +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.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
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)

Expand All @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,34 @@ 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 :
BehaviorSpec({
val userRepository = mockk<UserRepository>()
val studyRedisAdapter = mockk<StudyRedisAdapter>()
val sseEmitterRegistry = mockk<StudyAttendanceSseEmitterRegistry>()
val service = UncheckStudyAttendanceServiceImpl(userRepository, studyRedisAdapter, sseEmitterRegistry)
val studyAttendanceHistoryRepository = mockk<StudyAttendanceHistoryRepository>()
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() }

Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
Loading