Skip to content

Commit

Permalink
feat: 오프라인 출석 코드 기능 추가 (#119)
Browse files Browse the repository at this point in the history
* feat: 세션에 출석 코드 추가

* feat: 코드를 이용한 출석 처리 로직 구현

- 기본적인 출석 체크의 검증 로직과 유사하고, 오프라인의 경우에만 작동하도록 validation

* feat: 마스킹 로직에 code 추가

* chore: Session.code, Attendance.try_count 추가 dml 작성

* feat: 재시도 로직 추가

* chore: 스웨거에 에러 응답 추가

* test: code 추가 및 masking 로직에 따른 테스트 코드 수정

* test: code 추가 및 masking 로직에 따른 테스트 코드 추가

* fix: 잘못 주입되는 gateway 변경

* feat: 오프라인 세션의 출석 코드 방식 테스트 코드 추가

* chore: 스웨거에 마스킹 설명 추가

* chore: 스웨거에 마스킹 설명 변경

* chore: 정책에 따른 출석 에러 메시지 변경
  • Loading branch information
ddingmin authored Jul 18, 2024
1 parent 9966fbe commit 0fe711d
Show file tree
Hide file tree
Showing 19 changed files with 440 additions and 34 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ package com.depromeet.makers.domain.exception

open class AttendanceException(
errorCode: ErrorCode,
) : DomainException(errorCode)
data: Any? = null,
) : DomainException(errorCode, data)

class AttendanceBeforeTimeException : AttendanceException(ErrorCode.BEFORE_AVAILABLE_ATTENDANCE_TIME)
class AttendanceAfterTimeException : AttendanceException(ErrorCode.AFTER_AVAILABLE_ATTENDANCE_TIME)
Expand All @@ -11,3 +12,6 @@ class InvalidCheckInTimeException : AttendanceException(ErrorCode.INVALID_CHECKI
class MissingPlaceParamException : AttendanceException(ErrorCode.MISSING_PLACE_PARAM)
class InvalidCheckInDistanceException : AttendanceException(ErrorCode.INVALID_CHECKIN_DISTANCE)
class NotFoundAttendanceException : AttendanceException(ErrorCode.NOT_EXIST_ATTENDANCE)
class InvalidCheckInCodeException(data: Any?) : AttendanceException(ErrorCode.INVALID_CHECKIN_CODE, data)
class NotSupportedCheckInCodeException : AttendanceException(ErrorCode.NOT_SUPPORTED_CHECKIN_CODE)
class TryCountOverException : AttendanceException(ErrorCode.TRY_COUNT_OVER)
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,8 @@ enum class ErrorCode(
INVALID_CHECKIN_TIME("AT0004", "현재 주차에 해당하는 세션을 찾을 수 없습니다."),
MISSING_PLACE_PARAM("AT0005", "오프라인 세션 출석체크의 현재 위치 정보가 누락되었습니다."),
INVALID_CHECKIN_DISTANCE("AT0006", "현재 위치와 세션 장소의 거리가 너무 멉니다."),
NOT_EXIST_ATTENDANCE("AT0007", "해당하는 출석 정보가 없습니다.")
NOT_EXIST_ATTENDANCE("AT0007", "해당하는 출석 정보가 없습니다."),
INVALID_CHECKIN_CODE("AT0008", "잘못된 출석 코드입니다. 다시 시도해주세요."),
NOT_SUPPORTED_CHECKIN_CODE("AT0009", "출석 코드로 출석할 수 없는 세션입니다."),
TRY_COUNT_OVER("AT0010", "출석 인증 횟수를 초과했습니다."),
}
24 changes: 20 additions & 4 deletions src/main/kotlin/com/depromeet/makers/domain/model/Attendance.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ data class Attendance(
val member: Member,
val sessionType: SessionType,
val attendanceStatus: AttendanceStatus,
val attendanceTime: LocalDateTime?
val attendanceTime: LocalDateTime?,
val tryCount: Int,
) {
fun checkIn(now: LocalDateTime, attendanceStatus: AttendanceStatus): Attendance {
return this.copy(
Expand Down Expand Up @@ -51,25 +52,35 @@ data class Attendance(

fun isTardy() = attendanceStatus.isTardy()

fun tryCountUp() = this.copy(tryCount = tryCount + 1)

fun isTryCountOver() = tryCount >= MAX_TRY_COUNT

fun getTryCount() = TryCount(tryCount)

fun update(
attendanceId: String = this.attendanceId,
generation: Int = this.generation,
week: Int = this.week,
member: Member = this.member,
attendanceStatus: AttendanceStatus = this.attendanceStatus,
attendanceTime: LocalDateTime? = this.attendanceTime
attendanceTime: LocalDateTime? = this.attendanceTime,
tryCount: Int = this.tryCount,
): Attendance {
return this.copy(
attendanceId = attendanceId,
generation = generation,
week = week,
member = member,
attendanceStatus = attendanceStatus,
attendanceTime = attendanceTime
attendanceTime = attendanceTime,
tryCount = tryCount
)
}

companion object {
const val MAX_TRY_COUNT = 3 // 최대 출석 코드 기입 횟수

fun newAttendance(
generation: Int,
week: Int,
Expand All @@ -83,7 +94,12 @@ data class Attendance(
member = member,
sessionType = sessionType,
attendanceStatus = attendance,
attendanceTime = null
attendanceTime = null,
tryCount = 0
)
}

data class TryCount(
val tryCount: Int,
)
}
12 changes: 10 additions & 2 deletions src/main/kotlin/com/depromeet/makers/domain/model/Session.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.depromeet.makers.domain.model

import com.depromeet.makers.util.generateULID
import java.time.LocalDateTime
import kotlin.random.Random

data class Session(
val sessionId: String,
Expand All @@ -12,14 +13,16 @@ data class Session(
val startTime: LocalDateTime,
val sessionType: SessionType,
val place: Place,
val code: String? = generateCode(),
) {
fun isOnline() = sessionType.isOnline()

fun isOffline() = sessionType.isOffline()

fun maskLocation(): Session {
fun mask(): Session {
return copy(
place = place.maskLocation()
place = place.maskLocation(),
code = null,
)
}

Expand Down Expand Up @@ -64,5 +67,10 @@ data class Session(
place = place,
)
}

fun generateCode(): String {
val randomNumber = Random.nextInt(0, 10000)
return String.format("%04d", randomNumber)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.depromeet.makers.domain.usecase

import com.depromeet.makers.domain.exception.*
import com.depromeet.makers.domain.gateway.AttendanceGateway
import com.depromeet.makers.domain.gateway.MemberGateway
import com.depromeet.makers.domain.gateway.SessionGateway
import com.depromeet.makers.domain.model.Attendance
import java.time.DayOfWeek
import java.time.LocalDateTime

class CheckInSessionWithCode(
private val attendanceGateway: AttendanceGateway,
private val sessionGateway: SessionGateway,
private val memberGateway: MemberGateway,
) : UseCase<CheckInSessionWithCode.CheckInSessionWithCodeInput, Attendance> {
data class CheckInSessionWithCodeInput(
val memberId: String,
val now: LocalDateTime,
val code: String,
)

override fun execute(input: CheckInSessionWithCodeInput): Attendance {
val member = memberGateway.getById(input.memberId)
val monday = input.now.getMonday()

val thisWeekSession =
sessionGateway.findByStartTimeBetween(
monday,
monday.plusDays(7),
) ?: throw InvalidCheckInTimeException()

if (thisWeekSession.isOnline()) {
throw NotSupportedCheckInCodeException()
}

val attendance =
runCatching {
attendanceGateway.findByMemberIdAndGenerationAndWeek(
member.memberId,
thisWeekSession.generation,
thisWeekSession.week,
)
}.getOrElse { throw NotFoundAttendanceException() }

if (attendance.isTryCountOver()) {
throw TryCountOverException()
}

attendance.isAvailableCheckInRequest(thisWeekSession.startTime, input.now)

if (thisWeekSession.code != input.code) {
attendanceGateway.save(attendance.tryCountUp())
throw InvalidCheckInCodeException(attendance.getTryCount())
}

val expectAttendanceStatus = attendance.expectAttendanceStatus(thisWeekSession.startTime, input.now)
return attendanceGateway.save(attendance.checkIn(input.now, expectAttendanceStatus))
}

private fun LocalDateTime.getMonday() = this.toLocalDate().with(DayOfWeek.MONDAY).atStartOfDay()
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class GetInfoSession(

return when {
input.isOrganizer -> session
else -> session.maskLocation()
else -> session.mask()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class GetSessions(

return when {
input.isOrganizer -> sessions
else -> sessions.map { it.maskLocation() }
else -> sessions.map { it.mask() }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ class AttendanceEntity private constructor(

@Column(name = "attendance_time", nullable = true)
val attendanceTime: LocalDateTime?,

@Column(name = "try_count", nullable = true)
var tryCount: Int? = 0,
) {
fun toDomain() = Attendance(
attendanceId = id,
Expand All @@ -48,6 +51,7 @@ class AttendanceEntity private constructor(
attendanceStatus = attendanceStatus,
sessionType = sessionType,
attendanceTime = attendanceTime,
tryCount = tryCount ?: 0
)

companion object {
Expand All @@ -59,7 +63,8 @@ class AttendanceEntity private constructor(
member = MemberEntity.fromDomain(member),
sessionType = sessionType,
attendanceStatus = attendanceStatus,
attendanceTime = attendanceTime
attendanceTime = attendanceTime,
tryCount = tryCount,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ class SessionEntity private constructor(
@Column(name = "place_name", nullable = true, columnDefinition = "VARCHAR(255)")
var placeName: String?,

@Column(name = "code")
var code: String?,

) {
fun toDomain(): Session {
return Session(
Expand All @@ -54,6 +57,7 @@ class SessionEntity private constructor(
startTime = startTime,
sessionType = sessionType,
place = Place.newPlace(address, longitude, latitude, placeName),
code = code,
)
}

Expand All @@ -72,6 +76,7 @@ class SessionEntity private constructor(
longitude = place.longitude,
latitude = place.latitude,
placeName = place.name,
code = code,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,57 +1,117 @@
package com.depromeet.makers.presentation.restapi.controller

import com.depromeet.makers.domain.usecase.CheckInSession
import com.depromeet.makers.domain.usecase.CheckInSessionWithCode
import com.depromeet.makers.domain.usecase.GetCheckInStatus
import com.depromeet.makers.presentation.restapi.dto.request.CheckInCodeRequest
import com.depromeet.makers.presentation.restapi.dto.request.GetLocationRequest
import com.depromeet.makers.presentation.restapi.dto.response.AttendanceResponse
import com.depromeet.makers.presentation.restapi.dto.response.CheckInStatusResponse
import com.depromeet.makers.presentation.restapi.dto.response.ErrorResponse
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.ExampleObject
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.security.core.Authentication
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.*
import java.time.LocalDateTime

@Tag(name = "출석 관리 API", description = "출석 관리 API")
@RestController
@RequestMapping("/v1/check-in")
class CheckInController(
private val checkInSession: CheckInSession,
private val checkInSessionWithCode: CheckInSessionWithCode,
private val getCheckInStatus: GetCheckInStatus,
) {

@Operation(summary = "세션 출석", description = "세션에 출석합니다. (서버에서 현재 시간을 기준으로 출석 처리)")
@PostMapping
fun checkInSession(
authentication: Authentication,
@RequestBody request: GetLocationRequest,
): AttendanceResponse {
return AttendanceResponse.fromDomain(
): AttendanceResponse =
AttendanceResponse.fromDomain(
checkInSession.execute(
CheckInSession.CheckInSessionInput(
now = LocalDateTime.now(),
memberId = authentication.name,
latitude = request.latitude,
longitude = request.longitude,
)
)
),
),
)

@Operation(
summary = "코드 기반 세션 출석",
description = "세션에 출석합니다. (출석 코드를 기반으로 출석 처리)",
responses = [
ApiResponse(
responseCode = "200",
description = "출석 성공",
content = [Content(schema = Schema(implementation = AttendanceResponse::class))],
),
ApiResponse(
responseCode = "AT0008",
description = "출석 코드가 불일치",
content = [
Content(
schema = Schema(implementation = ErrorResponse::class),
examples = [ExampleObject(
"{\n" +
" \"code\": \"AT0008\",\n" +
" \"message\": \"출석 코드가 일치하지 않습니다.\",\n" +
" \"data\": {\n" +
" \"tryCount\": 1\n" +
" }\n" +
"}"
)],
),
],
),
ApiResponse(
responseCode = "AT0010",
description = "출석 시도 횟수 초과",
content = [
Content(
schema = Schema(implementation = ErrorResponse::class),
examples = [ExampleObject(
"{\n" +
" \"code\": \"AT0010\",\n" +
" \"message\": \"출석 시도 횟수를 초과하였습니다.\"\n" +
"}"
)],
),
],
),
]
)
@PostMapping("/code")
fun checkInSessionWithCode(
authentication: Authentication,
@RequestBody request: CheckInCodeRequest,
): AttendanceResponse =
AttendanceResponse.fromDomain(
checkInSessionWithCode.execute(
CheckInSessionWithCode.CheckInSessionWithCodeInput(
memberId = authentication.name,
now = LocalDateTime.now(),
code = request.code,
),
),
)
}

@Operation(summary = "세션 출석 상태", description = "현재 세션 출석 상태 확인합니다. (CheckInStatusResponse 스키마에 자세한 설명있습니다.)")
@GetMapping
fun checkInStatus(
authentication: Authentication,
): CheckInStatusResponse {
val checkInStatus = getCheckInStatus.execute(
GetCheckInStatus.GetCheckInStatusInput(
now = LocalDateTime.now(),
memberId = authentication.name,
fun checkInStatus(authentication: Authentication): CheckInStatusResponse {
val checkInStatus =
getCheckInStatus.execute(
GetCheckInStatus.GetCheckInStatusInput(
now = LocalDateTime.now(),
memberId = authentication.name,
),
)
)
return CheckInStatusResponse(
generation = checkInStatus.generation,
week = checkInStatus.week,
Expand Down
Loading

0 comments on commit 0fe711d

Please sign in to comment.