Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
"/api/calendar",
"/api/mentions/search",
"/api/posts/**",
"/api/search"
"/api/search",
"/api/entities/**"
).permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/auth/register").hasRole("PENDING")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ public interface ConcertRepository extends JpaRepository<Concert, Long> {
@Query("UPDATE Concert c SET c.viewCount = c.viewCount + 1 WHERE c.id = :id")
void incrementViewCount(@Param("id") Long id);

@Query("SELECT c FROM Concert c WHERE c.status NOT IN :hidden AND LOWER(c.title) LIKE :q ORDER BY c.startDate DESC")
List<Concert> searchByTitleForMention(@Param("hidden") Collection<ConcertStatus> hidden, @Param("q") String q, Pageable pageable);
@Query(value = "SELECT c FROM Concert c WHERE c.status NOT IN :hidden AND LOWER(c.title) LIKE :q ORDER BY c.startDate DESC, c.id DESC",
countQuery = "SELECT COUNT(c) FROM Concert c WHERE c.status NOT IN :hidden AND LOWER(c.title) LIKE :q")
Page<Concert> searchByTitleForMention(@Param("hidden") Collection<ConcertStatus> hidden, @Param("q") String q, Pageable pageable);

@Query("SELECT c FROM Concert c WHERE c.startDate <= :lastDay AND c.endDate >= :firstDay AND c.status NOT IN :hidden ORDER BY c.startDate ASC")
List<Concert> findByDateRange(@Param("firstDay") LocalDate firstDay, @Param("lastDay") LocalDate lastDay, @Param("hidden") Collection<ConcertStatus> hidden);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.Coming.Backend.post.controller;

import com.Coming.Backend.common.response.PageResponse;
import com.Coming.Backend.post.dto.EntityCardResponse;
import com.Coming.Backend.post.entity.EntityType;
import com.Coming.Backend.post.service.MentionService;
Expand All @@ -17,8 +18,6 @@
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@Tag(name = "Post")
@Validated
@RestController
Expand All @@ -28,13 +27,14 @@ public class MentionController {

private final MentionService mentionService;

@Operation(summary = "엔티티 검색 (게시글 본문 멘션 자동완성)")
@ApiResponse(responseCode = "400", description = "INVALID_INPUT (q 공백 또는 limit 범위 초과)")
@Operation(summary = "엔티티 검색 (게시글 본문 멘션 자동완성, 무한 스크롤)")
@ApiResponse(responseCode = "400", description = "INVALID_INPUT (q 공백, page 음수 또는 limit 범위 초과)")
@GetMapping("/search")
public ResponseEntity<List<EntityCardResponse>> search(
public ResponseEntity<PageResponse<EntityCardResponse>> search(
@RequestParam EntityType type,
@RequestParam @NotBlank String q,
@RequestParam(defaultValue = "10") @Min(1) @Max(20) int limit) {
return ResponseEntity.ok(mentionService.search(type, q, limit));
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(20) int limit) {
return ResponseEntity.ok(mentionService.search(type, q, page, limit));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public class SearchController {
private final PostService postService;

@Operation(summary = "게시글 통합 검색")
@ApiResponse(responseCode = "400", description = "INVALID_INPUT (q 공백)")
@ApiResponse(responseCode = "400", description = "INVALID_INPUT (q 공백 또는 trim 후 2자 미만)")
@GetMapping
public ResponseEntity<PageResponse<PostSummaryResponse>> search(
@RequestParam @NotBlank String q,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ public record EntityCardResponse(
Long id,
String title,
String subtitle,
String thumbnailUrl
String thumbnailUrl,
// TRACK 타입에서만 채워진다 (트랙이 속한 앨범 id). 그 외 타입은 항상 null.
Long releaseGroupId
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ public record PostEntityTagResponse(
Long entityId,
String title,
String subtitle,
String thumbnailUrl
String thumbnailUrl,
// TRACK 타입에서만 채워진다 (트랙이 속한 앨범 id). 그 외 타입은 항상 null.
Long releaseGroupId
) {
public static PostEntityTagResponse of(EntityType entityType, Long entityId, EntityCardResponse card) {
return new PostEntityTagResponse(entityType, entityId, card.title(), card.subtitle(), card.thumbnailUrl());
return new PostEntityTagResponse(entityType, entityId, card.title(), card.subtitle(), card.thumbnailUrl(), card.releaseGroupId());
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
package com.Coming.Backend.post.entity;

public enum EntityType {
CONCERT, ARTIST, RELEASE
CONCERT, ARTIST, RELEASE, TRACK
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,21 @@ public interface PostRepository extends JpaRepository<Post, Long> {
@Query("UPDATE Post p SET p.commentCount = p.commentCount + 1 WHERE p.id = :id")
void incrementCommentCount(@Param("id") Long id);

@Query("SELECT p FROM Post p WHERE p.id IN (SELECT t.postId FROM PostEntityTag t WHERE t.entityType = :entityType AND t.entityId = :entityId)")
/**
* RELEASE 조회 시, 해당 릴리즈에 속한 트랙(Track.releaseGroupId)이 태그된 게시글도 함께 포함한다.
* 트랙 앵커가 앨범 상세 페이지로 귀결되는 구조(`/releases/{releaseGroupId}#track-{id}`)이므로,
* 트랙을 언급한 글도 해당 앨범의 관련 게시글로 노출되어야 사용자에게 자연스럽다.
*/
@Query("""
SELECT p FROM Post p
WHERE p.id IN (
SELECT t.postId FROM PostEntityTag t
WHERE (t.entityType = :entityType AND t.entityId = :entityId)
OR (:entityType = com.Coming.Backend.post.entity.EntityType.RELEASE
AND t.entityType = com.Coming.Backend.post.entity.EntityType.TRACK
AND t.entityId IN (SELECT tr.id FROM Track tr WHERE tr.releaseGroupId = :entityId))
)
""")
Page<Post> findByEntityTag(@Param("entityType") EntityType entityType, @Param("entityId") Long entityId, Pageable pageable);

@Query("""
Expand All @@ -54,6 +68,8 @@ AND t.entityId IN (SELECT a.id FROM Artist a WHERE LOWER(a.name) LIKE :q))
AND t.entityId IN (SELECT c.id FROM Concert c WHERE LOWER(c.title) LIKE :q))
OR (t.entityType = com.Coming.Backend.post.entity.EntityType.RELEASE
AND t.entityId IN (SELECT r.id FROM ReleaseGroup r WHERE LOWER(r.title) LIKE :q))
OR (t.entityType = com.Coming.Backend.post.entity.EntityType.TRACK
AND t.entityId IN (SELECT tr.id FROM Track tr WHERE LOWER(tr.title) LIKE :q))
)
ORDER BY p.createdAt DESC
""")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
import com.Coming.Backend.post.dto.EntityCardResponse;
import com.Coming.Backend.post.entity.EntityType;
import com.Coming.Backend.release.entity.ReleaseGroup;
import com.Coming.Backend.release.entity.Track;
import com.Coming.Backend.release.repository.ReleaseGroupRepository;
import com.Coming.Backend.release.repository.TrackRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -17,6 +19,7 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;

@Service
Expand All @@ -27,6 +30,7 @@ public class EntityLookupService {
private final ConcertRepository concertRepository;
private final ArtistRepository artistRepository;
private final ReleaseGroupRepository releaseGroupRepository;
private final TrackRepository trackRepository;

/**
* entityType·entityId 키 목록에 대응하는 엔티티 카드 정보를 일괄 조회한다.
Expand All @@ -49,9 +53,32 @@ public Map<EntityKey, EntityCardResponse> findCards(Collection<EntityKey> keys)
result.put(new EntityKey(EntityType.RELEASE, release.getId()),
toCard(release, artistNames.get(release.getArtistId()))));

List<Track> tracks = trackRepository.findAllById(idsOf(keys, EntityType.TRACK));
toTrackCardsById(tracks).forEach((trackId, card) ->
result.put(new EntityKey(EntityType.TRACK, trackId), card));

return result;
}

/**
* 트랙 목록이 속한 앨범·아티스트 정보를 배치 조회해 트랙 id별 카드로 변환한다.
* 앨범이 삭제되어 참조가 끊긴 트랙은 제목만 채운 카드를 반환한다.
*/
Map<Long, EntityCardResponse> toTrackCardsById(Collection<Track> tracks) {
Map<Long, ReleaseGroup> releaseGroupsById = releaseGroupRepository.findAllById(
tracks.stream().map(Track::getReleaseGroupId).collect(Collectors.toSet())
).stream().collect(Collectors.toMap(ReleaseGroup::getId, Function.identity()));
Map<Long, String> artistNames = artistRepository.findAllById(
releaseGroupsById.values().stream().map(ReleaseGroup::getArtistId).collect(Collectors.toSet())
).stream().collect(Collectors.toMap(Artist::getId, Artist::getName));

return tracks.stream().collect(Collectors.toMap(Track::getId, track -> {
ReleaseGroup releaseGroup = releaseGroupsById.get(track.getReleaseGroupId());
String artistName = releaseGroup != null ? artistNames.get(releaseGroup.getArtistId()) : null;
return toCard(track, releaseGroup, artistName);
}));
}

private Set<Long> idsOf(Collection<EntityKey> keys, EntityType type) {
return keys.stream()
.filter(key -> key.type() == type)
Expand All @@ -61,15 +88,23 @@ private Set<Long> idsOf(Collection<EntityKey> keys, EntityType type) {

EntityCardResponse toCard(Concert concert) {
String subtitle = concert.getStartDate() + " · " + concert.getVenueName();
return new EntityCardResponse(EntityType.CONCERT, concert.getId(), concert.getTitle(), subtitle, concert.getPosterUrl());
return new EntityCardResponse(EntityType.CONCERT, concert.getId(), concert.getTitle(), subtitle, concert.getPosterUrl(), null);
}

EntityCardResponse toCard(Artist artist) {
return new EntityCardResponse(EntityType.ARTIST, artist.getId(), artist.getName(), null, artist.getImageUrl());
return new EntityCardResponse(EntityType.ARTIST, artist.getId(), artist.getName(), null, artist.getImageUrl(), null);
}

EntityCardResponse toCard(ReleaseGroup release, String artistName) {
return new EntityCardResponse(EntityType.RELEASE, release.getId(), release.getTitle(), artistName, release.getCoverUrl());
return new EntityCardResponse(EntityType.RELEASE, release.getId(), release.getTitle(), artistName, release.getCoverUrl(), null);
}

EntityCardResponse toCard(Track track, ReleaseGroup releaseGroup, String artistName) {
if (releaseGroup == null) {
return new EntityCardResponse(EntityType.TRACK, track.getId(), track.getTitle(), null, null, null);
}
String subtitle = artistName != null ? artistName + " · " + releaseGroup.getTitle() : releaseGroup.getTitle();
return new EntityCardResponse(EntityType.TRACK, track.getId(), track.getTitle(), subtitle, releaseGroup.getCoverUrl(), releaseGroup.getId());
}

public record EntityKey(EntityType type, Long id) {
Expand Down
55 changes: 33 additions & 22 deletions src/main/java/com/Coming/Backend/post/service/MentionService.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,21 @@

import com.Coming.Backend.artist.entity.Artist;
import com.Coming.Backend.artist.repository.ArtistRepository;
import com.Coming.Backend.common.response.PageResponse;
import com.Coming.Backend.concert.entity.Concert;
import com.Coming.Backend.concert.entity.ConcertStatus;
import com.Coming.Backend.concert.repository.ConcertRepository;
import com.Coming.Backend.post.dto.EntityCardResponse;
import com.Coming.Backend.post.entity.EntityType;
import com.Coming.Backend.release.entity.ReleaseGroup;
import com.Coming.Backend.release.entity.Track;
import com.Coming.Backend.release.repository.ReleaseGroupRepository;
import com.Coming.Backend.release.repository.TrackRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -32,43 +38,48 @@ public class MentionService {
private final ConcertRepository concertRepository;
private final ArtistRepository artistRepository;
private final ReleaseGroupRepository releaseGroupRepository;
private final TrackRepository trackRepository;
private final EntityLookupService entityLookupService;

/**
* 게시글 본문 멘션 자동완성을 위해 type별로 q에 대소문자 무시 부분 일치하는 엔티티를 최대 limit건 검색한다.
* 게시글 본문 멘션 자동완성을 위해 type별로 q에 대소문자 무시 부분 일치하는 엔티티를 페이지 단위로 검색한다.
* 무한 스크롤 조회를 위해 id를 tie-breaker로 사용해 페이지 간 정렬을 안정적으로 유지한다.
*/
public List<EntityCardResponse> search(EntityType type, String q, int limit) {
Pageable pageable = PageRequest.of(0, limit);
public PageResponse<EntityCardResponse> search(EntityType type, String q, int page, int size) {
return switch (type) {
case CONCERT -> searchConcerts(q, pageable);
case ARTIST -> searchArtists(q, pageable);
case RELEASE -> searchReleases(q, pageable);
case CONCERT -> searchConcerts(q, PageRequest.of(page, size));
case ARTIST -> searchArtists(q, PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "id")));
case RELEASE -> searchReleases(q, PageRequest.of(page, size));
case TRACK -> searchTracks(q, PageRequest.of(page, size));
};
}

private List<EntityCardResponse> searchConcerts(String q, Pageable pageable) {
private PageResponse<EntityCardResponse> searchConcerts(String q, Pageable pageable) {
String likeQ = toLikePattern(q);
return concertRepository.searchByTitleForMention(HIDDEN_STATUSES, likeQ, pageable).stream()
.map(entityLookupService::toCard)
.toList();
Page<Concert> concerts = concertRepository.searchByTitleForMention(HIDDEN_STATUSES, likeQ, pageable);
return PageResponse.from(concerts.map(entityLookupService::toCard));
}

private List<EntityCardResponse> searchArtists(String q, Pageable pageable) {
return artistRepository.findByNameOrAliasContainingIgnoreCase(q, pageable).stream()
.map(entityLookupService::toCard)
.toList();
private PageResponse<EntityCardResponse> searchArtists(String q, Pageable pageable) {
Page<Artist> artists = artistRepository.findByNameOrAliasContainingIgnoreCase(q, pageable);
return PageResponse.from(artists.map(entityLookupService::toCard));
}

private List<EntityCardResponse> searchReleases(String q, Pageable pageable) {
List<ReleaseGroup> releases = releaseGroupRepository
.searchReleases(null, null, null, toLikePattern(q), pageable)
.getContent();
private PageResponse<EntityCardResponse> searchReleases(String q, Pageable pageable) {
Page<ReleaseGroup> releases = releaseGroupRepository
.searchByTitleForMention(toLikePattern(q), pageable);
Map<Long, String> artistNames = artistRepository.findAllById(
releases.stream().map(ReleaseGroup::getArtistId).collect(Collectors.toSet())
releases.getContent().stream().map(ReleaseGroup::getArtistId).collect(Collectors.toSet())
).stream().collect(Collectors.toMap(Artist::getId, Artist::getName));
return releases.stream()
.map(release -> entityLookupService.toCard(release, artistNames.get(release.getArtistId())))
.toList();
return PageResponse.from(
releases.map(release -> entityLookupService.toCard(release, artistNames.get(release.getArtistId())))
);
}

private PageResponse<EntityCardResponse> searchTracks(String q, Pageable pageable) {
Page<Track> tracks = trackRepository.searchByTitleForMention(toLikePattern(q), pageable);
Map<Long, EntityCardResponse> cardsByTrackId = entityLookupService.toTrackCardsById(tracks.getContent());
return PageResponse.from(tracks.map(track -> cardsByTrackId.get(track.getId())));
}

private String toLikePattern(String q) {
Expand Down
10 changes: 9 additions & 1 deletion src/main/java/com/Coming/Backend/post/service/PostService.java
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ public class PostService {
*/
private static final int MAX_CONTENT_LENGTH = 50000;

private static final int MIN_SEARCH_QUERY_LENGTH = 2;

/**
* 게시글을 생성한다.
* entityTags가 가리키는 엔티티의 실존 여부는 검증하지 않는다 — 삭제된 참조와 동일하게
Expand Down Expand Up @@ -231,10 +233,16 @@ public List<TrendingTagResponse> getTrendingTags(int days, int limit) {

/**
* 게시글 제목·본문·태그된 엔티티명을 통합 검색한다. 최신순 고정.
*
* @param q trim 후 2자 미만이면 InvalidInputException.
*/
public PageResponse<PostSummaryResponse> search(String q, int page, int size) {
String trimmedQ = q.trim();
if (trimmedQ.length() < MIN_SEARCH_QUERY_LENGTH) {
throw new InvalidInputException();
}
Pageable pageable = PageRequest.of(page, size);
String likeQ = "%" + escapeLikeWildcards(q.toLowerCase(Locale.ROOT)) + "%";
String likeQ = "%" + escapeLikeWildcards(trimmedQ.toLowerCase(Locale.ROOT)) + "%";
Page<Post> result = postRepository.searchPosts(likeQ, pageable);
List<PostSummaryResponse> content = toSummaryResponses(result.getContent());
return new PageResponse<>(content, result.getNumber(), result.getSize(), result.getTotalElements(), result.getTotalPages());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,8 @@ Page<ReleaseGroup> searchReleases(@Param("artistId") Long artistId,
@Param("type") String type,
@Param("q") String q,
Pageable pageable);

@Query(value = "SELECT r FROM ReleaseGroup r WHERE LOWER(r.title) LIKE :q ESCAPE '\\' ORDER BY r.id ASC",
countQuery = "SELECT COUNT(r) FROM ReleaseGroup r WHERE LOWER(r.title) LIKE :q ESCAPE '\\'")
Page<ReleaseGroup> searchByTitleForMention(@Param("q") String q, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package com.Coming.Backend.release.repository;

import com.Coming.Backend.release.entity.Track;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.Collection;
import java.util.List;
Expand All @@ -11,4 +15,8 @@ public interface TrackRepository extends JpaRepository<Track, Long> {
List<Track> findByReleaseGroupIdOrderByPosition(Long releaseGroupId);

List<Track> findByReleaseGroupIdInOrderByPosition(Collection<Long> releaseGroupIds);

@Query(value = "SELECT t FROM Track t WHERE LOWER(t.title) LIKE :q ESCAPE '\\' ORDER BY t.id ASC",
countQuery = "SELECT COUNT(t) FROM Track t WHERE LOWER(t.title) LIKE :q ESCAPE '\\'")
Page<Track> searchByTitleForMention(@Param("q") String q, Pageable pageable);
}
Loading
Loading