Skip to content

Commit bdd1098

Browse files
authored
Merge pull request #53 from GAT2025/feat/#25-1
2 parents a678c27 + 269bddd commit bdd1098

16 files changed

Lines changed: 504 additions & 6 deletions

File tree

src/main/kotlin/gat_be/dev/common/status/ErrorStatus.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,16 @@ enum class ErrorStatus(
8282
REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "JWT_401", "DB에 저장된 토큰과 일치하지 않습니다"),
8383
REFRESH_TOKEN_MISMATCH(HttpStatus.UNAUTHORIZED, "JWT_401", "리프레시 토큰 정보가 사용자 정보와 일치하지 않습니다."),
8484

85+
/**
86+
* Review
87+
*/
88+
REVIEW_ALREADY_EXISTS(HttpStatus.CONFLICT, "REVIEW_409", "리뷰를 이미 작성하였습니다"),
89+
TEAM_NOT_MATCHES_WITH_REVIEWEE(HttpStatus.FORBIDDEN, "REVIEW_403", "해당 유저와 같은 팀이 아닙니다"),
90+
CANNOT_CREATE_REVIEW_OF_MYSELF(HttpStatus.CONFLICT, "REVIEW_409", "본인에 대한 리뷰는 작성할 수 없습니다"),
91+
CANNOT_CREATE_REVIEW_WHEN_TEAM_IS_NOT_DONE(HttpStatus.CONFLICT, "REVIEW_409", "팀 활동이 종료되기 전에는 리뷰를 작성할 수 없습니다"),
92+
INVALID_REVIEW_LIST_SORT_PARAM(HttpStatus.BAD_REQUEST, "REVIEW_400", "리뷰 리스트 조회를 위한 정렬 조건이 잘못 되었습니다."),
93+
REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND, "REVIEW_404", "해당 리뷰 정보를 찾을 수 없습니다")
94+
8595
/**
8696
* Notification
8797
*/
@@ -90,4 +100,5 @@ enum class ErrorStatus(
90100
INITIAL_FIREBASE_INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "NOTIFICATION_500", "Firebase 인증 초기화 중 오류가 발생했습니다."),
91101
SEND_FCM_NOTIFICATION_INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "NOTIFICATION_500", "FCM 알림 전송 중 오류가 발생했습니다.")
92102

103+
93104
}

src/main/kotlin/gat_be/dev/common/status/SuccessStatus.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,13 @@ enum class SuccessStatus(
6363
* Notification
6464
*/
6565
MARK_NOTIFICATION_AS_READ_SUCCESS(HttpStatus.OK, "NOTIFICATION_200", "알림 읽음 표시 성공"),
66-
GET_NOTIFICATION_LIST_SUCCESS(HttpStatus.OK, "NOTIFICATION_200", "알림 리스트 조회 성공")
66+
GET_NOTIFICATION_LIST_SUCCESS(HttpStatus.OK, "NOTIFICATION_200", "알림 리스트 조회 성공"),
6767

68+
/**
69+
* Review
70+
*/
71+
CREATE_REVIEW_SUCCESS(HttpStatus.OK, "REVIEW_200", "리뷰 작성 성공"),
72+
GET_REVIEW_LIST_SUCCESS(HttpStatus.OK, "REVIEW_200", "리뷰 리스트 조회 성공"),
73+
GET_REVIEW_DETAIL_SUCCESS(HttpStatus.OK, "REVIEW_200", "리뷰 상세 조회 성공"),
74+
GET_REVIEW_EXISTS_SUCCESS(HttpStatus.OK, "REVIEW_200", "리뷰 작성 여부 조회 성공"),
6875
}
Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,87 @@
11
package gat_be.dev.domain.review.controller
22

3-
import gat_be.dev.domain.review.service.ReviewService
3+
import ApiResponse
4+
import gat_be.dev.common.status.SuccessStatus
5+
import gat_be.dev.domain.review.dto.request.CreateReviewRequest
6+
import gat_be.dev.domain.review.dto.response.ReviewDetailResponse
7+
import gat_be.dev.domain.review.dto.response.ReviewExistsResponse
8+
import gat_be.dev.domain.review.dto.response.ReviewListResponse
9+
import gat_be.dev.domain.review.facade.ReviewCreateFacade
10+
import gat_be.dev.domain.review.facade.ReviewDetailGetFacade
11+
import gat_be.dev.domain.review.facade.ReviewExistsGetFacade
12+
import gat_be.dev.domain.review.facade.ReviewListGetFacade
13+
import io.swagger.v3.oas.annotations.Operation
14+
import io.swagger.v3.oas.annotations.media.Content
15+
import io.swagger.v3.oas.annotations.media.Schema
16+
import io.swagger.v3.oas.annotations.tags.Tag
17+
import jakarta.validation.Valid
18+
import org.springframework.http.ResponseEntity
19+
import org.springframework.security.core.annotation.AuthenticationPrincipal
20+
import org.springframework.web.bind.annotation.GetMapping
21+
import org.springframework.web.bind.annotation.PathVariable
22+
import org.springframework.web.bind.annotation.PostMapping
23+
import org.springframework.web.bind.annotation.RequestBody
424
import org.springframework.web.bind.annotation.RequestMapping
25+
import org.springframework.web.bind.annotation.RequestParam
526
import org.springframework.web.bind.annotation.RestController
627

728
@RestController
8-
@RequestMapping() // TODO:
29+
@RequestMapping("/reviews")
30+
@Tag(name = "Review")
931
class ReviewController(
10-
private val reviewService: ReviewService
32+
private val reviewCreateFacade: ReviewCreateFacade,
33+
private val reviewListGetFacade: ReviewListGetFacade,
34+
private val reviewDetailGetFacade: ReviewDetailGetFacade,
35+
private val reviewExistsGetFacade: ReviewExistsGetFacade
1136
) {
37+
@PostMapping("")
38+
@Operation(summary = "새로운 리뷰 작성")
39+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "리뷰 작성 성공", content = [Content()])
40+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "리뷰 작성에 필요한 값들이 유효하지 않은 경우", content = [Content()])
41+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "해당 유저와 같은 팀이 아닌 경우", content = [Content()])
42+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "팀 활동이 종료되기 전 리뷰를 작성하거나 유저 스스로에게 리뷰를 쓰는 경우", content = [Content()])
43+
fun createReview(
44+
@AuthenticationPrincipal userId: Long,
45+
@Valid @RequestBody request: CreateReviewRequest
46+
): ResponseEntity<ApiResponse<Nothing>> {
47+
reviewCreateFacade.createReview(userId, request)
48+
return ApiResponse.success(SuccessStatus.CREATE_REVIEW_SUCCESS)
49+
}
1250

51+
@GetMapping("/list/{userId}")
52+
@Operation(summary = "리뷰 리스트 조회")
53+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "리뷰 리스트 조회 성공", content = [Content(mediaType = "application/json", schema = Schema(implementation = ReviewListResponse::class))])
54+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "리뷰 리스트 조회 파라미터가 올바르지 않은 경우", content = [Content()])
55+
fun getReviewList(
56+
@PathVariable userId: Long,
57+
@RequestParam(required = false, defaultValue = "createdAt") sort: String,
58+
@RequestParam(required = false, defaultValue = "asc") order: String
59+
): ResponseEntity<ApiResponse<ReviewListResponse>> {
60+
val reviewListResponse = reviewListGetFacade.getReviewList(userId, sort, order)
61+
return ApiResponse.success(SuccessStatus.GET_REVIEW_LIST_SUCCESS, reviewListResponse)
62+
}
63+
64+
@GetMapping("/{reviewId}")
65+
@Operation(summary = "리뷰 상세 조회")
66+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "리뷰 상세 조회 성공", content = [Content(mediaType = "application/json", schema = Schema(implementation = ReviewDetailResponse::class))])
67+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "해당 리뷰 혹은 팀원 정보를 찾을 수 없는 경우", content = [Content()])
68+
fun getReviewDetail(
69+
@AuthenticationPrincipal userId: Long,
70+
@PathVariable reviewId: Long
71+
): ResponseEntity<ApiResponse<ReviewDetailResponse>> {
72+
val reviewDetailResponse = reviewDetailGetFacade.getReviewDetail(reviewId)
73+
return ApiResponse.success(SuccessStatus.GET_REVIEW_DETAIL_SUCCESS, reviewDetailResponse)
74+
}
75+
76+
@GetMapping("/{teamId}/exists")
77+
@Operation(summary = "리뷰 작성 여부 조회")
78+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "리뷰 작성 여부 조회 성공", content = [Content(mediaType = "application/json", schema = Schema(implementation = ReviewExistsResponse::class))])
79+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "팀에 대한 접근 권한이 없는 경우", content = [Content()])
80+
fun getReviewExists(
81+
@AuthenticationPrincipal userId: Long,
82+
@PathVariable teamId: Long
83+
): ResponseEntity<ApiResponse<ReviewExistsResponse>> {
84+
val reviewExistsResponse = reviewExistsGetFacade.getReviewExists(userId, teamId)
85+
return ApiResponse.success(SuccessStatus.GET_REVIEW_EXISTS_SUCCESS, reviewExistsResponse)
86+
}
1387
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,37 @@
11
package gat_be.dev.domain.review.dto.request
22

3+
import jakarta.validation.constraints.*
4+
5+
data class CreateReviewRequest(
6+
@field:NotNull(message = "팀 멤버 ID는 필수입니다.")
7+
val teamMemberId: Long,
8+
9+
@field:NotNull(message = "모든 항목에 점수 입력은 필수입니다.")
10+
@field:Min(value = 1, message = "점수는 최소 1점입니다.")
11+
@field:Max(value = 5, message = "점수는 최대 5점입니다.")
12+
val timePunctualityScore: Int,
13+
14+
@field:NotNull(message = "모든 항목에 점수 입력은 필수입니다.")
15+
@field:Min(value = 1, message = "점수는 최소 1점입니다.")
16+
@field:Max(value = 5, message = "점수는 최대 5점입니다.")
17+
val communicationScore: Int,
18+
19+
@field:NotNull(message = "모든 항목에 점수 입력은 필수입니다.")
20+
@field:Min(value = 1, message = "점수는 최소 1점입니다.")
21+
@field:Max(value = 5, message = "점수는 최대 5점입니다.")
22+
val skillAndContributionScore: Int,
23+
24+
@field:NotNull(message = "모든 항목에 점수 입력은 필수입니다.")
25+
@field:Min(value = 1, message = "점수는 최소 1점입니다.")
26+
@field:Max(value = 5, message = "점수는 최대 5점입니다.")
27+
val collaborationAndTeamworkScore: Int,
28+
29+
@field:NotNull(message = "평점 입력은 필수입니다.")
30+
@field:Min(value = 1, message = "점수는 최소 1점입니다.")
31+
@field:Max(value = 5, message = "점수는 최대 5점입니다.")
32+
val rating: Double,
33+
34+
@field:NotBlank(message = "리뷰 내용을 입력해주세요.")
35+
@field:Size(max = 200, message = "내용은 200자를 초과할 수 없습니다.")
36+
val content: String
37+
)
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,89 @@
11
package gat_be.dev.domain.review.dto.response
22

3+
import gat_be.dev.domain.review.entity.Review
4+
import gat_be.dev.domain.review.entity.ReviewCategory
5+
import gat_be.dev.domain.review.enums.Category
6+
import gat_be.dev.domain.team.enums.TechStackRole
7+
8+
data class ReviewListResponse(
9+
val reviewList: List<ReviewInfo>
10+
)
11+
12+
data class ReviewInfo(
13+
val reviewId: Long,
14+
val rating: Double,
15+
val content: String,
16+
val teamName: String,
17+
val createdAt: String
18+
) {
19+
constructor(review: Review, teamName: String): this(
20+
reviewId = review.reviewId,
21+
rating = review.rating,
22+
content = review.content,
23+
teamName = teamName,
24+
createdAt = review.getCreatedAtFormatted()
25+
)
26+
}
27+
28+
data class ReviewDetailResponse(
29+
val title: String,
30+
val role: String,
31+
val rating: Double,
32+
val content: String,
33+
val score: CategoryScores,
34+
val createdAt: String
35+
) {
36+
companion object {
37+
fun from(review: Review,
38+
title: String,
39+
role: String
40+
): ReviewDetailResponse {
41+
return ReviewDetailResponse(
42+
title = title,
43+
role = role,
44+
rating = review.rating,
45+
content = review.content,
46+
createdAt = review.getCreatedAtDetailedFormatted(),
47+
score = CategoryScores.from(review.categories)
48+
)
49+
}
50+
}
51+
}
52+
53+
data class CategoryScores(
54+
val timePunctuality: Int,
55+
val communication: Int,
56+
val skillAndContribution: Int,
57+
val collaborationAndTeamwork: Int
58+
) {
59+
companion object {
60+
fun from(categories: List<ReviewCategory>): CategoryScores {
61+
val categoryMap = categories.associate { it.category to it.score }
62+
63+
return CategoryScores(
64+
timePunctuality = categoryMap[Category.TIME_PUNCTUALITY] ?: 0,
65+
communication = categoryMap[Category.COMMUNICATION] ?: 0,
66+
skillAndContribution = categoryMap[Category.SKILL_AND_CONTRIBUTION] ?: 0,
67+
collaborationAndTeamwork = categoryMap[Category.COLLABORATION_AND_TEAMWORK] ?: 0
68+
)
69+
}
70+
}
71+
}
72+
73+
data class ReviewExistsResponse(
74+
val exists: List<ReviewExistsInfo>
75+
)
76+
77+
data class ReviewExistsInfo(
78+
val name: String,
79+
val role: String,
80+
val teamMemberId: Long,
81+
val reviewExists: Boolean
82+
) {
83+
constructor(name: String, role: TechStackRole, teamMemberId: Long, reviewExists: Boolean): this(
84+
name = name,
85+
role = role.name,
86+
teamMemberId = teamMemberId,
87+
reviewExists = reviewExists
88+
)
89+
}

src/main/kotlin/gat_be/dev/domain/review/entity/Review.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package gat_be.dev.domain.review.entity
22

33
import gat_be.dev.common.base.BaseEntity
4+
import gat_be.dev.domain.review.enums.Category
45
import jakarta.persistence.*
56

67
@Entity
@@ -19,7 +20,7 @@ class Review(
1920

2021
// 1. Properties
2122
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
22-
private val reviewId: Long = 0L
23+
val reviewId: Long = 0L
2324

2425
@Column(nullable = false, columnDefinition = "DOUBLE DEFAULT 0.0")
2526
var rating: Double = rating
@@ -33,4 +34,8 @@ class Review(
3334
@OneToMany(mappedBy = "review", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
3435
var categories: MutableList<ReviewCategory> = mutableListOf()
3536

37+
// 3. Business logic
38+
fun getCategoryScore(category: Category): Int {
39+
return categories.find { it.category == category }?.score ?: 0
40+
}
3641
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package gat_be.dev.domain.review.facade
2+
3+
import gat_be.dev.common.exception.GeneralException
4+
import gat_be.dev.common.status.ErrorStatus
5+
import gat_be.dev.domain.review.dto.request.CreateReviewRequest
6+
import gat_be.dev.domain.review.service.ReviewService
7+
import gat_be.dev.domain.team.entity.Team
8+
import gat_be.dev.domain.team.entity.TeamMember
9+
import gat_be.dev.domain.team.service.TeamService
10+
import jakarta.transaction.Transactional
11+
import org.springframework.stereotype.Service
12+
13+
@Service
14+
class ReviewCreateFacade(
15+
private val reviewService: ReviewService,
16+
private val teamService: TeamService
17+
) {
18+
// 리뷰 작성하기
19+
@Transactional
20+
fun createReview(userId: Long, request: CreateReviewRequest) {
21+
val reviewee = teamService.findTeamMemberByTeamMemberId(request.teamMemberId)
22+
val targetTeam = reviewee.team
23+
validateIsTeamDone(targetTeam)
24+
25+
val reviewer = teamService.findTeamMemberByTeamAndUserId(targetTeam, userId)
26+
validateNotSelfReview(reviewer, reviewee)
27+
reviewService.validateDuplicateReview(reviewer.userId, reviewee.teamMemberId)
28+
29+
reviewService.createReview(userId, request)
30+
}
31+
32+
private fun validateIsTeamDone(targetTeam: Team) {
33+
if (!targetTeam.isDone()) {
34+
throw GeneralException(ErrorStatus.CANNOT_CREATE_REVIEW_WHEN_TEAM_IS_NOT_DONE)
35+
}
36+
}
37+
38+
private fun validateNotSelfReview(reviewer: TeamMember, reviewee: TeamMember) {
39+
if (reviewer.teamMemberId == reviewee.teamMemberId) {
40+
throw GeneralException(ErrorStatus.CANNOT_CREATE_REVIEW_OF_MYSELF)
41+
}
42+
}
43+
44+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package gat_be.dev.domain.review.facade
2+
3+
import gat_be.dev.domain.review.dto.response.ReviewDetailResponse
4+
import gat_be.dev.domain.review.service.ReviewService
5+
import gat_be.dev.domain.team.service.TeamService
6+
import org.springframework.stereotype.Service
7+
8+
@Service
9+
class ReviewDetailGetFacade(
10+
private val reviewService: ReviewService,
11+
private val teamService: TeamService
12+
) {
13+
// 리뷰 상세 조회
14+
fun getReviewDetail(reviewId: Long): ReviewDetailResponse {
15+
val review = reviewService.findReviewByReviewId(reviewId)
16+
val (team, role) = teamService.findTeamAndRoleByTeamMemberId(review.teamMemberId)
17+
18+
return ReviewDetailResponse.from(review, team.title, role.name)
19+
}
20+
21+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package gat_be.dev.domain.review.facade
2+
3+
import gat_be.dev.domain.review.dto.response.ReviewExistsResponse
4+
import gat_be.dev.domain.team.service.TeamService
5+
import jakarta.transaction.Transactional
6+
import org.springframework.stereotype.Service
7+
8+
@Service
9+
class ReviewExistsGetFacade (
10+
private val teamService: TeamService
11+
) {
12+
// 모든 팀원에 대한 리뷰 작성 여부 조회
13+
@Transactional
14+
fun getReviewExists(
15+
userId: Long,
16+
teamId: Long
17+
): ReviewExistsResponse {
18+
teamService.validateTeamMembership(userId, teamId)
19+
val teamMemberInfoList = teamService.findTeamMemberWithReviewStatusByTeamIdAndUserId(teamId, userId)
20+
return ReviewExistsResponse(teamMemberInfoList)
21+
}
22+
}

0 commit comments

Comments
 (0)