Skip to content
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
427 changes: 246 additions & 181 deletions docs/api-specification.yaml

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.back.koreaTravelGuide.domain.rate.controller

import com.back.koreaTravelGuide.common.ApiResponse
import com.back.koreaTravelGuide.domain.rate.dto.GuideRatingSummaryResponse
import com.back.koreaTravelGuide.domain.rate.dto.RateRequest
import com.back.koreaTravelGuide.domain.rate.dto.RateResponse
import com.back.koreaTravelGuide.domain.rate.service.RateService
import io.swagger.v3.oas.annotations.Operation
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/api/rate")
class RateController(
private val rateService: RateService,
) {
@Operation(summary = "가이드 평가 생성/수정")
@PutMapping("/guides/{guideId}")
fun rateGuide(
@AuthenticationPrincipal raterUserId: Long,
@PathVariable guideId: Long,
@RequestBody request: RateRequest,
): ResponseEntity<ApiResponse<RateResponse>> {
val rate = rateService.rateGuide(raterUserId, guideId, request.rating, request.comment)
return ResponseEntity.ok(ApiResponse("가이드 평가가 등록되었습니다.", RateResponse.from(rate)))
}

@Operation(summary = "AI 채팅 세션 평가 생성/수정")
@PutMapping("/aichat/sessions/{sessionId}")
fun rateAiSession(
@AuthenticationPrincipal raterUserId: Long,
@PathVariable sessionId: Long,
@RequestBody request: RateRequest,
): ResponseEntity<ApiResponse<RateResponse>> {
val rate = rateService.rateAiSession(raterUserId, sessionId, request.rating, request.comment)
return ResponseEntity.ok(ApiResponse("AI 채팅 평가가 등록되었습니다.", RateResponse.from(rate)))
}

@Operation(summary = "내가 받은 가이드 평가 조회")
@GetMapping("/guides/my")
@PreAuthorize("hasRole('GUIDE')")
fun getMyGuideRatings(
@AuthenticationPrincipal guideId: Long,
): ResponseEntity<ApiResponse<GuideRatingSummaryResponse>> {
val summary = rateService.getMyGuideRatingSummary(guideId)
return ResponseEntity.ok(ApiResponse("내 가이드 평점 정보를 조회했습니다.", summary))
}

@Operation(summary = "관리자의 모든 AI 채팅 평가 조회")
@GetMapping("/admin/aichat/sessions")
@PreAuthorize("hasRole('ADMIN')")
fun getAllAiSessionRatings(pageable: Pageable): ResponseEntity<ApiResponse<Page<RateResponse>>> {
val ratingsPage = rateService.getAllAiSessionRatingsForAdmin(pageable)
return ResponseEntity.ok(ApiResponse("모든 AI 채팅 평가 목록을 조회했습니다.", ratingsPage))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.back.koreaTravelGuide.domain.rate.dto

data class GuideRatingSummaryResponse(
val averageRating: Double,
val totalRatings: Int,
val ratings: List<RateResponse>,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.back.koreaTravelGuide.domain.rate.dto

import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min

data class RateRequest(
@field:Min(1, message = "평점은 1 이상이어야 합니다.")
@field:Max(5, message = "평점은 5 이하여야 합니다.")
val rating: Int,
val comment: String?,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.back.koreaTravelGuide.domain.rate.dto

import com.back.koreaTravelGuide.domain.rate.entity.Rate
import java.time.ZonedDateTime

data class RateResponse(
val id: Long,
val raterNickname: String,
val rating: Int,
val comment: String?,
val createdAt: ZonedDateTime,
) {
companion object {
fun from(rate: Rate): RateResponse {
return RateResponse(
id = rate.id!!,
raterNickname = rate.user.nickname,
rating = rate.rating,
comment = rate.comment,
createdAt = rate.createdAt,
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.back.koreaTravelGuide.domain.rate.entity

import com.back.koreaTravelGuide.domain.user.entity.User
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import java.time.ZoneId
import java.time.ZonedDateTime

@Entity
@Table(name = "rates")
class Rate(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@ManyToOne
@JoinColumn(name = "user_id", nullable = false)
val user: User,
@Enumerated(EnumType.STRING)
@Column(nullable = false)
val targetType: RateTargetType,
@Column(nullable = false)
val targetId: Long,
@Min(1)
@Max(5)
@Column(nullable = false)
var rating: Int,
@Column(columnDefinition = "Text")
var comment: String?,
@Column(nullable = false, updatable = false)
val createdAt: ZonedDateTime = ZonedDateTime.now(ZoneId.of("Asia/Seoul")),
@Column(nullable = false)
var updatedAt: ZonedDateTime = ZonedDateTime.now(ZoneId.of("Asia/Seoul")),
) {
fun update(
rating: Int,
comment: String?,
) {
this.rating = rating
this.comment = comment
this.updatedAt = ZonedDateTime.now(ZoneId.of("Asia/Seoul"))
}
}

// /bigint id PK "Auto Increment"
// bigint user_id FK "평가자 ID (GUEST만)"
// enum target_type "AI_CHAT_SESSION, GUIDE"
// bigint target_id "평가 대상 ID (세션 ID 또는 가이드 ID)"
// int rating "평점 1-5"
// text comment "평가 코멘트 (선택사항)"
// datetime created_at "평가 일시"
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.back.koreaTravelGuide.domain.rate.entity

enum class RateTargetType {
AI_SESSION,
GUIDE,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.back.koreaTravelGuide.domain.rate.repository

import com.back.koreaTravelGuide.domain.rate.entity.Rate
import com.back.koreaTravelGuide.domain.rate.entity.RateTargetType
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository

@Repository
interface RateRepository : JpaRepository<Rate, Long> {
fun findByTargetTypeAndTargetIdAndUserId(
targetType: RateTargetType,
targetId: Long,
userId: Long,
): Rate?

fun findByTargetTypeAndTargetId(
targetType: RateTargetType,
targetId: Long,
): List<Rate>

fun findByTargetType(
targetType: RateTargetType,
pageable: Pageable,
): Page<Rate>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package com.back.koreaTravelGuide.domain.rate.service

import com.back.koreaTravelGuide.domain.ai.aiChat.repository.AiChatSessionRepository
import com.back.koreaTravelGuide.domain.rate.dto.GuideRatingSummaryResponse
import com.back.koreaTravelGuide.domain.rate.dto.RateResponse
import com.back.koreaTravelGuide.domain.rate.entity.Rate
import com.back.koreaTravelGuide.domain.rate.entity.RateTargetType
import com.back.koreaTravelGuide.domain.rate.repository.RateRepository
import com.back.koreaTravelGuide.domain.user.enums.UserRole
import com.back.koreaTravelGuide.domain.user.repository.UserRepository
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
@Transactional
class RateService(
private val rateRepository: RateRepository,
private val userRepository: UserRepository,
private val aiChatSessionRepository: AiChatSessionRepository,
) {
// 가이드 평가
fun rateGuide(
raterUserId: Long,
guideId: Long,
rating: Int,
comment: String?,
): Rate {
val guide =
userRepository.findByIdOrNull(guideId)
?: throw NoSuchElementException("해당 가이드를 찾을 수 없습니다.")
if (guide.role != UserRole.GUIDE) {
throw IllegalArgumentException("평가 대상은 가이드이어야 합니다.")
}

val rater =
userRepository.findByIdOrNull(raterUserId)
?: throw NoSuchElementException("평가자를 찾을 수 없습니다.")
if (rater.role != UserRole.USER) {
throw IllegalArgumentException("유저만 가이드를 평가할 수 있습니다.")
}

val existingRate =
rateRepository.findByTargetTypeAndTargetIdAndUserId(
targetType = RateTargetType.GUIDE,
targetId = guideId,
userId = raterUserId,
)

return if (existingRate != null) {
existingRate.update(rating, comment)
rateRepository.save(existingRate)
} else {
val newRate =
Rate(
user = rater,
targetType = RateTargetType.GUIDE,
targetId = guideId,
rating = rating,
comment = comment,
)
rateRepository.save(newRate)
}
}

// ai 평가
fun rateAiSession(
raterUserId: Long,
sessionId: Long,
rating: Int,
comment: String?,
): Rate {
val session =
aiChatSessionRepository.findByIdOrNull(sessionId)
?: throw NoSuchElementException("해당 AI 채팅 세션을 찾을 수 없습니다.")

if (session.userId != raterUserId) {
throw IllegalArgumentException("세션 소유자만 평가할 수 있습니다.")
}

val rater =
userRepository.findByIdOrNull(raterUserId)
?: throw NoSuchElementException("평가자를 찾을 수 없습니다.")

val existingRate =
rateRepository.findByTargetTypeAndTargetIdAndUserId(
targetType = RateTargetType.AI_SESSION,
targetId = sessionId,
userId = raterUserId,
)

return if (existingRate != null) {
existingRate.update(rating, comment)
rateRepository.save(existingRate)
} else {
val newRate =
Rate(
user = rater,
targetType = RateTargetType.AI_SESSION,
targetId = sessionId,
rating = rating,
comment = comment,
)
rateRepository.save(newRate)
}
}

// 가이드 평점 매기기
@Transactional(readOnly = true)
fun getGuideRatingSummary(guideId: Long): GuideRatingSummaryResponse {
val ratings = rateRepository.findByTargetTypeAndTargetId(RateTargetType.GUIDE, guideId)

if (ratings.isEmpty()) {
return GuideRatingSummaryResponse(0.0, 0, emptyList())
}

val totalRatings = ratings.size
val averageRating = ratings.sumOf { it.rating } / totalRatings.toDouble()

// 소수점 2자리에서 반올림
val roundedAverage = String.format("%.1f", averageRating).toDouble()

val rateResponses = ratings.map { RateResponse.from(it) }

return GuideRatingSummaryResponse(
averageRating = roundedAverage,
totalRatings = totalRatings,
ratings = rateResponses,
)
}

// 가이드 평점 조회
@Transactional(readOnly = true)
fun getMyGuideRatingSummary(guideId: Long): GuideRatingSummaryResponse {
val guide =
userRepository.findByIdOrNull(guideId)
?: throw NoSuchElementException("사용자를 찾을 수 없습니다.")
if (guide.role != UserRole.GUIDE) {
throw IllegalArgumentException("가이드만 자신의 평점을 조회할 수 있습니다.")
}
return getGuideRatingSummary(guideId)
}

// ai 평점 조회
@Transactional(readOnly = true)
fun getAllAiSessionRatingsForAdmin(pageable: Pageable): Page<RateResponse> {
val ratingsPage = rateRepository.findByTargetType(RateTargetType.AI_SESSION, pageable)
return ratingsPage.map { RateResponse.from(it) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ class GuideController(
return ResponseEntity.ok(ApiResponse("가이드 정보를 성공적으로 조회했습니다.", guide))
}

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