Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: 매칭 API 엔드포인트 구조 개선 #240

Merged
merged 18 commits into from
Dec 6, 2024
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
7 changes: 7 additions & 0 deletions src/main/kotlin/uoslife/servermeeting/admin/api/AdminApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -161,4 +161,11 @@ class AdminApi(
val requestInfo = requestUtils.toRequestInfoDto(request)
return ResponseEntity.status(HttpStatus.OK).body(adminService.refundPayment(requestInfo))
}

@Operation(summary = "매칭 결과 캐시 웜업 API", description = "결제 완료된 매칭 데이터를 캐시에 저장합니다")
@PostMapping("/cache/warmup")
fun triggerCacheWarmup(@RequestParam season: Int): ResponseEntity<Unit> {
adminService.warmUpCacheAsync(season)
return ResponseEntity.status(HttpStatus.NO_CONTENT).build()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import java.time.Duration
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Service
import uoslife.servermeeting.match.service.MatchingService
import uoslife.servermeeting.meetingteam.dao.UserTeamDao
import uoslife.servermeeting.global.common.dto.RequestInfoDto
import uoslife.servermeeting.payment.dto.response.PaymentResponseDto
import uoslife.servermeeting.payment.service.PaymentService
Expand All @@ -17,7 +20,9 @@ import uoslife.servermeeting.verification.util.VerificationUtils
class AdminService(
private val redisTemplate: RedisTemplate<String, Any>,
private val userService: UserService,
@Qualifier("PortOneService") private val paymentService: PaymentService
@Qualifier("PortOneService") private val paymentService: PaymentService,
private val matchingService: MatchingService,
private val userTeamDao: UserTeamDao,
) {
companion object {
private val logger = LoggerFactory.getLogger(AdminService::class.java)
Expand Down Expand Up @@ -45,4 +50,18 @@ class AdminService(
logger.info("[ADMIN-매칭 실패 유저 환불] $requestInfo")
return result
}

@Async
fun warmUpCacheAsync(season: Int) {
logger.info("[캐시 웜업 시작]")
val participants = userTeamDao.findAllParticipantsBySeasonAndType(season)
participants.forEach { participant ->
try {
matchingService.getMatchInfo(participant.userId, participant.teamType, season)
} catch (e: Exception) {
logger.info("[캐시 웜업 실패] userId: ${participant.userId} message: ${e.message}")
}
}
logger.info("[캐시 웜업 성공] 대상 인원: ${participants.size}")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import uoslife.servermeeting.global.auth.service.AuthService
import uoslife.servermeeting.global.auth.util.CookieUtils
import uoslife.servermeeting.global.error.ErrorResponse

@Tag(name = "Auth", description = "Auth API")
@Tag(name = "Auth", description = "인증 API")
@RestController
@RequestMapping("/api/auth")
class AuthApi(
Expand Down
27 changes: 12 additions & 15 deletions src/main/kotlin/uoslife/servermeeting/global/config/CacheConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,16 @@ class CacheConfig(private val redisConnectionFactory: RedisConnectionFactory) {
@Bean
fun cacheManager(): CacheManager {
val objectMapper =
ObjectMapper()
.registerModule(KotlinModule.Builder().build()) // Kotlin 지원 모듈 추가
.apply {
configure(MapperFeature.USE_STD_BEAN_NAMING, true)
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
activateDefaultTyping(
BasicPolymorphicTypeValidator.builder()
.allowIfBaseType(Any::class.java)
.build(),
ObjectMapper.DefaultTyping.EVERYTHING
)
}
ObjectMapper().registerModule(KotlinModule.Builder().build()).apply {
configure(MapperFeature.USE_STD_BEAN_NAMING, true)
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
activateDefaultTyping(
BasicPolymorphicTypeValidator.builder()
.allowIfBaseType(Any::class.java)
.build(),
ObjectMapper.DefaultTyping.EVERYTHING
)
}
val defaultConfig =
RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(24))
Expand All @@ -51,9 +49,8 @@ class CacheConfig(private val redisConnectionFactory: RedisConnectionFactory) {

val configurations =
mapOf(
"user-participation" to defaultConfig.entryTtl(Duration.ofDays(2)),
"match-result" to defaultConfig.entryTtl(Duration.ofDays(2)),
"partner-info" to defaultConfig.entryTtl(Duration.ofDays(2))
"meeting-participation" to defaultConfig.entryTtl(Duration.ofDays(2)),
"match-info" to defaultConfig.entryTtl(Duration.ofDays(2))
)

return RedisCacheManager.builder(redisConnectionFactory)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.security.core.AuthenticationException
import org.springframework.web.HttpRequestMethodNotSupportedException
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.MissingServletRequestParameterException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
Expand Down Expand Up @@ -119,4 +120,14 @@ class GlobalExceptionHandler {
val response = ErrorResponse(errorCode)
return ResponseEntity(response, HttpStatus.valueOf(errorCode.status))
}

@ExceptionHandler(MissingServletRequestParameterException::class)
fun handleMissingServletRequestParameterException(
exception: MissingServletRequestParameterException
): ResponseEntity<ErrorResponse> {
logger.error("MissingServletRequestParameterException", exception)
val errorCode = ErrorCode.INVALID_INPUT_VALUE
val response = ErrorResponse(errorCode)
return ResponseEntity(response, HttpStatus.valueOf(errorCode.status))
}
}
113 changes: 29 additions & 84 deletions src/main/kotlin/uoslife/servermeeting/match/api/MatchApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,18 @@ import io.swagger.v3.oas.annotations.media.Schema
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.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.userdetails.UserDetails
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.RestController
import org.springframework.web.bind.annotation.*
import uoslife.servermeeting.global.error.ErrorResponse
import uoslife.servermeeting.match.dto.response.MatchResultResponse
import uoslife.servermeeting.match.dto.response.MeetingParticipationResponse
import uoslife.servermeeting.match.dto.response.*
import uoslife.servermeeting.match.service.MatchingService
import uoslife.servermeeting.meetingteam.dto.response.MeetingTeamInformationGetResponse
import uoslife.servermeeting.meetingteam.entity.enums.TeamType

@RestController
@RequestMapping("/api/match")
@Tag(name = "Match", description = "매칭 API")
@Tag(name = "Match", description = "매칭 내역 조회 API")
class MatchApi(
private val matchingService: MatchingService,
) {
Expand All @@ -45,107 +40,57 @@ class MatchApi(
)
@GetMapping("/me/participations")
fun getUserMeetingParticipation(
@AuthenticationPrincipal userDetails: UserDetails
@AuthenticationPrincipal userDetails: UserDetails,
@RequestParam season: Int,
): ResponseEntity<MeetingParticipationResponse> {
val result = matchingService.getUserMeetingParticipation(userDetails.username.toLong())
val result =
matchingService.getUserMeetingParticipation(userDetails.username.toLong(), season)
return ResponseEntity.ok(result)
}

@Operation(summary = "매칭 결과 조회", description = "특정 매칭의 성공 여부를 조회합니다.")
@Operation(summary = "매칭 정보 조회", description = "매칭 결과와 매칭 상대의 정보를 조회합니다.")
@ApiResponses(
value =
[
ApiResponse(
responseCode = "200",
description = "매칭 결과 정보 반환",
content =
[Content(schema = Schema(implementation = MatchResultResponse::class))]
),
ApiResponse(
responseCode = "403",
description = "해당 팀에 대한 접근 권한 없음",
content =
[
Content(
schema = Schema(implementation = ErrorResponse::class),
examples =
[
ExampleObject(
name = "MT03",
value =
"{\"message\": \"Unauthorized team access.\", \"status\": 403, \"code\": \"MT03\"}"
)]
)]
)]
)
@GetMapping("/teams/{teamId}/result")
fun getMatchResult(
@PathVariable teamId: Long,
@AuthenticationPrincipal userDetails: UserDetails
): ResponseEntity<MatchResultResponse> {
return ResponseEntity.ok(
matchingService.getMatchResult(userDetails.username.toLong(), teamId)
)
}

@Operation(summary = "매칭된 상대방 정보 조회", description = "매칭된 상대 팀의 상세 정보를 조회합니다.")
@ApiResponses(
value =
[
ApiResponse(
responseCode = "200",
description = "매칭된 상대방 정보 반환",
content =
[
Content(
schema =
Schema(
implementation = MeetingTeamInformationGetResponse::class
)
)]
description = "매칭 결과 반환",
content = [Content(schema = Schema(implementation = MatchInfoResponse::class))]
),
ApiResponse(
responseCode = "400",
description = "매치를 찾을 수 없음",
description = "잘못된 요청",
content =
[
Content(
schema = Schema(implementation = ErrorResponse::class),
examples =
[
ExampleObject(
name = "MT01",
name = "Meeting Team Not Found",
description = "신청 내역 없음",
value =
"{\"message\": \"Match is not Found.\", \"status\": 400, \"code\": \"MT01\"}"
)]
)]
),
ApiResponse(
responseCode = "403",
description = "해당 매치에 대한 접근 권한 없음",
content =
[
Content(
schema = Schema(implementation = ErrorResponse::class),
examples =
[
"{\"message\": \"Meeting Team is not Found.\", \"status\": 400, \"code\": \"M06\"}"
),
ExampleObject(
name = "MT04",
name = "Payment Not Found",
description = "결제 정보 없음",
value =
"{\"message\": \"Unauthorized match access.\", \"status\": 403, \"code\": \"MT04\"}"
)]
"{\"message\": \"Payment is not Found.\", \"status\": 400, \"code\": \"P01\"}"
),
]
)]
),
]
)
@GetMapping("/{matchId}/partner")
fun getMatchedPartnerInformation(
@PathVariable matchId: Long,
@GetMapping("/{teamType}/info")
fun getMatchInformation(
@AuthenticationPrincipal userDetails: UserDetails,
): ResponseEntity<MeetingTeamInformationGetResponse> {
return ResponseEntity.status(HttpStatus.OK)
.body(
matchingService.getMatchedPartnerInformation(userDetails.username.toLong(), matchId)
)
@PathVariable teamType: TeamType,
@RequestParam season: Int,
): ResponseEntity<MatchInfoResponse> {
return ResponseEntity.ok(
matchingService.getMatchInfo(userDetails.username.toLong(), teamType, season)
)
}
}
56 changes: 27 additions & 29 deletions src/main/kotlin/uoslife/servermeeting/match/dao/MatchedDao.kt
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
package uoslife.servermeeting.match.dao

import com.querydsl.core.types.Projections
import com.querydsl.jpa.impl.JPAQueryFactory
import jakarta.transaction.Transactional
import org.springframework.stereotype.Repository
import uoslife.servermeeting.match.dto.MatchResultDto
import uoslife.servermeeting.match.dto.response.MeetingParticipationResponse
import uoslife.servermeeting.match.entity.Match
import uoslife.servermeeting.match.entity.QMatch.match
import uoslife.servermeeting.meetingteam.entity.MeetingTeam
import uoslife.servermeeting.meetingteam.entity.QMeetingTeam.meetingTeam
import uoslife.servermeeting.meetingteam.entity.QUserTeam.userTeam
import uoslife.servermeeting.meetingteam.entity.enums.TeamType
import uoslife.servermeeting.payment.entity.enums.PaymentStatus

@Repository
@Transactional
Expand All @@ -32,34 +33,31 @@ class MatchedDao(private val queryFactory: JPAQueryFactory) {
.fetchOne()
}

fun findByTeam(team: MeetingTeam): Match? {
return queryFactory
.selectFrom(match)
.leftJoin(match.maleTeam, meetingTeam)
.leftJoin(match.femaleTeam, meetingTeam)
.where(match.maleTeam.eq(team).or(match.femaleTeam.eq(team)))
.fetchOne()
}
fun findUserParticipation(userId: Long, season: Int): MeetingParticipationResponse {
val teams =
queryFactory
.selectFrom(userTeam)
.join(userTeam.team)
.fetchJoin() // team을 미리 로딩
.leftJoin(userTeam.team.payments)
.fetchJoin() // payments도 미리 로딩
.where(userTeam.user.id.eq(userId), userTeam.team.season.eq(season))
.fetch()

fun findMatchResultByUserIdAndTeamId(userId: Long, teamId: Long): MatchResultDto? {
return queryFactory
.select(Projections.constructor(MatchResultDto::class.java, meetingTeam.type, match.id))
.from(userTeam)
.join(userTeam.team, meetingTeam)
.leftJoin(match)
.on(meetingTeam.eq(match.maleTeam).or(meetingTeam.eq(match.femaleTeam)))
.where(userTeam.user.id.eq(userId), userTeam.team.id.eq(teamId))
.fetchOne()
}
val participations =
teams
.groupBy { it.team.type }
.mapValues { (_, userTeams) ->
userTeams.all { userTeam ->
val payments = userTeam.team.payments
payments?.isNotEmpty() == true &&
payments.all { it.status == PaymentStatus.SUCCESS }
}
}

fun findById(matchId: Long): Match? {
return queryFactory
.selectFrom(match)
.join(match.maleTeam)
.fetchJoin()
.join(match.femaleTeam)
.fetchJoin()
.where(match.id.eq(matchId))
.fetchOne()
return MeetingParticipationResponse(
single = participations[TeamType.SINGLE] ?: false,
triple = participations[TeamType.TRIPLE] ?: false,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package uoslife.servermeeting.match.dto.response

import io.swagger.v3.oas.annotations.media.Schema
import uoslife.servermeeting.meetingteam.dto.response.UserCardProfile
import uoslife.servermeeting.user.entity.enums.GenderType

data class MatchInfoResponse(
@Schema(description = "매칭 성공 여부") val isMatched: Boolean,
@Schema(description = "상대 팀 정보", nullable = true) val partnerTeam: PartnerTeamInfo?
) {
data class PartnerTeamInfo(
@Schema(description = "팀 이름 (3대3)") val teamName: String?,
@Schema(description = "데이트 코스 (1대1)") val course: String?,
@Schema(description = "성별") val gender: GenderType,
@Schema(description = "팀원 프로필 목록") val userProfiles: List<UserCardProfile>
)

companion object {
fun toMatchInfoResponse(
response: MatchedMeetingTeamInformationGetResponse
): MatchInfoResponse {
return MatchInfoResponse(
isMatched = true,
partnerTeam =
PartnerTeamInfo(
teamName = response.teamName,
course = response.course,
gender = response.gender,
userProfiles = response.userProfiles!!
)
)
}
}
}
Loading
Loading