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())
}
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,
)
Comment on lines +15 to +18

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Spring Data JPA의 기본 deleteBy... 메서드는 대상을 먼저 조회(SELECT)한 후 개별적으로 삭제(DELETE)를 수행하므로 성능상 비효율적입니다. 단일 쿼리로 즉시 삭제할 수 있도록 @Modifying@Query를 사용한 벌크 삭제 쿼리로 변경하는 것을 권장합니다.

    @Modifying
    @Query("DELETE FROM StudyAttendanceHistoryJpaEntity h WHERE h.user.id = :userId AND h.attendedDate = :attendedDate")
    fun deleteByUserIdAndAttendedDate(
        @Param("userId") userId: Long,
        @Param("attendedDate") attendedDate: LocalDate,
    )
References
  1. Prefer bulk delete operations over individual entity deletions when cleaning up related data to avoid performance overhead.


@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),
)
}
Comment on lines 44 to +50

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

데이터베이스 저장(studyAttendanceHistoryRepository.save) 중 예외(예: 동시 요청으로 인한 Unique Constraint 위반 등)가 발생하면 트랜잭션이 롤백되지만, 이미 실행된 Redis 작업(studyRedisAdapter.checkAttendance)은 롤백되지 않아 데이터 일관성이 깨질 수 있습니다.

안전한 트랜잭션 처리를 위해 데이터베이스 쓰기 작업을 Redis 상태 변경 및 SSE 브로드캐스트보다 먼저 수행하도록 순서를 변경하는 것을 권장합니다.

Suggested change
studyRedisAdapter.checkAttendance(userId)
val today = LocalDate.now(clock)
if (!studyAttendanceHistoryRepository.existsByUserIdAndAttendedDate(userId, today)) {
studyAttendanceHistoryRepository.save(
StudyAttendanceHistoryJpaEntity(user = user, attendedDate = today),
)
}
val today = LocalDate.now(clock)
if (!studyAttendanceHistoryRepository.existsByUserIdAndAttendedDate(userId, today)) {
studyAttendanceHistoryRepository.save(
StudyAttendanceHistoryJpaEntity(user = user, attendedDate = today),
)
}
studyRedisAdapter.checkAttendance(userId)

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))
Comment on lines 38 to +39

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

데이터베이스 삭제 작업 중 예외가 발생할 경우 트랜잭션은 롤백되지만, 이미 실행된 Redis 작업(studyRedisAdapter.cancelAttendance)은 롤백되지 않아 상태 불일치가 발생할 수 있습니다.

데이터베이스 작업을 먼저 수행한 후 Redis 상태를 변경하도록 순서를 변경하는 것을 권장합니다.

Suggested change
studyRedisAdapter.cancelAttendance(userId)
studyAttendanceHistoryRepository.deleteByUserIdAndAttendedDate(userId, LocalDate.now(clock))
studyAttendanceHistoryRepository.deleteByUserIdAndAttendedDate(userId, LocalDate.now(clock))
studyRedisAdapter.cancelAttendance(userId)

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
@@ -1,24 +1,26 @@
package team.incube.flooding.domain.neis.client

import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.client.ResourceAccessException
import org.springframework.web.client.RestClient
import org.springframework.web.client.RestClientResponseException
import team.incube.flooding.domain.neis.client.dto.GetTimetablesRequest
import team.incube.flooding.domain.neis.config.NeisTimetableProperties
import team.incube.flooding.domain.neis.config.DgTimetableProperties
import team.themoment.sdk.exception.ExpectedException
import tools.jackson.databind.JsonNode

@Component
class NeisTimetableClient(
private val neisTimetableProperties: NeisTimetableProperties,
private val dgTimetableProperties: DgTimetableProperties,
restClientBuilder: RestClient.Builder,
@Value("\${datagsm.open-api-key}") private val apiKey: String,
) {
private val restClient =
restClientBuilder
.clone()
.baseUrl(neisTimetableProperties.baseUrl)
.baseUrl(dgTimetableProperties.baseUrl)
.build()

fun getTimetables(request: GetTimetablesRequest): JsonNode =
Expand All @@ -27,28 +29,23 @@ class NeisTimetableClient(
.get()
.uri { builder ->
builder
.path(neisTimetableProperties.path)
.queryParam("KEY", neisTimetableProperties.apiKey)
.queryParam("Type", neisTimetableProperties.dataType)
.queryParam("pIndex", 1)
.queryParam("pSize", neisTimetableProperties.pageSize)
.queryParam("ATPT_OFCDC_SC_CODE", request.officeCode)
.queryParam("SD_SCHUL_CODE", request.schoolCode)
.queryParam("GRADE", request.grade)
.queryParam("CLASS_NM", request.classNumber)
.queryParam("ALL_TI_YMD", request.date.replace("-", ""))
.path(dgTimetableProperties.path)
.queryParam("grade", request.grade)
.queryParam("classNum", request.classNumber)
.queryParam("date", request.date)
.build()
}.retrieve()
}.header("X-API-KEY", apiKey)
.retrieve()
.body(JsonNode::class.java)
?: throw ExpectedException("NEIS 시간표 응답이 비어 있습니다.", HttpStatus.INTERNAL_SERVER_ERROR)
?: throw ExpectedException("DG 시간표 응답이 비어 있습니다.", HttpStatus.INTERNAL_SERVER_ERROR)
} catch (exception: RestClientResponseException) {
throw ExpectedException(
"NEIS 시간표 호출에 실패했습니다. status=${exception.statusCode.value()}",
"DG 시간표 호출에 실패했습니다. status=${exception.statusCode.value()}",
HttpStatus.INTERNAL_SERVER_ERROR,
)
} catch (exception: ResourceAccessException) {
throw ExpectedException(
"NEIS 시간표 서버에 연결할 수 없습니다.",
"DG 시간표 서버에 연결할 수 없습니다.",
HttpStatus.INTERNAL_SERVER_ERROR,
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package team.incube.flooding.domain.neis.client.dto

data class GetTimetablesRequest(
val officeCode: String,
val schoolCode: String,
val grade: Int,
val classNumber: Int,
val date: String,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package team.incube.flooding.domain.neis.config

import org.springframework.boot.context.properties.ConfigurationProperties

@ConfigurationProperties(prefix = "datagsm.neis.timetables")
data class DgTimetableProperties(
val baseUrl: String,
val path: String = "v1/neis/timetables",
)

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,12 @@ class NeisController(
),
)

@Operation(summary = "시간표 조회", description = "NEIS 원본 API를 사용해 시간표를 조회합니다.")
@Operation(summary = "시간표 조회", description = "DataGSM API를 사용해 시간표를 조회합니다.")
@ApiResponses(
ApiResponse(responseCode = "200", description = "시간표 조회 성공"),
)
@GetMapping("/timetables")
fun getTimetables(
@Parameter(description = "시도교육청 코드")
@RequestParam officeCode: String,
@Parameter(description = "학교 코드")
@RequestParam schoolCode: String,
@Parameter(description = "학년")
@RequestParam
@Min(value = 1, message = "grade는 1 이상이어야 합니다.")
Expand All @@ -74,8 +70,6 @@ class NeisController(
"OK",
getNeisTimetablesService.execute(
GetNeisTimetablesRequest(
officeCode = officeCode,
schoolCode = schoolCode,
grade = grade,
classNumber = classNumber,
date = date,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package team.incube.flooding.domain.neis.presentation.data.request

data class GetNeisTimetablesRequest(
val officeCode: String,
val schoolCode: String,
val grade: Int,
val classNumber: Int,
val date: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ class GetNeisTimetablesServiceImpl(
val response =
neisTimetableClient.getTimetables(
GetTimetablesRequest(
officeCode = request.officeCode,
schoolCode = request.schoolCode,
grade = request.grade,
classNumber = request.classNumber,
date = request.date,
Expand Down Expand Up @@ -66,7 +64,7 @@ class GetNeisTimetablesServiceImpl(
period = periodNumbers.firstOrNull() ?: (idx + 1),
subject = valueOf(periodNode, "ITRT_CNTNT", "subject") ?: "미정",
teacher = valueOf(periodNode, "TEACHER_NM", "teacher"),
classroom = valueOf(periodNode, "CLRM_NM", "CLASSROOM", "classroom"),
classroom = valueOf(periodNode, "CLRM_NM", "CLASSROOM", "classroom", "classroomName"),
)

periodNumbers.forEach { periodNum ->
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
10 changes: 3 additions & 7 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,20 +85,16 @@ oauth:
client-id: ${OAUTH_CLIENT_ID}
client-secret: ${OAUTH_CLIENT_SECRET}

neis:
openapi:
timetable:
baseUrl: ${NEIS_OPENAPI_TIMETABLE_BASE_URL:https://open.neis.go.kr/}
apiKey: ${NEIS_TIMETABLE_API_KEY}
path: "hub/hisTimetable"

datagsm:
base-url: ${DATAGSM_BASE_URL:https://api.datagsm.com}
open-api-key: ${DATAGSM_OPEN_API_KEY}
neis:
meals:
baseUrl: ${DATAGSM_NEIS_MEALS_BASE_URL:https://openapi.datagsm.kr/}
path: "v1/neis/meals"
timetables:
baseUrl: ${DATAGSM_NEIS_TIMETABLES_BASE_URL:https://openapi.datagsm.kr/}
path: "v1/neis/timetables"

ai:
chatbot:
Expand Down
Loading
Loading