Skip to content

Commit 389cbf2

Browse files
authored
[feat/OPS-252] 조건별 자료 검색 (#89)
* refactor/OPS-255 : datasource 테이블 sources 칼럼 추가 * refactor/OPS-319 : 아카이브 로그인 연동 * refactor/OPS-319 : 아카이브 로그인 연동 * refactor/OPS-327 : 자료 등록 LLM 연동 * refactor/OPS-252 : 조건별 자료 검색 구현 * refactor/OPS-252 : 조건별 자료 검색
1 parent 23b8a7f commit 389cbf2

File tree

14 files changed

+607
-15
lines changed

14 files changed

+607
-15
lines changed

backend.iml

Lines changed: 0 additions & 12 deletions
This file was deleted.

build.gradle

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ dependencies {
5252
// Bean Validation
5353
implementation 'org.springframework.boot:spring-boot-starter-validation'
5454

55+
// QueryDSL JPA + APT (Jakarta)
56+
implementation "com.querydsl:querydsl-jpa:5.0.0:jakarta"
57+
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
58+
59+
// APT가 jakarta 패키지 인식하도록 추가
60+
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
61+
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
62+
5563
// Lombok (compile only)
5664
compileOnly 'org.projectlombok:lombok'
5765
annotationProcessor 'org.projectlombok:lombok'

src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceController.java

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
import jakarta.validation.Valid;
44
import lombok.RequiredArgsConstructor;
5+
import org.springframework.data.domain.Page;
6+
import org.springframework.data.domain.Pageable;
7+
import org.springframework.data.domain.Sort;
8+
import org.springframework.data.web.PageableDefault;
59
import org.springframework.http.ResponseEntity;
610
import org.springframework.security.core.annotation.AuthenticationPrincipal;
711
import org.springframework.web.bind.annotation.*;
@@ -11,6 +15,7 @@
1115
import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails;
1216

1317
import java.util.HashMap;
18+
import java.util.LinkedHashMap;
1419
import java.util.Map;
1520

1621
@RestController
@@ -157,5 +162,43 @@ public ResponseEntity<?> updateDataSource(
157162
);
158163
}
159164

165+
@GetMapping("")
166+
public ResponseEntity<?> search(
167+
@RequestParam(required = false) String title,
168+
@RequestParam(required = false) String summary,
169+
@RequestParam(required = false) String category,
170+
@RequestParam(required = false) String folderName,
171+
@PageableDefault(size = 8, sort = "createdAt", direction = Sort.Direction.DESC)
172+
Pageable pageable,
173+
@AuthenticationPrincipal CustomUserDetails userDetails
174+
) {
175+
Integer memberId = userDetails.getMember().getId();
176+
177+
DataSourceSearchCondition cond = DataSourceSearchCondition.builder()
178+
.title(title)
179+
.summary(summary)
180+
.folderName(folderName)
181+
.category(category)
182+
.build();
183+
184+
Page<DataSourceSearchItem> page = dataSourceService.search(memberId, cond, pageable);
185+
String sorted = pageable.getSort().toString().replace(": ", ",");
186+
187+
Map<String, Object> res = new LinkedHashMap<>();
188+
res.put("status", 200);
189+
res.put("msg", "복수개의 자료가 조회됐습니다.");
190+
res.put("data", page.getContent());
191+
res.put("pageInfo", Map.of(
192+
"page", page.getNumber(),
193+
"size", page.getSize(),
194+
"totalElements", page.getTotalElements(),
195+
"totalPages", page.getTotalPages(),
196+
"first", page.isFirst(),
197+
"last", page.isLast(),
198+
"sorted", sorted
199+
));
200+
return ResponseEntity.ok(res);
201+
}
202+
160203
record ApiResponse<T>(int status, String msg, T data) {}
161204
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package org.tuna.zoopzoop.backend.domain.datasource.dto;
2+
3+
import lombok.Builder;
4+
import lombok.Getter;
5+
6+
@Getter
7+
@Builder
8+
public class DataSourceSearchCondition {
9+
private final String title;
10+
private final String summary;
11+
private final String category;
12+
private final String folderName;
13+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package org.tuna.zoopzoop.backend.domain.datasource.dto;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Getter;
5+
6+
import java.time.LocalDate;
7+
import java.util.List;
8+
9+
@Getter
10+
@AllArgsConstructor
11+
public class DataSourceSearchItem {
12+
private Integer dataSourceId;
13+
private String title;
14+
private LocalDate dataCreatedDate;
15+
private String summary;
16+
private String sourceUrl;
17+
private String imageUrl;
18+
private List<String> tags;
19+
private String category;
20+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package org.tuna.zoopzoop.backend.domain.datasource.repository;
2+
3+
import org.springframework.data.domain.Page;
4+
import org.springframework.data.domain.Pageable;
5+
import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceSearchCondition;
6+
import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceSearchItem;
7+
8+
public interface DataSourceQRepository {
9+
Page<DataSourceSearchItem> search(Integer memberId, DataSourceSearchCondition cond, Pageable pageable);
10+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package org.tuna.zoopzoop.backend.domain.datasource.repository;
2+
3+
import com.querydsl.core.BooleanBuilder;
4+
import com.querydsl.core.Tuple;
5+
import com.querydsl.core.types.Order;
6+
import com.querydsl.core.types.OrderSpecifier;
7+
import com.querydsl.core.types.dsl.PathBuilder;
8+
import com.querydsl.jpa.impl.JPAQuery;
9+
import com.querydsl.jpa.impl.JPAQueryFactory;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.data.domain.*;
12+
import org.springframework.stereotype.Repository;
13+
import org.tuna.zoopzoop.backend.domain.archive.archive.entity.QPersonalArchive;
14+
import org.tuna.zoopzoop.backend.domain.archive.folder.entity.QFolder;
15+
import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceSearchCondition;
16+
import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceSearchItem;
17+
import org.tuna.zoopzoop.backend.domain.datasource.entity.QDataSource;
18+
import org.tuna.zoopzoop.backend.domain.datasource.entity.QTag;
19+
20+
import java.util.*;
21+
import java.util.stream.Collectors;
22+
23+
@Repository
24+
@RequiredArgsConstructor
25+
public class DataSourceQRepositoryImpl implements DataSourceQRepository {
26+
27+
private final JPAQueryFactory queryFactory;
28+
29+
@Override
30+
public Page<DataSourceSearchItem> search(Integer memberId, DataSourceSearchCondition cond, Pageable pageable) {
31+
if (memberId == null)
32+
throw new IllegalArgumentException("memberId must not be null");
33+
34+
QDataSource ds = QDataSource.dataSource;
35+
QFolder folder = QFolder.folder;
36+
QPersonalArchive pa = QPersonalArchive.personalArchive;
37+
QTag tag = QTag.tag;
38+
39+
// where
40+
BooleanBuilder where = new BooleanBuilder()
41+
.and(ds.isActive.isTrue());
42+
43+
if (cond.getTitle() != null && !cond.getTitle().isBlank()) {
44+
where.and(ds.title.containsIgnoreCase(cond.getTitle()));
45+
}
46+
if (cond.getSummary() != null && !cond.getSummary().isBlank()) {
47+
where.and(ds.summary.containsIgnoreCase(cond.getSummary()));
48+
}
49+
if (cond.getCategory() != null && !cond.getCategory().isBlank()) {
50+
where.and(ds.category.stringValue().containsIgnoreCase(cond.getCategory()));
51+
}
52+
if (cond.getFolderName() != null && !cond.getFolderName().isBlank()) {
53+
where.and(ds.folder.name.eq(cond.getFolderName()));
54+
}
55+
56+
BooleanBuilder ownership = new BooleanBuilder()
57+
.and(pa.member.id.eq(memberId));
58+
59+
// count
60+
JPAQuery<Long> countQuery = queryFactory
61+
.select(ds.id.countDistinct())
62+
.from(ds)
63+
.join(ds.folder, folder)
64+
.join(pa).on(pa.archive.eq(folder.archive))
65+
.where(where.and(ownership));
66+
67+
// content
68+
JPAQuery<Tuple> contentQuery = queryFactory
69+
.select(ds.id, ds.title, ds.dataCreatedDate, ds.summary, ds.sourceUrl, ds.imageUrl, ds.category)
70+
.from(ds)
71+
.join(ds.folder, folder)
72+
.join(pa).on(pa.archive.eq(folder.archive))
73+
.where(where.and(ownership));
74+
75+
List<OrderSpecifier<?>> orderSpecifiers = toOrderSpecifiers(pageable.getSort());
76+
if (!orderSpecifiers.isEmpty()) {
77+
contentQuery.orderBy(orderSpecifiers.toArray(new OrderSpecifier<?>[0]));
78+
} else {
79+
contentQuery.orderBy(ds.dataCreatedDate.desc());
80+
}
81+
82+
// fetch
83+
List<Tuple> tuples = contentQuery
84+
.offset(pageable.getOffset())
85+
.limit(pageable.getPageSize())
86+
.fetch();
87+
88+
Long totalCount = countQuery.fetchOne();
89+
long total = (totalCount == null ? 0L : totalCount);
90+
91+
// 태그 배치 조회
92+
List<Integer> ids = tuples.stream().map(t -> t.get(ds.id)).toList();
93+
94+
Map<Integer, List<String>> tagsById = ids.isEmpty() ? Map.of()
95+
: queryFactory
96+
.select(ds.id, tag.tagName)
97+
.from(ds)
98+
.leftJoin(ds.tags, tag)
99+
.where(ds.id.in(ids))
100+
.fetch()
101+
.stream()
102+
.collect(Collectors.groupingBy(
103+
row -> row.get(ds.id),
104+
Collectors.mapping(row -> row.get(tag.tagName), Collectors.toList())
105+
));
106+
107+
// map to DTO
108+
List<DataSourceSearchItem> content = tuples.stream()
109+
.map(row -> new DataSourceSearchItem(
110+
row.get(ds.id),
111+
row.get(ds.title),
112+
row.get(ds.dataCreatedDate), // LocalDate 그대로 내려줌
113+
row.get(ds.summary),
114+
row.get(ds.sourceUrl),
115+
row.get(ds.imageUrl),
116+
tagsById.getOrDefault(row.get(ds.id), List.of()),
117+
row.get(ds.category).name()
118+
))
119+
.toList();
120+
121+
return new PageImpl<>(content, pageable, total);
122+
}
123+
124+
// createdAt / title 허용. createdAt은 내부적으로 dataCreatedDate로 매핑
125+
private List<OrderSpecifier<?>> toOrderSpecifiers(Sort sort) {
126+
if (sort == null || sort.isEmpty()) return List.of();
127+
128+
PathBuilder<org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource> root =
129+
new PathBuilder<>(org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource.class, "dataSource");
130+
131+
List<OrderSpecifier<?>> specs = new ArrayList<>();
132+
for (Sort.Order o : sort) {
133+
Order dir = o.isAscending() ? Order.ASC : Order.DESC;
134+
switch (o.getProperty()) {
135+
case "title" ->
136+
specs.add(new OrderSpecifier<>(dir, root.getString("title")));
137+
case "createdAt" -> // 요청 키
138+
specs.add(new OrderSpecifier<>(dir, root.getDate("dataCreatedDate", java.time.LocalDate.class)));
139+
default -> { }
140+
}
141+
}
142+
return specs;
143+
}
144+
}

src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,24 @@
33
import jakarta.persistence.NoResultException;
44
import jakarta.transaction.Transactional;
55
import lombok.RequiredArgsConstructor;
6+
import org.springframework.data.domain.Page;
7+
import org.springframework.data.domain.Pageable;
68
import org.springframework.stereotype.Service;
79
import org.tuna.zoopzoop.backend.domain.archive.archive.entity.PersonalArchive;
810
import org.tuna.zoopzoop.backend.domain.archive.archive.repository.PersonalArchiveRepository;
911
import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder;
1012
import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository;
1113
import org.tuna.zoopzoop.backend.domain.datasource.dataprocessor.service.DataProcessorService;
1214
import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceDto;
13-
import org.tuna.zoopzoop.backend.domain.datasource.entity.Category;
15+
import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceSearchCondition;
16+
import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceSearchItem;
1417
import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource;
1518
import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag;
19+
import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceQRepository;
1620
import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository;
1721
import org.tuna.zoopzoop.backend.domain.datasource.repository.TagRepository;
1822

1923
import java.io.IOException;
20-
import java.time.LocalDate;
2124
import java.util.*;
2225
import java.util.stream.Collectors;
2326

@@ -29,6 +32,7 @@ public class DataSourceService {
2932
private final PersonalArchiveRepository personalArchiveRepository;
3033
private final TagRepository tagRepository;
3134
private final DataProcessorService dataProcessorService;
35+
private final DataSourceQRepository dataSourceQRepository;
3236

3337
/**
3438
* 지정한 folder 위치에 자료 생성
@@ -218,5 +222,13 @@ public Integer updateDataSource(Integer memberId, Integer dataSourceId, String n
218222
return ds.getId();
219223
}
220224

225+
/**
226+
* 자료 검색
227+
*/
228+
@Transactional
229+
public Page<DataSourceSearchItem> search(Integer memberId, DataSourceSearchCondition cond, Pageable pageable) {
230+
return dataSourceQRepository.search(memberId, cond, pageable);
231+
}
232+
221233
public record MoveResult(Integer datasourceId, Integer folderId) {}
222234
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package org.tuna.zoopzoop.backend.global.config;
2+
3+
import com.querydsl.jpa.impl.JPAQueryFactory;
4+
import jakarta.persistence.EntityManager;
5+
import jakarta.persistence.PersistenceContext;
6+
import org.springframework.context.annotation.Bean;
7+
import org.springframework.context.annotation.Configuration;
8+
9+
@Configuration
10+
public class QuerydslConfig {
11+
12+
@PersistenceContext
13+
private EntityManager em;
14+
15+
@Bean
16+
public JPAQueryFactory jpaQueryFactory() {
17+
return new JPAQueryFactory(em);
18+
}
19+
}

src/main/resources/application-dev.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ spring:
1010
ddl-auto: create-drop
1111
show-sql: true
1212

13+
app:
14+
seed:
15+
enabled: true
16+
1317
sentry:
1418
dsn: https://60f1acad189d2994353d59b7895076ee@o4510100579155968.ingest.us.sentry.io/4510100584923136
1519
# Add data like request headers and IP for users,

0 commit comments

Comments
 (0)