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
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,16 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
"/actuator/health"
).permitAll()
.requestMatchers(HttpMethod.GET, "/api/artists/following").hasAnyRole("USER", "ADMIN")
.requestMatchers(HttpMethod.GET,
"/api/concerts/*/rating/me",
"/api/releases/*/rating/me",
"/api/mentions/search"
).hasAnyRole("USER", "ADMIN")
.requestMatchers(HttpMethod.GET,
"/api/artists/**",
"/api/concerts/**",
"/api/releases/**",
"/api/calendar",
"/api/mentions/search",
"/api/posts/**",
"/api/notices/**",
"/api/search",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public enum ErrorCode {
ALREADY_IN_CALENDAR(HttpStatus.CONFLICT, "이미 캘린더에 추가된 공연입니다."),
NOT_IN_CALENDAR(HttpStatus.BAD_REQUEST, "캘린더에 없는 공연입니다."),
CONCERT_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 존재하는 공연입니다."),
CONCERT_NOT_ENDED(HttpStatus.BAD_REQUEST, "공연 종료 후 별점을 등록할 수 있습니다."),

// Release
RELEASE_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 릴리즈입니다."),
Expand Down Expand Up @@ -70,6 +71,11 @@ public enum ErrorCode {
REPORT_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 신고한 대상입니다."),
REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 신고입니다."),

// Rating
RATING_TARGET_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 평가 대상입니다."),
INVALID_RATING_SCORE(HttpStatus.BAD_REQUEST, "별점은 0.5~5.0 사이 0.5 단위여야 합니다."),
RATING_NOT_FOUND(HttpStatus.NOT_FOUND, "등록된 별점이 없습니다."),

// Pipeline
PIPELINE_NOT_FOUND(HttpStatus.NOT_FOUND, "Data 파이프라인에서 해당 리소스를 찾을 수 없습니다."),
PIPELINE_CONFLICT(HttpStatus.CONFLICT, "이미 처리 중인 수집 요청입니다. 잠시 후 다시 확인해주세요."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@
import com.Coming.Backend.concert.entity.ConcertStatus;
import com.Coming.Backend.concert.exception.UnauthorizedException;
import com.Coming.Backend.concert.service.ConcertService;
import com.Coming.Backend.rating.dto.RatingMeResponse;
import com.Coming.Backend.rating.dto.RatingUpsertRequest;
import com.Coming.Backend.rating.entity.RatingTargetType;
import com.Coming.Backend.rating.service.RatingService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.RequiredArgsConstructor;
Expand All @@ -20,8 +25,11 @@
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
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.RequestParam;
import org.springframework.validation.annotation.Validated;
Expand All @@ -40,6 +48,7 @@ public class ConcertController {
private static final Set<String> SORTABLE_PROPERTIES = Set.of("startDate", "ticketOpenAt");

private final ConcertService concertService;
private final RatingService ratingService;

@Operation(summary = "공연 목록 조회")
@ApiResponse(responseCode = "400", description = "INVALID_INPUT (허용되지 않은 sort 필드)")
Expand Down Expand Up @@ -98,4 +107,35 @@ public ResponseEntity<List<ConcertSummaryResponse>> getTicketingConcerts(
public ResponseEntity<SetlistResponse> getSetlist(@PathVariable Long id) {
return ResponseEntity.ok(concertService.getSetlist(id));
}

@Operation(summary = "공연 별점 등록·수정")
@ApiResponse(responseCode = "400", description = "INVALID_RATING_SCORE")
@ApiResponse(responseCode = "404", description = "RATING_TARGET_NOT_FOUND")
@PutMapping("/{id}/rating")
public ResponseEntity<Void> upsertRating(
@PathVariable Long id,
@AuthenticationPrincipal Long userId,
@RequestBody @Valid RatingUpsertRequest request) {
ratingService.upsert(userId, RatingTargetType.CONCERT, id, request.score());
return ResponseEntity.ok().build();
}

@Operation(summary = "내 공연 별점 조회")
@ApiResponse(responseCode = "401", description = "UNAUTHORIZED")
@GetMapping("/{id}/rating/me")
public ResponseEntity<RatingMeResponse> getMyRating(
@PathVariable Long id,
@AuthenticationPrincipal Long userId) {
return ResponseEntity.ok(ratingService.getMine(userId, RatingTargetType.CONCERT, id));
}

@Operation(summary = "공연 별점 취소")
@ApiResponse(responseCode = "404", description = "RATING_NOT_FOUND")
@DeleteMapping("/{id}/rating")
public ResponseEntity<Void> deleteRating(
@PathVariable Long id,
@AuthenticationPrincipal Long userId) {
ratingService.delete(userId, RatingTargetType.CONCERT, id);
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public record ConcertDetailResponse(
String price,
boolean isInCalendar,
LocalDateTime ticketOpenAt,
List<TicketLinkDto> ticketLinks
List<TicketLinkDto> ticketLinks,
Double averageRating,
long ratingCount
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ public record ConcertSummaryResponse(
String venue,
ConcertStatus status,
boolean isInCalendar,
LocalDateTime ticketOpenAt
LocalDateTime ticketOpenAt,
Double averageRating,
long ratingCount
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.Coming.Backend.concert.exception;

import com.Coming.Backend.common.exception.BusinessException;
import com.Coming.Backend.common.exception.ErrorCode;

public class ConcertNotEndedException extends BusinessException {

public ConcertNotEndedException() {
super(ErrorCode.CONCERT_NOT_ENDED);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
import com.Coming.Backend.concert.repository.ConcertRepository;
import com.Coming.Backend.concert.repository.SetlistRepository;
import com.Coming.Backend.concert.repository.SetlistTrackRepository;
import com.Coming.Backend.rating.dto.RatingSummary;
import com.Coming.Backend.rating.entity.RatingTargetType;
import com.Coming.Backend.rating.service.RatingService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
Expand Down Expand Up @@ -64,6 +67,7 @@ public class ConcertService {
private final UserFollowArtistRepository userFollowArtistRepository;
private final SetlistRepository setlistRepository;
private final SetlistTrackRepository setlistTrackRepository;
private final RatingService ratingService;

/**
* 예정·진행 중 공연을 우선 노출하고 이후 조회수 순으로 상위 10건의 인기 공연 목록을 반환한다.
Expand Down Expand Up @@ -211,6 +215,10 @@ public ConcertDetailResponse getConcert(Long id, Long userId) {
boolean isInCalendar = userId != null &&
userConcertCalendarRepository.existsByUserIdAndConcertId(userId, id);

RatingSummary ratingSummary = ratingService
.getSummaries(RatingTargetType.CONCERT, List.of(id))
.getOrDefault(id, RatingSummary.empty());

return new ConcertDetailResponse(
concert.getId(),
concert.getPosterUrl(),
Expand All @@ -224,7 +232,9 @@ public ConcertDetailResponse getConcert(Long id, Long userId) {
concert.getPrice(),
isInCalendar,
concert.getTicketOpenAt(),
buildTicketLinks(id)
buildTicketLinks(id),
ratingSummary.averageRating(),
ratingSummary.ratingCount()
);
}

Expand All @@ -240,10 +250,14 @@ private List<ConcertSummaryResponse> toConcertSummaryList(List<Concert> concerts
Map<Long, String> artistNameMap = buildArtistNameMap(allArtistIds);
Map<Long, String> koreanNameMap = buildKoreanNameMap(List.copyOf(allArtistIds));
Set<Long> calendarConcertIds = buildCalendarConcertIds(userId, concertIds);
Map<Long, RatingSummary> ratingSummaryMap =
ratingService.getSummaries(RatingTargetType.CONCERT, concertIds);
return concerts.stream().map(concert -> {
List<ArtistSummary> artists = concertToArtistIds.getOrDefault(concert.getId(), List.of()).stream()
.map(artistId -> new ArtistSummary(artistId, artistNameMap.get(artistId), koreanNameMap.get(artistId)))
.toList();
RatingSummary ratingSummary =
ratingSummaryMap.getOrDefault(concert.getId(), RatingSummary.empty());
return new ConcertSummaryResponse(
concert.getId(),
concert.getPosterUrl(),
Expand All @@ -254,7 +268,9 @@ private List<ConcertSummaryResponse> toConcertSummaryList(List<Concert> concerts
concert.getVenueName(),
concert.getStatus(),
calendarConcertIds.contains(concert.getId()),
concert.getTicketOpenAt()
concert.getTicketOpenAt(),
ratingSummary.averageRating(),
ratingSummary.ratingCount()
);
}).toList();
}
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/com/Coming/Backend/rating/dto/RatingMeResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.Coming.Backend.rating.dto;

import java.math.BigDecimal;

public record RatingMeResponse(
BigDecimal score
) {
public static RatingMeResponse empty() {
return new RatingMeResponse(null);
}
}
10 changes: 10 additions & 0 deletions src/main/java/com/Coming/Backend/rating/dto/RatingSummary.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.Coming.Backend.rating.dto;

public record RatingSummary(
Double averageRating,
long ratingCount
) {
public static RatingSummary empty() {
return new RatingSummary(null, 0);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.Coming.Backend.rating.dto;

import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotNull;

import java.math.BigDecimal;

public record RatingUpsertRequest(
@NotNull
@DecimalMin("0.5")
@DecimalMax("5.0")
BigDecimal score
) {
}
48 changes: 48 additions & 0 deletions src/main/java/com/Coming/Backend/rating/entity/Rating.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.Coming.Backend.rating.entity;

import com.Coming.Backend.common.entity.BaseTimeEntity;
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.Table;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.math.BigDecimal;

@Entity
@Table(name = "rating")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
public class Rating extends BaseTimeEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "user_id", nullable = false)
private Long userId;

@Enumerated(EnumType.STRING)
@Column(name = "target_type", nullable = false, length = 20)
private RatingTargetType targetType;

@Column(name = "target_id", nullable = false)
private Long targetId;

@Column(name = "score", nullable = false)
private BigDecimal score;

public void updateScore(BigDecimal score) {
this.score = score;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.Coming.Backend.rating.entity;

public enum RatingTargetType {
CONCERT, RELEASE
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.Coming.Backend.rating.exception;

import com.Coming.Backend.common.exception.BusinessException;
import com.Coming.Backend.common.exception.ErrorCode;

public class InvalidRatingScoreException extends BusinessException {

public InvalidRatingScoreException() {
super(ErrorCode.INVALID_RATING_SCORE);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.Coming.Backend.rating.exception;

import com.Coming.Backend.common.exception.BusinessException;
import com.Coming.Backend.common.exception.ErrorCode;

public class RatingNotFoundException extends BusinessException {

public RatingNotFoundException() {
super(ErrorCode.RATING_NOT_FOUND);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.Coming.Backend.rating.exception;

import com.Coming.Backend.common.exception.BusinessException;
import com.Coming.Backend.common.exception.ErrorCode;

public class RatingTargetNotFoundException extends BusinessException {

public RatingTargetNotFoundException() {
super(ErrorCode.RATING_TARGET_NOT_FOUND);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.Coming.Backend.rating.repository;

import com.Coming.Backend.rating.entity.Rating;
import com.Coming.Backend.rating.entity.RatingTargetType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.math.BigDecimal;
import java.util.Collection;
import java.util.List;
import java.util.Optional;

public interface RatingRepository extends JpaRepository<Rating, Long> {

Optional<Rating> findByUserIdAndTargetTypeAndTargetId(
Long userId, RatingTargetType targetType, Long targetId);

/**
* user_id·target_type·target_id 유니크 제약을 이용해 별점을 원자적으로 등록·수정한다.
* 동시에 같은 대상에 첫 별점을 등록하는 요청이 몰려도 유니크 제약 위반 없이 하나는 삽입, 나머지는 갱신으로 처리된다.
*/
@Modifying
@Query(value = """
INSERT INTO rating (user_id, target_type, target_id, score, created_at, updated_at)
VALUES (:userId, :targetType, :targetId, :score, now(), now())
ON CONFLICT (user_id, target_type, target_id)
DO UPDATE SET score = :score, updated_at = now()
""", nativeQuery = true)
void upsert(@Param("userId") Long userId, @Param("targetType") String targetType,
@Param("targetId") Long targetId, @Param("score") BigDecimal score);

@Query("SELECT r.targetId AS targetId, AVG(r.score) AS averageScore, COUNT(r) AS ratingCount " +
"FROM Rating r WHERE r.targetType = :targetType " +
"AND r.targetId IN :targetIds GROUP BY r.targetId")
List<RatingAggregate> aggregateByTargetIds(@Param("targetType") RatingTargetType targetType,
@Param("targetIds") Collection<Long> targetIds);

interface RatingAggregate {
Long getTargetId();

Double getAverageScore();

Long getRatingCount();
}
}
Loading
Loading