From 11d63dd00190adfcb600d03759e9b98d0a980b50 Mon Sep 17 00:00:00 2001 From: Kimjiwon Date: Tue, 25 Aug 2026 18:17:48 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20EventRepository/SessionRepository?= =?UTF-8?q?=EC=97=90=20=EA=B8=B0=EC=88=98=EB=B3=84=20=EC=9B=94=EB=B3=84=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=BF=BC=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/domain/repository/EventReader.kt | 7 +++++++ .../domain/repository/EventRepository.kt | 18 ++++++++++++++++++ .../session/domain/repository/SessionReader.kt | 7 +++++++ .../domain/repository/SessionRepository.kt | 10 ++++++++++ 4 files changed, 42 insertions(+) diff --git a/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventReader.kt b/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventReader.kt index e7fe161f..3c90c460 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventReader.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventReader.kt @@ -15,5 +15,12 @@ interface EventReader { end: LocalDateTime, ): List + fun findByClubIdAndCardinalAndDateRange( + clubId: Long, + cardinal: Int, + start: LocalDateTime, + end: LocalDateTime, + ): List + fun findAllByCardinal(cardinal: Int): List } diff --git a/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventRepository.kt b/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventRepository.kt index f9ba7014..25de2eab 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventRepository.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventRepository.kt @@ -40,4 +40,22 @@ interface EventRepository : @Param("end") end: LocalDateTime, @Param("start") start: LocalDateTime, ): List + + @Query( + "SELECT e FROM Event e WHERE e.club.id = :clubId AND e.cardinal = :cardinal AND e.start <= :end AND e.end >= :start ORDER BY e.start ASC", + ) + fun findByClubIdAndCardinalAndStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc( + @Param("clubId") clubId: Long, + @Param("cardinal") cardinal: Int, + @Param("end") end: LocalDateTime, + @Param("start") start: LocalDateTime, + ): List + + override fun findByClubIdAndCardinalAndDateRange( + clubId: Long, + cardinal: Int, + start: LocalDateTime, + end: LocalDateTime, + ): List = + findByClubIdAndCardinalAndStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(clubId, cardinal, end, start) } diff --git a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt index b270b389..c1197959 100644 --- a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt +++ b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt @@ -28,6 +28,13 @@ interface SessionReader { end: LocalDateTime, ): List + fun findAllByClubIdAndCardinalAndStartBetween( + clubId: Long, + cardinal: Int, + start: LocalDateTime, + end: LocalDateTime, + ): List + fun findAllByClubIdAndCardinalIn( clubId: Long, cardinals: List, diff --git a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt index 22796d31..5cb5c582 100644 --- a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt +++ b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt @@ -67,6 +67,16 @@ interface SessionRepository : end: LocalDateTime, ): List = findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(end, start) + @Query( + "SELECT s FROM Session s WHERE s.club.id = :clubId AND s.cardinal = :cardinal AND s.start <= :end AND s.end >= :start ORDER BY s.start ASC", + ) + override fun findAllByClubIdAndCardinalAndStartBetween( + @Param("clubId") clubId: Long, + @Param("cardinal") cardinal: Int, + @Param("start") start: LocalDateTime, + @Param("end") end: LocalDateTime, + ): List + override fun findAllByClubIdAndCardinalIn( clubId: Long, cardinals: List, From 37ee6bbe253c7e375090ffc95435710e095b91b3 Mon Sep 17 00:00:00 2001 From: Kimjiwon Date: Tue, 25 Aug 2026 18:21:27 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=EC=9D=BC=EC=A0=95=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EC=9D=91=EB=8B=B5=20DTO=20?= =?UTF-8?q?=EB=B0=8F=20AttendanceReader=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/repository/AttendanceReader.kt | 8 +++++ .../domain/repository/AttendanceRepository.kt | 10 +++++- .../dto/response/AttendeeResponse.kt | 15 +++++++++ .../dto/response/ScheduleAttendanceStatus.kt | 8 +++++ .../dto/response/ScheduleDetailResponse.kt | 32 +++++++++++++++++++ 5 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/com/weeth/domain/schedule/application/dto/response/AttendeeResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleAttendanceStatus.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleDetailResponse.kt diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceReader.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceReader.kt index 9f773813..feee4292 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceReader.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceReader.kt @@ -2,6 +2,7 @@ package com.weeth.domain.attendance.domain.repository import com.weeth.domain.attendance.domain.entity.Attendance import com.weeth.domain.attendance.domain.enums.AttendanceStatus +import com.weeth.domain.session.domain.entity.Session import org.springframework.data.domain.Pageable import org.springframework.data.domain.Slice @@ -17,4 +18,11 @@ interface AttendanceReader { status: AttendanceStatus, pageable: Pageable, ): Slice + + fun findAllBySession(session: Session): List + + fun findBySessionAndUserId( + session: Session, + userId: Long, + ): Attendance? } diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt index 3e2754fa..7c1e017d 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt @@ -39,7 +39,15 @@ interface AttendanceRepository : ): List @EntityGraph(attributePaths = ["clubMember", "clubMember.user"]) - fun findAllBySession(session: Session): List + override fun findAllBySession(session: Session): List + + @Query( + "SELECT a FROM Attendance a JOIN FETCH a.clubMember cm JOIN FETCH cm.user WHERE a.session = :session AND cm.user.id = :userId", + ) + override fun findBySessionAndUserId( + @Param("session") session: Session, + @Param("userId") userId: Long, + ): Attendance? @Query( """ diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/AttendeeResponse.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/AttendeeResponse.kt new file mode 100644 index 00000000..4049429d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/AttendeeResponse.kt @@ -0,0 +1,15 @@ +package com.weeth.domain.schedule.application.dto.response + +import com.weeth.domain.club.domain.enums.MemberRole +import io.swagger.v3.oas.annotations.media.Schema + +data class AttendeeResponse( + @field:Schema(description = "이름", example = "홍길동") + val name: String, + @field:Schema(description = "학과", example = "컴퓨터공학과") + val department: String?, + @field:Schema(description = "직급", example = "USER") + val role: MemberRole, + @field:Schema(description = "프로필 이미지 URL") + val profileImageUrl: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleAttendanceStatus.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleAttendanceStatus.kt new file mode 100644 index 00000000..7a935f01 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleAttendanceStatus.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.schedule.application.dto.response + +enum class ScheduleAttendanceStatus { + UPCOMING, // 출석 예정 + OPEN, // 지금 출석 가능 + COMPLETED, // 출석 완료 + ABSENT, // 결석 +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleDetailResponse.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleDetailResponse.kt new file mode 100644 index 00000000..d4a820f9 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleDetailResponse.kt @@ -0,0 +1,32 @@ +package com.weeth.domain.schedule.application.dto.response + +import com.weeth.domain.schedule.domain.enums.Type +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class ScheduleDetailResponse( + @field:Schema(description = "일정 ID", example = "1") + val id: Long, + @field:Schema(description = "일정 유형", example = "SESSION") + val type: Type, + @field:Schema(description = "제목", example = "1주차 정기모임") + val title: String, + @field:Schema(description = "설명") + val description: String?, + @field:Schema(description = "장소", example = "가천대 체육관") + val location: String?, + @field:Schema(description = "시작 시간") + val start: LocalDateTime, + @field:Schema(description = "종료 시간") + val end: LocalDateTime, + @field:Schema(description = "생성자 이름", example = "홍길동") + val creatorName: String?, + @field:Schema(description = "내 출석 상태 (SESSION만, EVENT는 null)") + val myAttendanceStatus: ScheduleAttendanceStatus?, + @field:Schema(description = "출석 완료 시간 (COMPLETED일 때만, 나머지는 null)") + val attendedAt: LocalDateTime?, + @field:Schema(description = "총 참석자 수 (SESSION만, EVENT는 null)") + val totalAttendees: Int?, + @field:Schema(description = "참석자 목록 (SESSION만, EVENT는 null)") + val attendees: List?, +) From 7b9b082abbf409f64ad7590562fa0bc5ed0e9d5b Mon Sep 17 00:00:00 2001 From: Kimjiwon Date: Tue, 25 Aug 2026 18:21:36 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=EC=9B=94=EB=B3=84/=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=9D=BC=EC=A0=95=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/mapper/ScheduleMapper.kt | 55 +++++++++++++- .../usecase/query/GetScheduleQueryService.kt | 72 ++++++++++++++++++- .../presentation/ScheduleController.kt | 22 +++++- .../presentation/ScheduleResponseCode.kt | 1 + 4 files changed, 145 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/mapper/ScheduleMapper.kt b/src/main/kotlin/com/weeth/domain/schedule/application/mapper/ScheduleMapper.kt index 6d8ad1d4..d006fb6a 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/mapper/ScheduleMapper.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/mapper/ScheduleMapper.kt @@ -1,13 +1,21 @@ package com.weeth.domain.schedule.application.mapper +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import com.weeth.domain.schedule.application.dto.response.AttendeeResponse +import com.weeth.domain.schedule.application.dto.response.ScheduleAttendanceStatus +import com.weeth.domain.schedule.application.dto.response.ScheduleDetailResponse import com.weeth.domain.schedule.application.dto.response.ScheduleResponse import com.weeth.domain.schedule.domain.entity.Event import com.weeth.domain.schedule.domain.enums.Type import com.weeth.domain.session.domain.entity.Session import org.springframework.stereotype.Component +import java.time.LocalDateTime @Component -class ScheduleMapper { +class ScheduleMapper( + private val fileAccessUrlPort: FileAccessUrlPort, +) { fun toResponse(event: Event): ScheduleResponse = ScheduleResponse( id = event.id, @@ -29,4 +37,49 @@ class ScheduleMapper { location = session.location, cardinal = session.cardinal, ) + + fun toDetailResponse(event: Event): ScheduleDetailResponse = + ScheduleDetailResponse( + id = event.id, + type = Type.EVENT, + title = event.title, + description = event.content, + location = event.location, + start = event.start, + end = event.end, + creatorName = event.user?.name, + myAttendanceStatus = null, + attendedAt = null, + totalAttendees = null, + attendees = null, + ) + + fun toDetailResponse( + session: Session, + attendances: List, + myAttendanceStatus: ScheduleAttendanceStatus, + attendedAt: LocalDateTime?, + ): ScheduleDetailResponse = + ScheduleDetailResponse( + id = session.id, + type = Type.SESSION, + title = session.title, + description = session.content, + location = session.location, + start = session.start, + end = session.end, + creatorName = session.user?.name, + myAttendanceStatus = myAttendanceStatus, + attendedAt = attendedAt, + totalAttendees = attendances.size, + attendees = attendances.map { toAttendeeResponse(it) }, + ) + + private fun toAttendeeResponse(attendance: Attendance): AttendeeResponse = + AttendeeResponse( + name = attendance.clubMember.user.name, + department = attendance.clubMember.user.department, + role = attendance.clubMember.memberRole, + profileImageUrl = attendance.clubMember.profileImageStorageKey?.let { fileAccessUrlPort.resolve(it) }, + ) } diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt index 2725bd43..566e4ab1 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt @@ -1,12 +1,21 @@ package com.weeth.domain.schedule.application.usecase.query +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.enums.AttendanceStatus +import com.weeth.domain.attendance.domain.repository.AttendanceReader import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.schedule.application.dto.response.EventResponse +import com.weeth.domain.schedule.application.dto.response.ScheduleAttendanceStatus +import com.weeth.domain.schedule.application.dto.response.ScheduleDetailResponse import com.weeth.domain.schedule.application.dto.response.ScheduleResponse import com.weeth.domain.schedule.application.exception.EventNotFoundException import com.weeth.domain.schedule.application.mapper.EventMapper import com.weeth.domain.schedule.application.mapper.ScheduleMapper +import com.weeth.domain.schedule.domain.enums.Type import com.weeth.domain.schedule.domain.repository.EventRepository +import com.weeth.domain.session.application.exception.SessionNotFoundException +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.session.domain.enums.SessionStatus import com.weeth.domain.session.domain.repository.SessionReader import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service @@ -18,6 +27,7 @@ import java.time.LocalDateTime class GetScheduleQueryService( private val eventRepository: EventRepository, private val sessionReader: SessionReader, + private val attendanceReader: AttendanceReader, private val clubMemberPolicy: ClubMemberPolicy, private val scheduleMapper: ScheduleMapper, private val eventMapper: EventMapper, @@ -38,6 +48,7 @@ class GetScheduleQueryService( fun findMonthly( clubId: Long, userId: Long, + cardinal: Int, start: LocalDateTime, end: LocalDateTime, ): List { @@ -45,17 +56,74 @@ class GetScheduleQueryService( val events = eventRepository - .findByClubIdAndStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(clubId, end, start) + .findByClubIdAndCardinalAndDateRange(clubId, cardinal, start, end) .map { scheduleMapper.toResponse(it) } val sessions = sessionReader - .findAllByClubIdAndStartBetween(clubId, start, end) + .findAllByClubIdAndCardinalAndStartBetween(clubId, cardinal, start, end) .map { scheduleMapper.toResponse(it) } return (events + sessions).sortedBy { it.start } } + fun findDetail( + clubId: Long, + userId: Long, + id: Long, + type: Type, + ): ScheduleDetailResponse { + clubMemberPolicy.getActiveMember(clubId, userId) + return when (type) { + Type.EVENT -> { + val event = eventRepository.findByIdOrNull(id) ?: throw EventNotFoundException() + if (event.club.id != clubId) throw EventNotFoundException() + scheduleMapper.toDetailResponse(event) + } + + Type.SESSION -> { + val session = sessionReader.getById(id) + if (session.club.id != clubId) throw SessionNotFoundException() + val allAttendances = attendanceReader.findAllBySession(session) + val myAttendance = attendanceReader.findBySessionAndUserId(session, userId) + val myStatus = deriveAttendanceStatus(myAttendance, session) + val attendedAt = myAttendance?.modifiedAt?.takeIf { myStatus == ScheduleAttendanceStatus.COMPLETED } + scheduleMapper.toDetailResponse(session, allAttendances, myStatus, attendedAt) + } + } + } + + private fun deriveAttendanceStatus( + attendance: Attendance?, + session: Session, + ): ScheduleAttendanceStatus { + val now = LocalDateTime.now() + if (attendance == null) { + return when { + now.isBefore(session.start) -> ScheduleAttendanceStatus.UPCOMING + session.status == SessionStatus.OPEN && !now.isAfter(session.end) -> ScheduleAttendanceStatus.OPEN + else -> ScheduleAttendanceStatus.ABSENT + } + } + return when (attendance.status) { + AttendanceStatus.ATTEND -> { + ScheduleAttendanceStatus.COMPLETED + } + + AttendanceStatus.ABSENT -> { + ScheduleAttendanceStatus.ABSENT + } + + AttendanceStatus.PENDING -> { + val isOpen = + session.status == SessionStatus.OPEN && + !now.isBefore(session.start) && + !now.isAfter(session.end) + if (isOpen) ScheduleAttendanceStatus.OPEN else ScheduleAttendanceStatus.UPCOMING + } + } + } + fun findYearly( clubId: Long, userId: Long, diff --git a/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt index 150939d4..0ce377a8 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt @@ -1,7 +1,9 @@ package com.weeth.domain.schedule.presentation +import com.weeth.domain.schedule.application.dto.response.ScheduleDetailResponse import com.weeth.domain.schedule.application.dto.response.ScheduleResponse import com.weeth.domain.schedule.application.usecase.query.GetScheduleQueryService +import com.weeth.domain.schedule.domain.enums.Type import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.common.response.CommonResponse import com.weeth.global.common.web.TsidParam @@ -11,6 +13,7 @@ import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.format.annotation.DateTimeFormat import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @@ -28,16 +31,31 @@ class ScheduleController( @TsidParam @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, + @RequestParam cardinal: Int, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) start: LocalDateTime, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) end: LocalDateTime, ): CommonResponse> = CommonResponse.success( ScheduleResponseCode.SCHEDULE_MONTHLY_FIND_SUCCESS, - getScheduleQueryService.findMonthly(clubId, userId, start, end), + getScheduleQueryService.findMonthly(clubId, userId, cardinal, start, end), + ) + + @GetMapping("/{id}") + @Operation(summary = "일정 상세 조회") + fun findDetail( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + @PathVariable id: Long, + @RequestParam type: Type, + ): CommonResponse = + CommonResponse.success( + ScheduleResponseCode.SCHEDULE_DETAIL_FIND_SUCCESS, + getScheduleQueryService.findDetail(clubId, userId, id, type), ) @GetMapping("/yearly") - @Operation(summary = "연도별 일정 조회") + @Operation(summary = "연도별 일정 조회", hidden = true) fun findByYearly( @TsidParam @TsidPathVariable clubId: Long, diff --git a/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleResponseCode.kt b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleResponseCode.kt index cb7d5c5f..8db77de8 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleResponseCode.kt @@ -14,4 +14,5 @@ enum class ScheduleResponseCode( EVENT_FIND_SUCCESS(10803, HttpStatus.OK, "일정이 성공적으로 조회되었습니다."), SCHEDULE_MONTHLY_FIND_SUCCESS(10804, HttpStatus.OK, "월별 일정이 성공적으로 조회되었습니다."), SCHEDULE_YEARLY_FIND_SUCCESS(10805, HttpStatus.OK, "연도별 일정이 성공적으로 조회되었습니다."), + SCHEDULE_DETAIL_FIND_SUCCESS(10806, HttpStatus.OK, "일정 상세가 성공적으로 조회되었습니다."), } From 7a8b21af09cb8d664f9d7c0955a8c79024c83ed3 Mon Sep 17 00:00:00 2001 From: Kimjiwon Date: Tue, 25 Aug 2026 18:21:48 +0900 Subject: [PATCH 4/8] =?UTF-8?q?test:=20=EC=9B=94=EB=B3=84/=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=9D=BC=EC=A0=95=20=EC=A1=B0=ED=9A=8C=20QueryServ?= =?UTF-8?q?ice=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../query/GetScheduleQueryServiceTest.kt | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 src/test/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryServiceTest.kt diff --git a/src/test/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryServiceTest.kt new file mode 100644 index 00000000..b38cbed4 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryServiceTest.kt @@ -0,0 +1,315 @@ +package com.weeth.domain.schedule.application.usecase.query + +import com.weeth.domain.attendance.domain.repository.AttendanceReader +import com.weeth.domain.attendance.fixture.AttendanceTestFixture +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.schedule.application.dto.response.ScheduleAttendanceStatus +import com.weeth.domain.schedule.application.dto.response.ScheduleDetailResponse +import com.weeth.domain.schedule.application.dto.response.ScheduleResponse +import com.weeth.domain.schedule.application.exception.EventNotFoundException +import com.weeth.domain.schedule.application.mapper.EventMapper +import com.weeth.domain.schedule.application.mapper.ScheduleMapper +import com.weeth.domain.schedule.domain.enums.Type +import com.weeth.domain.schedule.domain.repository.EventRepository +import com.weeth.domain.schedule.fixture.ScheduleTestFixture +import com.weeth.domain.session.application.exception.SessionNotFoundException +import com.weeth.domain.session.domain.enums.SessionStatus +import com.weeth.domain.session.domain.repository.SessionReader +import com.weeth.domain.session.fixture.SessionTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.springframework.data.repository.findByIdOrNull +import java.time.LocalDateTime + +class GetScheduleQueryServiceTest : + DescribeSpec({ + val eventRepository = mockk() + val sessionReader = mockk() + val attendanceReader = mockk() + val clubMemberPolicy = mockk(relaxed = true) + val scheduleMapper = mockk() + val eventMapper = mockk() + val queryService = + GetScheduleQueryService( + eventRepository, + sessionReader, + attendanceReader, + clubMemberPolicy, + scheduleMapper, + eventMapper, + ) + + val clubId = 1L + val userId = 10L + val cardinal = 7 + val start = LocalDateTime.of(2026, 12, 1, 0, 0) + val end = LocalDateTime.of(2026, 12, 31, 23, 59, 59) + + beforeTest { + clearMocks(eventRepository, sessionReader, attendanceReader, scheduleMapper) + } + + describe("findMonthly") { + it("이벤트와 세션을 시작 시간 순으로 합쳐서 반환한다") { + val event = + ScheduleTestFixture.createEvent( + id = 1L, + cardinal = cardinal, + start = LocalDateTime.of(2026, 12, 10, 10, 0), + end = LocalDateTime.of(2026, 12, 10, 12, 0), + ) + val session = + SessionTestFixture.createSession( + id = 2L, + cardinal = cardinal, + start = LocalDateTime.of(2026, 12, 5, 14, 0), + end = LocalDateTime.of(2026, 12, 5, 16, 0), + ) + val eventResponse = + ScheduleResponse( + id = 1L, + title = "Test Event", + start = event.start, + end = event.end, + type = Type.EVENT, + location = "Test Location", + cardinal = cardinal, + ) + val sessionResponse = + ScheduleResponse( + id = 2L, + title = "Test Session", + start = session.start, + end = session.end, + type = Type.SESSION, + location = "Test Location", + cardinal = cardinal, + ) + + every { eventRepository.findByClubIdAndCardinalAndDateRange(clubId, cardinal, start, end) } returns + listOf(event) + every { sessionReader.findAllByClubIdAndCardinalAndStartBetween(clubId, cardinal, start, end) } returns + listOf(session) + every { scheduleMapper.toResponse(event) } returns eventResponse + every { scheduleMapper.toResponse(session) } returns sessionResponse + + // 세션(12/5)이 이벤트(12/10)보다 앞에 와야 함 + queryService.findMonthly(clubId, userId, cardinal, start, end) shouldBe + listOf(sessionResponse, eventResponse) + } + + it("이벤트만 있으면 이벤트만 반환한다") { + val event = ScheduleTestFixture.createEvent(id = 1L, cardinal = cardinal) + val eventResponse = + ScheduleResponse( + id = 1L, + title = "Test Event", + start = event.start, + end = event.end, + type = Type.EVENT, + location = "Test Location", + cardinal = cardinal, + ) + + every { eventRepository.findByClubIdAndCardinalAndDateRange(clubId, cardinal, start, end) } returns + listOf(event) + every { sessionReader.findAllByClubIdAndCardinalAndStartBetween(clubId, cardinal, start, end) } returns + emptyList() + every { scheduleMapper.toResponse(event) } returns eventResponse + + queryService.findMonthly(clubId, userId, cardinal, start, end) shouldBe listOf(eventResponse) + } + + it("세션만 있으면 세션만 반환한다") { + val session = SessionTestFixture.createSession(id = 1L, cardinal = cardinal) + val sessionResponse = + ScheduleResponse( + id = 1L, + title = "Test Session", + start = session.start, + end = session.end, + type = Type.SESSION, + location = "Test Location", + cardinal = cardinal, + ) + + every { eventRepository.findByClubIdAndCardinalAndDateRange(clubId, cardinal, start, end) } returns + emptyList() + every { sessionReader.findAllByClubIdAndCardinalAndStartBetween(clubId, cardinal, start, end) } returns + listOf(session) + every { scheduleMapper.toResponse(session) } returns sessionResponse + + queryService.findMonthly(clubId, userId, cardinal, start, end) shouldBe listOf(sessionResponse) + } + + it("해당 기수의 일정이 없으면 빈 목록을 반환한다") { + every { eventRepository.findByClubIdAndCardinalAndDateRange(clubId, cardinal, start, end) } returns + emptyList() + every { sessionReader.findAllByClubIdAndCardinalAndStartBetween(clubId, cardinal, start, end) } returns + emptyList() + + queryService.findMonthly(clubId, userId, cardinal, start, end) shouldBe emptyList() + } + } + + describe("findDetail") { + val club = ClubTestFixture.createClub(id = clubId) + val member = ClubMemberTestFixture.createActiveMember(club = club) + + context("EVENT 타입일 때") { + it("이벤트 상세를 반환한다") { + val event = ScheduleTestFixture.createEvent(id = 1L, club = club) + val mockResponse = mockk() + + every { eventRepository.findByIdOrNull(1L) } returns event + every { scheduleMapper.toDetailResponse(event) } returns mockResponse + + queryService.findDetail(clubId, userId, 1L, Type.EVENT) shouldBe mockResponse + } + + it("이벤트가 없으면 EventNotFoundException을 던진다") { + every { eventRepository.findByIdOrNull(99L) } returns null + + shouldThrow { + queryService.findDetail(clubId, userId, 99L, Type.EVENT) + } + } + + it("이벤트가 다른 클럽 소속이면 EventNotFoundException을 던진다") { + val otherClub = ClubTestFixture.createClub(id = 999L) + val event = ScheduleTestFixture.createEvent(id = 1L, club = otherClub) + + every { eventRepository.findByIdOrNull(1L) } returns event + + shouldThrow { + queryService.findDetail(clubId, userId, 1L, Type.EVENT) + } + } + } + + context("SESSION 타입일 때") { + it("출석 완료(COMPLETED)이면 attendedAt과 함께 매퍼를 호출한다") { + val session = SessionTestFixture.createSession(id = 1L, club = club) + val attendance = AttendanceTestFixture.createAttendance(session, member) + attendance.attend() + val mockResponse = mockk() + + every { sessionReader.getById(1L) } returns session + every { attendanceReader.findAllBySession(session) } returns listOf(attendance) + every { attendanceReader.findBySessionAndUserId(session, userId) } returns attendance + every { scheduleMapper.toDetailResponse(session, listOf(attendance), any(), any()) } returns + mockResponse + + queryService.findDetail(clubId, userId, 1L, Type.SESSION) shouldBe mockResponse + verify { + scheduleMapper.toDetailResponse( + session, + listOf(attendance), + ScheduleAttendanceStatus.COMPLETED, + attendance.modifiedAt, + ) + } + } + + it("결석(ABSENT)이면 attendedAt 없이 매퍼를 호출한다") { + val session = SessionTestFixture.createSession(id = 1L, club = club) + val attendance = AttendanceTestFixture.createAttendance(session, member) + attendance.absent() + + every { sessionReader.getById(1L) } returns session + every { attendanceReader.findAllBySession(session) } returns listOf(attendance) + every { attendanceReader.findBySessionAndUserId(session, userId) } returns attendance + every { scheduleMapper.toDetailResponse(session, listOf(attendance), any(), any()) } returns mockk() + + queryService.findDetail(clubId, userId, 1L, Type.SESSION) + verify { + scheduleMapper.toDetailResponse( + session, + listOf(attendance), + ScheduleAttendanceStatus.ABSENT, + null, + ) + } + } + + it("출석 예정(UPCOMING)이면 attendedAt 없이 매퍼를 호출한다") { + val session = + SessionTestFixture.createSession( + id = 1L, + club = club, + start = LocalDateTime.now().plusDays(1), + end = LocalDateTime.now().plusDays(1).plusHours(2), + ) + val attendance = AttendanceTestFixture.createAttendance(session, member) + + every { sessionReader.getById(1L) } returns session + every { attendanceReader.findAllBySession(session) } returns listOf(attendance) + every { attendanceReader.findBySessionAndUserId(session, userId) } returns attendance + every { scheduleMapper.toDetailResponse(session, listOf(attendance), any(), any()) } returns mockk() + + queryService.findDetail(clubId, userId, 1L, Type.SESSION) + verify { + scheduleMapper.toDetailResponse( + session, + listOf(attendance), + ScheduleAttendanceStatus.UPCOMING, + null, + ) + } + } + + it("현재 출석 가능(OPEN) 시간이면 OPEN 상태로 매퍼를 호출한다") { + val session = + SessionTestFixture.createSession( + id = 1L, + club = club, + start = LocalDateTime.now().minusHours(1), + end = LocalDateTime.now().plusHours(1), + status = SessionStatus.OPEN, + ) + val attendance = AttendanceTestFixture.createAttendance(session, member) + + every { sessionReader.getById(1L) } returns session + every { attendanceReader.findAllBySession(session) } returns listOf(attendance) + every { attendanceReader.findBySessionAndUserId(session, userId) } returns attendance + every { scheduleMapper.toDetailResponse(session, listOf(attendance), any(), any()) } returns mockk() + + queryService.findDetail(clubId, userId, 1L, Type.SESSION) + verify { + scheduleMapper.toDetailResponse( + session, + listOf(attendance), + ScheduleAttendanceStatus.OPEN, + null, + ) + } + } + + it("세션이 없으면 SessionNotFoundException을 던진다") { + every { sessionReader.getById(99L) } throws SessionNotFoundException() + + shouldThrow { + queryService.findDetail(clubId, userId, 99L, Type.SESSION) + } + } + + it("세션이 다른 클럽 소속이면 SessionNotFoundException을 던진다") { + val otherClub = ClubTestFixture.createClub(id = 999L) + val session = SessionTestFixture.createSession(id = 1L, club = otherClub) + + every { sessionReader.getById(1L) } returns session + + shouldThrow { + queryService.findDetail(clubId, userId, 1L, Type.SESSION) + } + } + } + } + }) From f2aff8e8bb148fb80ff223f17840f7411dffc5d7 Mon Sep 17 00:00:00 2001 From: Kimjiwon Date: Tue, 25 Aug 2026 18:32:19 +0900 Subject: [PATCH 5/8] =?UTF-8?q?style:=20ScheduleAttendanceStatus=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EA=B3=B5=EB=B0=B1=20ktlint=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/dto/response/ScheduleAttendanceStatus.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleAttendanceStatus.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleAttendanceStatus.kt index 7a935f01..1e0df3aa 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleAttendanceStatus.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleAttendanceStatus.kt @@ -1,8 +1,8 @@ package com.weeth.domain.schedule.application.dto.response enum class ScheduleAttendanceStatus { - UPCOMING, // 출석 예정 - OPEN, // 지금 출석 가능 - COMPLETED, // 출석 완료 - ABSENT, // 결석 + UPCOMING, // 출석 예정 + OPEN, // 지금 출석 가능 + COMPLETED, // 출석 완료 + ABSENT, // 결석 } From 1484edfce0a251b4f812d2e9f0ddafcee8bf22b2 Mon Sep 17 00:00:00 2001 From: Kimjiwon Date: Tue, 25 Aug 2026 23:10:01 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20AttendeeResponse=20=EC=A7=81?= =?UTF-8?q?=EA=B8=89=20=E2=86=92=20=EA=B6=8C=ED=95=9C=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9A=A9=EC=96=B4=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/application/dto/response/AttendeeResponse.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/AttendeeResponse.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/AttendeeResponse.kt index 4049429d..d57abd36 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/AttendeeResponse.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/AttendeeResponse.kt @@ -8,7 +8,7 @@ data class AttendeeResponse( val name: String, @field:Schema(description = "학과", example = "컴퓨터공학과") val department: String?, - @field:Schema(description = "직급", example = "USER") + @field:Schema(description = "권한", example = "USER") val role: MemberRole, @field:Schema(description = "프로필 이미지 URL") val profileImageUrl: String?, From eddb8c607a7fcece42a37152dd40d38974f43cc1 Mon Sep 17 00:00:00 2001 From: Kimjiwon Date: Tue, 25 Aug 2026 23:20:05 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20=EC=B6=9C=EC=84=9D=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=20=EC=8B=9C=EA=B0=84=20=C2=B110=EB=B6=84=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EC=B6=9C=EC=84=9D=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=8F=84=EC=B6=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/usecase/query/GetScheduleQueryService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt index 566e4ab1..8cdcfec1 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt @@ -100,8 +100,8 @@ class GetScheduleQueryService( val now = LocalDateTime.now() if (attendance == null) { return when { - now.isBefore(session.start) -> ScheduleAttendanceStatus.UPCOMING - session.status == SessionStatus.OPEN && !now.isAfter(session.end) -> ScheduleAttendanceStatus.OPEN + now.isBefore(session.start.minusMinutes(10)) -> ScheduleAttendanceStatus.UPCOMING + session.status == SessionStatus.OPEN && session.isCheckInAllowed(now) -> ScheduleAttendanceStatus.OPEN else -> ScheduleAttendanceStatus.ABSENT } } From 6d9d861efbcb242e8bdcc405ac65f7ca92e37dd4 Mon Sep 17 00:00:00 2001 From: Kimjiwon Date: Tue, 25 Aug 2026 23:21:14 +0900 Subject: [PATCH 8/8] =?UTF-8?q?chore:=20=EC=97=B0=EB=8F=84=EB=B3=84=20?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=20=EC=A1=B0=ED=9A=8C=20API=20Deprecated=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../weeth/domain/schedule/presentation/ScheduleController.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt index 0ce377a8..d22c5a99 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt @@ -54,8 +54,9 @@ class ScheduleController( getScheduleQueryService.findDetail(clubId, userId, id, type), ) + @Deprecated("사용하지 않는 API") @GetMapping("/yearly") - @Operation(summary = "연도별 일정 조회", hidden = true) + @Operation(summary = "연도별 일정 조회", deprecated = true) fun findByYearly( @TsidParam @TsidPathVariable clubId: Long,