diff --git a/docs/api-specification.yaml b/docs/api-specification.yaml index ce5c59c..33063d2 100644 --- a/docs/api-specification.yaml +++ b/docs/api-specification.yaml @@ -12,8 +12,10 @@ servers: description: 로컬 개발 서버 tags: + - name: auth + description: 인증 및 권한 관리 - name: users - description: 사용자 관리 (OAuth 인증) + description: 사용자 정보 관리 - name: aichat description: AI 채팅 도메인 - name: userchat @@ -32,7 +34,37 @@ components: data: type: object description: 응답 데이터 + nullable: true + # Auth Schemas + UserRole: + type: string + enum: [USER, ADMIN, GUIDE, PENDING] + description: 사용자 역할 + + UserRoleUpdateRequest: + type: object + required: + - role + properties: + role: + $ref: '#/components/schemas/UserRole' + + LoginResponse: + type: object + properties: + accessToken: + type: string + description: JWT Access Token + + AccessTokenResponse: + type: object + properties: + accessToken: + type: string + description: 새로 발급된 JWT Access Token + + # User Schemas User: type: object properties: @@ -40,13 +72,6 @@ components: type: integer format: int64 description: 사용자 고유 식별자 - oauthProvider: - type: string - enum: [google, kakao, naver] - description: OAuth 제공자 - oauthId: - type: string - description: OAuth 제공자별 고유 ID email: type: string format: email @@ -57,14 +82,17 @@ components: profileImageUrl: type: string format: uri + nullable: true description: 프로필 이미지 URL - userType: - $ref: '#/components/schemas/UserType' + role: + $ref: '#/components/schemas/UserRole' location: type: string + nullable: true description: 가이드 활동 지역 (GUIDE만) description: type: string + nullable: true description: 사용자 소개글 createdAt: type: string @@ -75,10 +103,34 @@ components: format: date-time description: 최종 로그인 일시 - UserType: - type: string - enum: [GUEST, GUIDE, ADMIN] - description: 사용자 유형 + UserResponse: + type: object + properties: + id: + type: integer + format: int64 + email: + type: string + format: email + nickname: + type: string + profileImageUrl: + type: string + format: uri + nullable: true + role: + $ref: '#/components/schemas/UserRole' + + UserUpdateRequest: + type: object + properties: + nickname: + type: string + nullable: true + profileImageUrl: + type: string + format: uri + nullable: true # AI Chat Domain Schemas AiChatSession: @@ -181,6 +233,11 @@ components: description: 읽음 상태 # Rating Schemas + RateTargetType: + type: string + enum: [AI_SESSION, GUIDE] + description: 평가 대상 타입 + Rate: type: object properties: @@ -188,18 +245,15 @@ components: type: integer format: int64 description: 평가 고유 식별자 - userId: - type: integer - format: int64 - description: 평가자 ID (GUEST만) + user: + $ref: '#/components/schemas/UserResponse' + description: 평가를 남긴 사용자 targetType: - type: string - enum: [AI_CHAT_SESSION, CHAT_ROOM] - description: 평가 대상 타입 + $ref: '#/components/schemas/RateTargetType' targetId: type: integer format: int64 - description: 평가 대상 ID + description: 평가 대상의 ID rating: type: integer minimum: 1 @@ -207,12 +261,56 @@ components: description: 평점 1-5 comment: type: string - description: 평가 코멘트 (선택사항) + nullable: true + description: 평가 코멘트 createdAt: type: string format: date-time description: 평가 일시 + RateRequest: + type: object + required: + - rating + properties: + rating: + type: integer + minimum: 1 + maximum: 5 + comment: + type: string + nullable: true + + RateResponse: + type: object + properties: + id: + type: integer + format: int64 + raterNickname: + type: string + rating: + type: integer + comment: + type: string + nullable: true + createdAt: + type: string + format: date-time + + GuideRatingSummaryResponse: + type: object + properties: + averageRating: + type: number + format: double + totalRatings: + type: integer + ratings: + type: array + items: + $ref: '#/components/schemas/RateResponse' + securitySchemes: BearerAuth: type: http @@ -224,37 +322,25 @@ security: paths: # =================== - # User Management APIs + # Auth APIs # =================== - /api/users/oauth/login: + /api/auth/role: post: tags: - - users - summary: OAuth 로그인 - description: Google, Kakao, Naver OAuth 로그인 처리 + - auth + summary: 신규 사용자 역할 선택 + description: OAuth 최초 로그인 후, 사용자의 역할을 선택하고 정식으로 가입을 완료합니다. + security: + - BearerAuth: [] requestBody: required: true content: application/json: schema: - type: object - required: - - provider - - code - properties: - provider: - type: string - enum: [google, kakao, naver] - description: OAuth 제공자 - code: - type: string - description: OAuth 인증 코드 - userType: - $ref: '#/components/schemas/UserType' - description: 사용자 유형 (첫 로그인 시 필요) + $ref: '#/components/schemas/UserRoleUpdateRequest' responses: '200': - description: 로그인 성공 + description: 역할 선택 및 로그인 성공 content: application/json: schema: @@ -263,24 +349,36 @@ paths: - type: object properties: data: - allOf: - - $ref: '#/components/schemas/User' - - type: object - properties: - token: - type: string - description: JWT 토큰 - '400': - description: 잘못된 요청 - - /api/users/profile: - get: + $ref: '#/components/schemas/LoginResponse' + '401': + description: 인증 실패 (유효하지 않은 registerToken) + + /api/auth/logout: + post: tags: - - users - summary: 내 프로필 조회 + - auth + summary: 로그아웃 + description: 현재 사용자의 Access Token을 만료시켜 로그아웃 처리합니다. + security: + - BearerAuth: [] responses: '200': - description: 프로필 조회 성공 + description: 로그아웃 성공 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + + /api/auth/refresh: + post: + tags: + - auth + summary: Access Token 재발급 + description: 쿠키에 담긴 Refresh Token을 사용하여 만료된 Access Token을 재발급합니다. + security: [] + responses: + '200': + description: 토큰 재발급 성공 content: application/json: schema: @@ -289,35 +387,24 @@ paths: - type: object properties: data: - allOf: - - $ref: '#/components/schemas/User' - - type: object - properties: - averageRating: - type: number - format: float - description: 평균 평점 (GUIDE인 경우만) - totalRatings: - type: integer - description: 총 평가 수 (GUIDE인 경우만) - - /api/users/{userId}/profile: + $ref: '#/components/schemas/AccessTokenResponse' + '401': + description: 유효하지 않은 Refresh Token + + # =================== + # User APIs + # =================== + /api/users/me: get: tags: - users - summary: 사용자 프로필 조회 - description: 특정 사용자의 프로필을 조회합니다. GUIDE인 경우 평균 평점도 포함됩니다. - parameters: - - name: userId - in: path - required: true - schema: - type: integer - format: int64 - description: 사용자 ID + summary: 내 정보 조회 + description: 현재 로그인된 사용자의 프로필 정보를 조회합니다. + security: + - BearerAuth: [] responses: '200': - description: 사용자 프로필 조회 성공 + description: 내 정보 조회 성공 content: application/json: schema: @@ -326,48 +413,46 @@ paths: - type: object properties: data: - allOf: - - $ref: '#/components/schemas/User' - - type: object - properties: - averageRating: - type: number - format: float - description: 평균 평점 (GUIDE인 경우만) - totalRatings: - type: integer - description: 총 평가 수 (GUIDE인 경우만) - '404': - description: 사용자를 찾을 수 없음 - - /api/users/logout: - post: + $ref: '#/components/schemas/UserResponse' + patch: tags: - users - summary: 로그아웃 + summary: 내 프로필 수정 + description: 현재 로그인된 사용자의 프로필 정보(닉네임, 프로필 이미지)를 수정합니다. + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserUpdateRequest' responses: '200': - description: 로그아웃 완료 + description: 프로필 수정 성공 content: application/json: schema: - $ref: '#/components/schemas/ApiResponse' - - /api/users/withdrawal: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/UserResponse' delete: tags: - users - summary: 회원탈퇴 - description: 사용자 계정을 영구적으로 삭제합니다 (모든 채팅 기록 포함) + summary: 회원 탈퇴 + description: 현재 로그인된 사용자 계정을 영구적으로 삭제합니다. + security: + - BearerAuth: [] responses: '200': - description: 회원탈퇴 완료 + description: 회원 탈퇴 성공 content: application/json: schema: $ref: '#/components/schemas/ApiResponse' - '401': - description: 인증되지 않은 사용자 # =================== # AI Chat Domain APIs @@ -725,37 +810,28 @@ paths: # =================== # Rate Domain APIs # =================== - /api/rate/aichat/sessions/{sessionId}: + /api/rate/guides/{guideId}: put: tags: - rate - summary: AI 세션 평가 - description: AI 채팅 세션에 대한 평가를 생성하거나 수정합니다 (세션 소유자만 가능) + summary: 가이드 평가 생성/수정 + description: 특정 가이드에 대한 평점을 생성하거나 수정합니다. (USER만 가능) + security: + - BearerAuth: [] parameters: - - name: sessionId + - name: guideId in: path required: true schema: type: integer format: int64 - description: AI 채팅 세션 ID + description: 평가할 가이드의 ID requestBody: required: true content: application/json: schema: - type: object - required: - - rating - properties: - rating: - type: integer - minimum: 1 - maximum: 5 - description: 평점 1-5 - comment: - type: string - description: 평가 코멘트 (선택사항) + $ref: '#/components/schemas/RateRequest' responses: '200': description: 평가 성공 @@ -767,46 +843,33 @@ paths: - type: object properties: data: - $ref: '#/components/schemas/Rate' - '403': - description: 권한 없음 (세션 소유자만 평가 가능) - '404': - description: 세션을 찾을 수 없음 + $ref: '#/components/schemas/RateResponse' - /api/rate/guides/{guideId}: + /api/rate/aichat/sessions/{sessionId}: put: tags: - rate - summary: 가이드 평가 - description: 가이드에 대한 평가를 생성하거나 수정합니다 (Guest만 가능) + summary: AI 채팅 세션 평가 생성/수정 + description: 특정 AI 채팅 세션에 대한 평가를 생성하거나 수정합니다. (세션 소유자만 가능) + security: + - BearerAuth: [] parameters: - - name: guideId + - name: sessionId in: path required: true schema: type: integer format: int64 - description: 가이드 ID + description: 평가할 AI 채팅 세션의 ID requestBody: required: true content: application/json: schema: - type: object - required: - - rating - properties: - rating: - type: integer - minimum: 1 - maximum: 5 - description: 평점 1-5 - comment: - type: string - description: 평가 코멘트 (선택사항) + $ref: '#/components/schemas/RateRequest' responses: '200': - description: 가이드 평가 성공 + description: 평가 성공 content: application/json: schema: @@ -815,21 +878,19 @@ paths: - type: object properties: data: - $ref: '#/components/schemas/Rate' - '403': - description: 권한 없음 (Guest만 평가 가능) - '404': - description: 가이드를 찾을 수 없음 + $ref: '#/components/schemas/RateResponse' - /api/rate/admin/aichat/sessions: + /api/rate/guides/my: get: tags: - rate - summary: 모든 AI 세션 평가 조회 (ADMIN만) - description: 관리자가 모든 AI 채팅 세션 평가를 조회합니다 + summary: 내가 받은 가이드 평가 조회 + description: 현재 로그인한 가이드가 자신이 받은 모든 평가 요약과 목록을 조회합니다. (GUIDE만 가능) + security: + - BearerAuth: [] responses: '200': - description: AI 세션 평가 목록 조회 성공 + description: 내 평가 목록 조회 성공 content: application/json: schema: @@ -838,21 +899,37 @@ paths: - type: object properties: data: - type: array - items: - $ref: '#/components/schemas/Rate' - '403': - description: 권한 없음 (ADMIN만 접근 가능) + $ref: '#/components/schemas/GuideRatingSummaryResponse' - /api/rate/guides/my: + /api/rate/admin/aichat/sessions: get: tags: - rate - summary: 내가 받은 가이드 평가 조회 (GUIDE만) - description: 가이드가 자신이 받은 모든 평가를 조회합니다 + summary: 관리자의 모든 AI 채팅 평가 조회 + description: 관리자가 모든 AI 채팅 세션의 평가 목록을 페이지 단위로 조회합니다. + security: + - BearerAuth: [] + parameters: + - in: query + name: page + schema: + type: integer + description: 페이지 번호 (0부터 시작) + default: 0 + - in: query + name: size + schema: + type: integer + description: 페이지당 개수 + default: 20 + - in: query + name: sort + schema: + type: string + description: '정렬 기준 (예: createdAt,desc)' responses: '200': - description: 내 평가 목록 조회 성공 + description: AI 채팅 평가 목록 조회 성공 content: application/json: schema: @@ -862,19 +939,7 @@ paths: properties: data: type: object - properties: - averageRating: - type: number - format: float - description: 평균 평점 - totalRatings: - type: integer - description: 총 평가 수 - ratings: - type: array - items: - $ref: '#/components/schemas/Rate' - '403': - description: 권한 없음 (GUIDE만 접근 가능) + description: Page 형태 + diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/rate/controller/RateController.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/rate/controller/RateController.kt new file mode 100644 index 0000000..ca617d4 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/rate/controller/RateController.kt @@ -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> { + 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> { + 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> { + 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>> { + val ratingsPage = rateService.getAllAiSessionRatingsForAdmin(pageable) + return ResponseEntity.ok(ApiResponse("모든 AI 채팅 평가 목록을 조회했습니다.", ratingsPage)) + } +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/rate/dto/GuideRatingSummaryResponse.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/rate/dto/GuideRatingSummaryResponse.kt new file mode 100644 index 0000000..fdb8657 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/rate/dto/GuideRatingSummaryResponse.kt @@ -0,0 +1,7 @@ +package com.back.koreaTravelGuide.domain.rate.dto + +data class GuideRatingSummaryResponse( + val averageRating: Double, + val totalRatings: Int, + val ratings: List, +) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/rate/dto/RateRequest.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/rate/dto/RateRequest.kt new file mode 100644 index 0000000..26f21b5 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/rate/dto/RateRequest.kt @@ -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?, +) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/rate/dto/RateResponse.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/rate/dto/RateResponse.kt new file mode 100644 index 0000000..ac21c7f --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/rate/dto/RateResponse.kt @@ -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, + ) + } + } +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/rate/entity/Rate.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/rate/entity/Rate.kt new file mode 100644 index 0000000..514c8c5 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/rate/entity/Rate.kt @@ -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 "평가 일시" diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/rate/entity/RateTargetType.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/rate/entity/RateTargetType.kt new file mode 100644 index 0000000..08a9819 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/rate/entity/RateTargetType.kt @@ -0,0 +1,6 @@ +package com.back.koreaTravelGuide.domain.rate.entity + +enum class RateTargetType { + AI_SESSION, + GUIDE, +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/rate/repository/RateRepository.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/rate/repository/RateRepository.kt new file mode 100644 index 0000000..51b17a2 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/rate/repository/RateRepository.kt @@ -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 { + fun findByTargetTypeAndTargetIdAndUserId( + targetType: RateTargetType, + targetId: Long, + userId: Long, + ): Rate? + + fun findByTargetTypeAndTargetId( + targetType: RateTargetType, + targetId: Long, + ): List + + fun findByTargetType( + targetType: RateTargetType, + pageable: Pageable, + ): Page +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/rate/service/RateService.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/rate/service/RateService.kt new file mode 100644 index 0000000..57d484b --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/rate/service/RateService.kt @@ -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 { + val ratingsPage = rateRepository.findByTargetType(RateTargetType.AI_SESSION, pageable) + return ratingsPage.map { RateResponse.from(it) } + } +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/user/controller/GuideController.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/user/controller/GuideController.kt index 470c69b..5944fb7 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/user/controller/GuideController.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/user/controller/GuideController.kt @@ -38,7 +38,6 @@ class GuideController( return ResponseEntity.ok(ApiResponse("가이드 정보를 성공적으로 조회했습니다.", guide)) } - // 가이드는 회원가입 후 프로필 수정을 통해 필드 입력 받음 @Operation(summary = "가이드 프로필 수정") @PreAuthorize("hasRole('GUIDE')") @PatchMapping("/me")