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
7 changes: 7 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ plugins {
id 'io.spring.dependency-management' version '1.1.7' apply false
}

ext {
querydslVersion = '7.4.0'
}

subprojects {
apply plugin: 'java-library'
apply plugin: 'io.spring.dependency-management'
Expand Down Expand Up @@ -36,6 +40,9 @@ subprojects {
dependencies {
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
annotationProcessor "io.github.openfeign.querydsl:querydsl-apt:${querydslVersion}:jpa"
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
annotationProcessor 'jakarta.annotation:jakarta.annotation-api'

testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
Expand Down
1 change: 1 addition & 0 deletions common/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ dependencies {
api 'org.springframework.boot:spring-boot-starter-json'
api 'org.springframework.boot:spring-boot-starter-validation'
api 'org.springframework.boot:spring-boot-starter-data-jpa'
api "io.github.openfeign.querydsl:querydsl-jpa:${querydslVersion}"

implementation 'io.hypersistence:hypersistence-tsid:2.1.4'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.nalssilog.common.config;

import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
public class QuerydslConfig {

@Bean
public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) {
return new JPAQueryFactory(entityManager);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@

import com.nalssilog.location.domain.LocationFavorite;
import java.util.List;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

/**
* Spring Data JPA 인터페이스. 서비스가 직접 호출하지 않고 {@link LocationFavoriteRepository} 래퍼를 통해 사용한다.
* 단순 CRUD와 메서드 이름으로 표현 가능한 조회만 담당한다.
* 인기 지역 집계는 {@link LocationFavoriteRepository}가 QueryDSL로 처리한다.
*/
public interface LocationFavoriteJpaRepository extends JpaRepository<LocationFavorite, Long> {

Expand All @@ -16,10 +15,4 @@ public interface LocationFavoriteJpaRepository extends JpaRepository<LocationFav
long deleteByMemberIdAndLocationId(Long memberId, Long locationId);

List<LocationFavorite> findAllByMemberIdOrderByCreatedAtDesc(Long memberId);

/**
* 즐겨찾기 많은 지역 id (임시 인기 기준). 나중에 제보 기반으로 교체 예정.
*/
@Query("select f.locationId from LocationFavorite f group by f.locationId order by count(f) desc")
List<Long> findPopularLocationIds(Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
package com.nalssilog.location.repository;

import static com.nalssilog.location.domain.QLocationFavorite.locationFavorite;

import com.nalssilog.location.domain.LocationFavorite;
import com.querydsl.jpa.impl.JPAQueryFactory;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Repository;

/**
* 서비스 호출용 LocationFavorite 저장소 래퍼.
* 서비스 호출용 LocationFavorite 저장소.
* 단순 조회는 Spring Data JPA에 위임하고, 인기 지역 집계는 QueryDSL로 처리한다.
*/
@Repository
@RequiredArgsConstructor
public class LocationFavoriteRepository {

private final LocationFavoriteJpaRepository locationFavoriteJpaRepository;
private final JPAQueryFactory queryFactory;

public boolean exists(Long memberId, Long locationId) {
return locationFavoriteJpaRepository.existsByMemberIdAndLocationId(memberId, locationId);
Expand All @@ -34,6 +38,12 @@ public List<Long> findFavoriteLocationIds(Long memberId) {
}

public List<Long> findPopularLocationIds(int size) {
return locationFavoriteJpaRepository.findPopularLocationIds(PageRequest.of(0, size));
return queryFactory
.select(locationFavorite.locationId)
.from(locationFavorite)
.groupBy(locationFavorite.locationId)
.orderBy(locationFavorite.id.count().desc())
.limit(size)
.fetch();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,48 +3,15 @@
import com.nalssilog.location.domain.Location;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Pageable;
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;

/**
* Spring Data JPA 인터페이스. 서비스가 직접 호출하지 않고 {@link LocationRepository} 래퍼를 통해 사용한다.
* 단순 CRUD와 메서드 이름으로 표현 가능한 조회만 담당한다.
* 키워드 검색과 원자적 등록은 {@link LocationRepository}가 담당한다.
*/
public interface LocationJpaRepository extends JpaRepository<Location, Long> {

/**
* 시도/시군구/동, 그리고 "시도 시군구 동" 조합 label 까지 부분검색(대소문자 무시).
* 예: "강남", "역삼", "서울 강남", "강남구 역삼동" 모두 매칭.
*/
@Query("""
select l from Location l
where lower(l.sido) like lower(concat('%', :keyword, '%'))
or lower(l.sigungu) like lower(concat('%', :keyword, '%'))
or lower(l.dong) like lower(concat('%', :keyword, '%'))
or lower(concat(l.sido, ' ', l.sigungu, ' ', l.dong)) like lower(concat('%', :keyword, '%'))
order by l.sido asc, l.sigungu asc, l.dong asc
""")
List<Location> searchByKeyword(@Param("keyword") String keyword, Pageable pageable);

List<Location> findByAdminCodeIn(List<String> adminCodes);

Optional<Location> findByAdminCode(String adminCode);

@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query(value = """
insert into location (
created_at, updated_at, admin_code, sido, sigungu, dong, latitude, longitude
) values (
current_timestamp, current_timestamp, :adminCode, :sido, :sigungu, :dong, :latitude, :longitude
)
on conflict (admin_code) do nothing
""", nativeQuery = true)
int insertIfAbsent(@Param("adminCode") String adminCode,
@Param("sido") String sido,
@Param("sigungu") String sigungu,
@Param("dong") String dong,
@Param("latitude") double latitude,
@Param("longitude") double longitude);
}
Original file line number Diff line number Diff line change
@@ -1,33 +1,61 @@
package com.nalssilog.location.repository;

import static com.nalssilog.location.domain.QLocation.location;

import com.nalssilog.common.exception.NalssiLogException;
import com.nalssilog.location.application.dto.LocationInfo;
import com.nalssilog.location.client.KakaoRegion;
import com.nalssilog.location.domain.Location;
import com.nalssilog.location.domain.LocationErrorCode;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.hibernate.Session;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

/**
* 서비스 호출용 Location 저장소 래퍼. 조회는 DTO 로 반환한다.
* 서비스 호출용 Location 저장소.
* 단순 조회는 Spring Data JPA에 위임하고, 키워드 검색은 QueryDSL로 처리한다.
*/
@Repository
@RequiredArgsConstructor
public class LocationRepository {

private final LocationJpaRepository locationJpaRepository;

private static final int SEARCH_LIMIT = 20;
private static final String INSERT_IF_ABSENT = """
insert Location (createdAt, updatedAt, adminCode, sido, sigungu, dong, latitude, longitude)
values (:now, :now, :adminCode, :sido, :sigungu, :dong, :latitude, :longitude)
on conflict (adminCode) do nothing
""";

private final LocationJpaRepository locationJpaRepository;
private final JPAQueryFactory queryFactory;
private final EntityManager entityManager;

public List<LocationInfo> searchByKeyword(String keyword) {
return locationJpaRepository.searchByKeyword(keyword, PageRequest.of(0, SEARCH_LIMIT)).stream()
BooleanExpression matchesKeyword = location.sido.containsIgnoreCase(keyword)
.or(location.sigungu.containsIgnoreCase(keyword))
.or(location.dong.containsIgnoreCase(keyword))
.or(location.sido.concat(" ")
.concat(location.sigungu).concat(" ")
.concat(location.dong)
.containsIgnoreCase(keyword));

return queryFactory
.selectFrom(location)
.where(matchesKeyword)
.orderBy(location.sido.asc(), location.sigungu.asc(), location.dong.asc())
.limit(SEARCH_LIMIT)
.fetch()
.stream()
.map(LocationInfo::of)
.toList();
}
Expand All @@ -38,9 +66,7 @@ public LocationInfo getById(Long id) {
.orElseThrow(() -> new NalssiLogException(LocationErrorCode.LOCATION_NOT_FOUND));
}

/**
* 주어진 id 순서를 보존해 조회한다. (인기·즐겨찾기 목록의 정렬 유지용)
*/
/** 주어진 id 순서를 보존해 조회한다. (인기·즐겨찾기 목록의 정렬 유지용) */
public List<LocationInfo> findByIds(List<Long> ids) {
Map<Long, Location> byId = locationJpaRepository.findAllById(ids).stream()
.collect(Collectors.toMap(Location::getId, Function.identity()));
Expand Down Expand Up @@ -74,18 +100,21 @@ public void saveAll(List<Location> locations) {

/**
* 카카오 법정동 코드로 지역을 원자적으로 등록한 뒤 반환한다.
* 같은 법정동에 요청이 동시에 들어와도 DB unique + ON CONFLICT 로 한 행만 유지한다.
* QueryDSL JPA가 INSERT를 지원하지 않아 Hibernate HQL upsert를 사용한다.
*/
@Transactional
public LocationInfo findOrCreate(KakaoRegion region) {
locationJpaRepository.insertIfAbsent(
region.adminCode(),
region.sido(),
region.sigungu(),
region.dong(),
region.latitude(),
region.longitude()
);
Instant now = Instant.now();
entityManager.unwrap(Session.class)
.createMutationQuery(INSERT_IF_ABSENT)
.setParameter("now", now)
.setParameter("adminCode", region.adminCode())
.setParameter("sido", region.sido())
.setParameter("sigungu", region.sigungu())
.setParameter("dong", region.dong())
.setParameter("latitude", region.latitude())
.setParameter("longitude", region.longitude())
.executeUpdate();

return locationJpaRepository.findByAdminCode(region.adminCode())
.map(LocationInfo::of)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,7 @@
public class ReportService {

private static final int PAGE_SIZE = 20;
// 홈 피드·통계 공통 최근 윈도우. "지금 이 동네 체감" 컨셉이라 24시간으로 통일.
private static final Duration RECENT_WINDOW = Duration.ofHours(24);
private static final Duration STATS_WINDOW = Duration.ofHours(24);

private final WeatherReportRepository reportRepository;
private final ThanksRepository thanksRepository;
Expand Down Expand Up @@ -72,8 +71,7 @@ public CursorPage<ReportResponse> list(Long locationId, String cursor, ReportAct
Instant cursorTime = decoded == null ? null : decoded.createdAt();
Long cursorId = decoded == null ? null : decoded.id();

Instant since = Instant.now().minus(RECENT_WINDOW);
List<ReportData> fetched = reportRepository.findPage(locationId, since, cursorTime, cursorId, PAGE_SIZE + 1);
List<ReportData> fetched = reportRepository.findPage(locationId, cursorTime, cursorId, PAGE_SIZE + 1);
boolean hasNext = fetched.size() > PAGE_SIZE;
List<ReportData> page = hasNext ? fetched.subList(0, PAGE_SIZE) : fetched;

Expand Down Expand Up @@ -140,12 +138,12 @@ public CursorPage<ReportResponse> listByMember(Long memberId, String cursor, Rep
}

/**
* 지역 날씨 통계. 최근 {@link #RECENT_WINDOW} 이내 제보들의 3축 분포 + 제보 수.
* 지역 날씨 통계. 최근 {@link #STATS_WINDOW} 이내 제보들의 3축 분포 + 제보 수.
* (locationClient.getLocation 이 유효하지 않은 지역이면 LOCATION_NOT_FOUND 를 던져 검증도 겸함)
*/
public WeatherStatsResponse stats(Long locationId) {
LocationSummary location = locationClient.getLocation(locationId);
WeatherStatsData stats = reportRepository.statsSince(locationId, Instant.now().minus(RECENT_WINDOW));
WeatherStatsData stats = reportRepository.statsSince(locationId, Instant.now().minus(STATS_WINDOW));

return WeatherStatsResponse.of(location, stats);
}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
package com.nalssilog.report.repository;

import com.nalssilog.report.application.dto.ThanksCountRow;
import com.nalssilog.report.domain.ActorType;
import com.nalssilog.report.domain.Thanks;
import java.util.Collection;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

/**
* Spring Data JPA 인터페이스. 서비스가 직접 호출하지 않고 {@link ThanksRepository} 래퍼를 통해 사용한다.
* 단순 CRUD와 메서드 이름으로 표현 가능한 조회만 담당한다.
* 배치 집계와 복합 조건 조회는 {@link ThanksRepository}가 QueryDSL로 처리한다.
*/
public interface ThanksJpaRepository extends JpaRepository<Thanks, Long> {

Expand All @@ -21,20 +17,4 @@ public interface ThanksJpaRepository extends JpaRepository<Thanks, Long> {
long deleteByReportId(Long reportId);

long countByReportId(Long reportId);

@Query("""
select new com.nalssilog.report.application.dto.ThanksCountRow(t.reportId, count(t))
from Thanks t
where t.reportId in :reportIds
group by t.reportId
""")
List<ThanksCountRow> countByReportIds(@Param("reportIds") Collection<Long> reportIds);

@Query("""
select distinct t.reportId from Thanks t
where t.reportId in :reportIds and t.actorType = :actorType and t.actorKey = :actorKey
""")
List<Long> findThankedReportIds(@Param("reportIds") Collection<Long> reportIds,
@Param("actorType") ActorType actorType,
@Param("actorKey") String actorKey);
}
Loading