Skip to content

Commit

Permalink
refactor: 매칭 API 엔드포인트 구조 개선 (#240)
Browse files Browse the repository at this point in the history
* feat: Match API 엔드포인트 경로 변경

- /matches/{matchId}/result -> /matches/result로 변경
- 요청 파라미터를 teamId에서 teamType으로 변경

* refactor: MatchService 파라미터 변경

- 파라미터 teamType으로 변경
- 캐시 키 형식 변경

* feat: teamType 사용 쿼리 메소드 추가

* chore: 사용하지 않는 메서드 제거

* chore: 불필요한 예외 처리 삭제

* docs: Swagger 업데이트

* refactor: getMatchedPartnerInformation API에서 불필요한 preference 정보 제거

- 책임 분리를 위해 새로운 DTO 생성
- ArrayList 변환 로직에서 preference 변환 제거

* feat: 매칭 API 시즌 파라미터 추가

* feat: 필수 파라미터 누락시 에러 처리 추가

* refactor: 매칭 결과 조회 성능 개선

* chore: unless 옵션 삭제

* fix: 미결제 팀원이 있는 경우에도 신청으로 처리되는 문제 수정

* docs: Swagger 업데이트

* fix: Redis 캐시 직렬화 오류 수정

- Entity의 Lazy Loading된 interest 컬렉션을 DTO 변환 시 즉시 초기화하도록 수정

* docs: Swagger 문서 수정

* refactor: 캐시 키 구조 개선

* chore: 사용하지 않는 DTO 삭제

* Merge branch 'main' into refactor/match-api-endpoints
  • Loading branch information
23tae authored Dec 6, 2024
1 parent 5b04279 commit 5e0bc26
Show file tree
Hide file tree
Showing 18 changed files with 212 additions and 291 deletions.
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

0 comments on commit 5e0bc26

Please sign in to comment.