Skip to content

Commit 56dba42

Browse files
authored
Merge pull request #77 from prgrms-web-devcourse-final-project/feat/be/73
Rate도메인 기초 설계
2 parents 7117614 + f1b3743 commit 56dba42

File tree

10 files changed

+598
-182
lines changed

10 files changed

+598
-182
lines changed

docs/api-specification.yaml

Lines changed: 246 additions & 181 deletions
Large diffs are not rendered by default.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package com.back.koreaTravelGuide.domain.rate.controller
2+
3+
import com.back.koreaTravelGuide.common.ApiResponse
4+
import com.back.koreaTravelGuide.domain.rate.dto.GuideRatingSummaryResponse
5+
import com.back.koreaTravelGuide.domain.rate.dto.RateRequest
6+
import com.back.koreaTravelGuide.domain.rate.dto.RateResponse
7+
import com.back.koreaTravelGuide.domain.rate.service.RateService
8+
import io.swagger.v3.oas.annotations.Operation
9+
import org.springframework.data.domain.Page
10+
import org.springframework.data.domain.Pageable
11+
import org.springframework.http.ResponseEntity
12+
import org.springframework.security.access.prepost.PreAuthorize
13+
import org.springframework.security.core.annotation.AuthenticationPrincipal
14+
import org.springframework.web.bind.annotation.GetMapping
15+
import org.springframework.web.bind.annotation.PathVariable
16+
import org.springframework.web.bind.annotation.PutMapping
17+
import org.springframework.web.bind.annotation.RequestBody
18+
import org.springframework.web.bind.annotation.RequestMapping
19+
import org.springframework.web.bind.annotation.RestController
20+
21+
@RestController
22+
@RequestMapping("/api/rate")
23+
class RateController(
24+
private val rateService: RateService,
25+
) {
26+
@Operation(summary = "가이드 평가 생성/수정")
27+
@PutMapping("/guides/{guideId}")
28+
fun rateGuide(
29+
@AuthenticationPrincipal raterUserId: Long,
30+
@PathVariable guideId: Long,
31+
@RequestBody request: RateRequest,
32+
): ResponseEntity<ApiResponse<RateResponse>> {
33+
val rate = rateService.rateGuide(raterUserId, guideId, request.rating, request.comment)
34+
return ResponseEntity.ok(ApiResponse("가이드 평가가 등록되었습니다.", RateResponse.from(rate)))
35+
}
36+
37+
@Operation(summary = "AI 채팅 세션 평가 생성/수정")
38+
@PutMapping("/aichat/sessions/{sessionId}")
39+
fun rateAiSession(
40+
@AuthenticationPrincipal raterUserId: Long,
41+
@PathVariable sessionId: Long,
42+
@RequestBody request: RateRequest,
43+
): ResponseEntity<ApiResponse<RateResponse>> {
44+
val rate = rateService.rateAiSession(raterUserId, sessionId, request.rating, request.comment)
45+
return ResponseEntity.ok(ApiResponse("AI 채팅 평가가 등록되었습니다.", RateResponse.from(rate)))
46+
}
47+
48+
@Operation(summary = "내가 받은 가이드 평가 조회")
49+
@GetMapping("/guides/my")
50+
@PreAuthorize("hasRole('GUIDE')")
51+
fun getMyGuideRatings(
52+
@AuthenticationPrincipal guideId: Long,
53+
): ResponseEntity<ApiResponse<GuideRatingSummaryResponse>> {
54+
val summary = rateService.getMyGuideRatingSummary(guideId)
55+
return ResponseEntity.ok(ApiResponse("내 가이드 평점 정보를 조회했습니다.", summary))
56+
}
57+
58+
@Operation(summary = "관리자의 모든 AI 채팅 평가 조회")
59+
@GetMapping("/admin/aichat/sessions")
60+
@PreAuthorize("hasRole('ADMIN')")
61+
fun getAllAiSessionRatings(pageable: Pageable): ResponseEntity<ApiResponse<Page<RateResponse>>> {
62+
val ratingsPage = rateService.getAllAiSessionRatingsForAdmin(pageable)
63+
return ResponseEntity.ok(ApiResponse("모든 AI 채팅 평가 목록을 조회했습니다.", ratingsPage))
64+
}
65+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.back.koreaTravelGuide.domain.rate.dto
2+
3+
data class GuideRatingSummaryResponse(
4+
val averageRating: Double,
5+
val totalRatings: Int,
6+
val ratings: List<RateResponse>,
7+
)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.back.koreaTravelGuide.domain.rate.dto
2+
3+
import jakarta.validation.constraints.Max
4+
import jakarta.validation.constraints.Min
5+
6+
data class RateRequest(
7+
@field:Min(1, message = "평점은 1 이상이어야 합니다.")
8+
@field:Max(5, message = "평점은 5 이하여야 합니다.")
9+
val rating: Int,
10+
val comment: String?,
11+
)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.back.koreaTravelGuide.domain.rate.dto
2+
3+
import com.back.koreaTravelGuide.domain.rate.entity.Rate
4+
import java.time.ZonedDateTime
5+
6+
data class RateResponse(
7+
val id: Long,
8+
val raterNickname: String,
9+
val rating: Int,
10+
val comment: String?,
11+
val createdAt: ZonedDateTime,
12+
) {
13+
companion object {
14+
fun from(rate: Rate): RateResponse {
15+
return RateResponse(
16+
id = rate.id!!,
17+
raterNickname = rate.user.nickname,
18+
rating = rate.rating,
19+
comment = rate.comment,
20+
createdAt = rate.createdAt,
21+
)
22+
}
23+
}
24+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package com.back.koreaTravelGuide.domain.rate.entity
2+
3+
import com.back.koreaTravelGuide.domain.user.entity.User
4+
import jakarta.persistence.Column
5+
import jakarta.persistence.Entity
6+
import jakarta.persistence.EnumType
7+
import jakarta.persistence.Enumerated
8+
import jakarta.persistence.GeneratedValue
9+
import jakarta.persistence.GenerationType
10+
import jakarta.persistence.Id
11+
import jakarta.persistence.JoinColumn
12+
import jakarta.persistence.ManyToOne
13+
import jakarta.persistence.Table
14+
import jakarta.validation.constraints.Max
15+
import jakarta.validation.constraints.Min
16+
import java.time.ZoneId
17+
import java.time.ZonedDateTime
18+
19+
@Entity
20+
@Table(name = "rates")
21+
class Rate(
22+
@Id
23+
@GeneratedValue(strategy = GenerationType.IDENTITY)
24+
val id: Long? = null,
25+
@ManyToOne
26+
@JoinColumn(name = "user_id", nullable = false)
27+
val user: User,
28+
@Enumerated(EnumType.STRING)
29+
@Column(nullable = false)
30+
val targetType: RateTargetType,
31+
@Column(nullable = false)
32+
val targetId: Long,
33+
@Min(1)
34+
@Max(5)
35+
@Column(nullable = false)
36+
var rating: Int,
37+
@Column(columnDefinition = "Text")
38+
var comment: String?,
39+
@Column(nullable = false, updatable = false)
40+
val createdAt: ZonedDateTime = ZonedDateTime.now(ZoneId.of("Asia/Seoul")),
41+
@Column(nullable = false)
42+
var updatedAt: ZonedDateTime = ZonedDateTime.now(ZoneId.of("Asia/Seoul")),
43+
) {
44+
fun update(
45+
rating: Int,
46+
comment: String?,
47+
) {
48+
this.rating = rating
49+
this.comment = comment
50+
this.updatedAt = ZonedDateTime.now(ZoneId.of("Asia/Seoul"))
51+
}
52+
}
53+
54+
// /bigint id PK "Auto Increment"
55+
// bigint user_id FK "평가자 ID (GUEST만)"
56+
// enum target_type "AI_CHAT_SESSION, GUIDE"
57+
// bigint target_id "평가 대상 ID (세션 ID 또는 가이드 ID)"
58+
// int rating "평점 1-5"
59+
// text comment "평가 코멘트 (선택사항)"
60+
// datetime created_at "평가 일시"
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.back.koreaTravelGuide.domain.rate.entity
2+
3+
enum class RateTargetType {
4+
AI_SESSION,
5+
GUIDE,
6+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.back.koreaTravelGuide.domain.rate.repository
2+
3+
import com.back.koreaTravelGuide.domain.rate.entity.Rate
4+
import com.back.koreaTravelGuide.domain.rate.entity.RateTargetType
5+
import org.springframework.data.domain.Page
6+
import org.springframework.data.domain.Pageable
7+
import org.springframework.data.jpa.repository.JpaRepository
8+
import org.springframework.stereotype.Repository
9+
10+
@Repository
11+
interface RateRepository : JpaRepository<Rate, Long> {
12+
fun findByTargetTypeAndTargetIdAndUserId(
13+
targetType: RateTargetType,
14+
targetId: Long,
15+
userId: Long,
16+
): Rate?
17+
18+
fun findByTargetTypeAndTargetId(
19+
targetType: RateTargetType,
20+
targetId: Long,
21+
): List<Rate>
22+
23+
fun findByTargetType(
24+
targetType: RateTargetType,
25+
pageable: Pageable,
26+
): Page<Rate>
27+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package com.back.koreaTravelGuide.domain.rate.service
2+
3+
import com.back.koreaTravelGuide.domain.ai.aiChat.repository.AiChatSessionRepository
4+
import com.back.koreaTravelGuide.domain.rate.dto.GuideRatingSummaryResponse
5+
import com.back.koreaTravelGuide.domain.rate.dto.RateResponse
6+
import com.back.koreaTravelGuide.domain.rate.entity.Rate
7+
import com.back.koreaTravelGuide.domain.rate.entity.RateTargetType
8+
import com.back.koreaTravelGuide.domain.rate.repository.RateRepository
9+
import com.back.koreaTravelGuide.domain.user.enums.UserRole
10+
import com.back.koreaTravelGuide.domain.user.repository.UserRepository
11+
import org.springframework.data.domain.Page
12+
import org.springframework.data.domain.Pageable
13+
import org.springframework.data.repository.findByIdOrNull
14+
import org.springframework.stereotype.Service
15+
import org.springframework.transaction.annotation.Transactional
16+
17+
@Service
18+
@Transactional
19+
class RateService(
20+
private val rateRepository: RateRepository,
21+
private val userRepository: UserRepository,
22+
private val aiChatSessionRepository: AiChatSessionRepository,
23+
) {
24+
// 가이드 평가
25+
fun rateGuide(
26+
raterUserId: Long,
27+
guideId: Long,
28+
rating: Int,
29+
comment: String?,
30+
): Rate {
31+
val guide =
32+
userRepository.findByIdOrNull(guideId)
33+
?: throw NoSuchElementException("해당 가이드를 찾을 수 없습니다.")
34+
if (guide.role != UserRole.GUIDE) {
35+
throw IllegalArgumentException("평가 대상은 가이드이어야 합니다.")
36+
}
37+
38+
val rater =
39+
userRepository.findByIdOrNull(raterUserId)
40+
?: throw NoSuchElementException("평가자를 찾을 수 없습니다.")
41+
if (rater.role != UserRole.USER) {
42+
throw IllegalArgumentException("유저만 가이드를 평가할 수 있습니다.")
43+
}
44+
45+
val existingRate =
46+
rateRepository.findByTargetTypeAndTargetIdAndUserId(
47+
targetType = RateTargetType.GUIDE,
48+
targetId = guideId,
49+
userId = raterUserId,
50+
)
51+
52+
return if (existingRate != null) {
53+
existingRate.update(rating, comment)
54+
rateRepository.save(existingRate)
55+
} else {
56+
val newRate =
57+
Rate(
58+
user = rater,
59+
targetType = RateTargetType.GUIDE,
60+
targetId = guideId,
61+
rating = rating,
62+
comment = comment,
63+
)
64+
rateRepository.save(newRate)
65+
}
66+
}
67+
68+
// ai 평가
69+
fun rateAiSession(
70+
raterUserId: Long,
71+
sessionId: Long,
72+
rating: Int,
73+
comment: String?,
74+
): Rate {
75+
val session =
76+
aiChatSessionRepository.findByIdOrNull(sessionId)
77+
?: throw NoSuchElementException("해당 AI 채팅 세션을 찾을 수 없습니다.")
78+
79+
if (session.userId != raterUserId) {
80+
throw IllegalArgumentException("세션 소유자만 평가할 수 있습니다.")
81+
}
82+
83+
val rater =
84+
userRepository.findByIdOrNull(raterUserId)
85+
?: throw NoSuchElementException("평가자를 찾을 수 없습니다.")
86+
87+
val existingRate =
88+
rateRepository.findByTargetTypeAndTargetIdAndUserId(
89+
targetType = RateTargetType.AI_SESSION,
90+
targetId = sessionId,
91+
userId = raterUserId,
92+
)
93+
94+
return if (existingRate != null) {
95+
existingRate.update(rating, comment)
96+
rateRepository.save(existingRate)
97+
} else {
98+
val newRate =
99+
Rate(
100+
user = rater,
101+
targetType = RateTargetType.AI_SESSION,
102+
targetId = sessionId,
103+
rating = rating,
104+
comment = comment,
105+
)
106+
rateRepository.save(newRate)
107+
}
108+
}
109+
110+
// 가이드 평점 매기기
111+
@Transactional(readOnly = true)
112+
fun getGuideRatingSummary(guideId: Long): GuideRatingSummaryResponse {
113+
val ratings = rateRepository.findByTargetTypeAndTargetId(RateTargetType.GUIDE, guideId)
114+
115+
if (ratings.isEmpty()) {
116+
return GuideRatingSummaryResponse(0.0, 0, emptyList())
117+
}
118+
119+
val totalRatings = ratings.size
120+
val averageRating = ratings.sumOf { it.rating } / totalRatings.toDouble()
121+
122+
// 소수점 2자리에서 반올림
123+
val roundedAverage = String.format("%.1f", averageRating).toDouble()
124+
125+
val rateResponses = ratings.map { RateResponse.from(it) }
126+
127+
return GuideRatingSummaryResponse(
128+
averageRating = roundedAverage,
129+
totalRatings = totalRatings,
130+
ratings = rateResponses,
131+
)
132+
}
133+
134+
// 가이드 평점 조회
135+
@Transactional(readOnly = true)
136+
fun getMyGuideRatingSummary(guideId: Long): GuideRatingSummaryResponse {
137+
val guide =
138+
userRepository.findByIdOrNull(guideId)
139+
?: throw NoSuchElementException("사용자를 찾을 수 없습니다.")
140+
if (guide.role != UserRole.GUIDE) {
141+
throw IllegalArgumentException("가이드만 자신의 평점을 조회할 수 있습니다.")
142+
}
143+
return getGuideRatingSummary(guideId)
144+
}
145+
146+
// ai 평점 조회
147+
@Transactional(readOnly = true)
148+
fun getAllAiSessionRatingsForAdmin(pageable: Pageable): Page<RateResponse> {
149+
val ratingsPage = rateRepository.findByTargetType(RateTargetType.AI_SESSION, pageable)
150+
return ratingsPage.map { RateResponse.from(it) }
151+
}
152+
}

src/main/kotlin/com/back/koreaTravelGuide/domain/user/controller/GuideController.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ class GuideController(
3838
return ResponseEntity.ok(ApiResponse("가이드 정보를 성공적으로 조회했습니다.", guide))
3939
}
4040

41-
// 가이드는 회원가입 후 프로필 수정을 통해 필드 입력 받음
4241
@Operation(summary = "가이드 프로필 수정")
4342
@PreAuthorize("hasRole('GUIDE')")
4443
@PatchMapping("/me")

0 commit comments

Comments
 (0)