Skip to content

Commit cbfd1e2

Browse files
committed
refactor/OPS-252 : 조건별 자료 검색
1 parent c7616dc commit cbfd1e2

File tree

9 files changed

+247
-85
lines changed

9 files changed

+247
-85
lines changed

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

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import org.springframework.data.domain.Pageable;
77
import org.springframework.data.domain.Sort;
88
import org.springframework.data.web.PageableDefault;
9-
import org.springframework.format.annotation.DateTimeFormat;
109
import org.springframework.http.ResponseEntity;
1110
import org.springframework.security.core.annotation.AuthenticationPrincipal;
1211
import org.springframework.web.bind.annotation.*;
@@ -15,8 +14,8 @@
1514
import org.tuna.zoopzoop.backend.domain.member.entity.Member;
1615
import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails;
1716

18-
import java.time.LocalDate;
1917
import java.util.HashMap;
18+
import java.util.LinkedHashMap;
2019
import java.util.Map;
2120

2221
@RestController
@@ -167,10 +166,8 @@ public ResponseEntity<?> updateDataSource(
167166
public ResponseEntity<?> search(
168167
@RequestParam(required = false) String title,
169168
@RequestParam(required = false) String summary,
170-
@RequestParam(required = false, name = "createdAt")
171-
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate createdAtAfter,
172-
@RequestParam(required = false) String folderName,
173169
@RequestParam(required = false) String category,
170+
@RequestParam(required = false) String folderName,
174171
@PageableDefault(size = 8, sort = "createdAt", direction = Sort.Direction.DESC)
175172
Pageable pageable,
176173
@AuthenticationPrincipal CustomUserDetails userDetails
@@ -180,28 +177,27 @@ public ResponseEntity<?> search(
180177
DataSourceSearchCondition cond = DataSourceSearchCondition.builder()
181178
.title(title)
182179
.summary(summary)
183-
.createdAtAfter(createdAtAfter)
184180
.folderName(folderName)
185181
.category(category)
186182
.build();
187183

188184
Page<DataSourceSearchItem> page = dataSourceService.search(memberId, cond, pageable);
189-
dataSourceService.search(memberId, cond, pageable);
185+
String sorted = pageable.getSort().toString().replace(": ", ",");
190186

191-
Map<String, Object> body = new java.util.LinkedHashMap<>();
192-
body.put("status", 200);
193-
body.put("msg", "복수개의 자료가 조회됐습니다.");
194-
body.put("data", page.getContent());
195-
body.put("pageInfo", Map.of(
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(
196192
"page", page.getNumber(),
197193
"size", page.getSize(),
198194
"totalElements", page.getTotalElements(),
199195
"totalPages", page.getTotalPages(),
200196
"first", page.isFirst(),
201197
"last", page.isLast(),
202-
"sorted", pageable.getSort().toString().replace(": ", ",")
198+
"sorted", sorted
203199
));
204-
return ResponseEntity.ok(body);
200+
return ResponseEntity.ok(res);
205201
}
206202

207203
record ApiResponse<T>(int status, String msg, T data) {}

src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/DataSourceSearchCondition.java

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,11 @@
33
import lombok.Builder;
44
import lombok.Getter;
55

6-
import java.time.LocalDate;
7-
86
@Getter
97
@Builder
108
public class DataSourceSearchCondition {
119
private final String title;
1210
private final String summary;
13-
private final LocalDate createdAtAfter;
14-
private final String folderName;
1511
private final String category;
12+
private final String folderName;
1613
}

src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/DataSourceSearchItem.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
public class DataSourceSearchItem {
1212
private Integer dataSourceId;
1313
private String title;
14-
private LocalDate createdAt;
14+
private LocalDate dataCreatedDate;
1515
private String summary;
1616
private String sourceUrl;
1717
private String imageUrl;

src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/DataSourceQRepositoryImpl.java

Lines changed: 23 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
// src/main/java/.../domain/datasource/repository/DataSourceQRepositoryImpl.java
21
package org.tuna.zoopzoop.backend.domain.datasource.repository;
32

43
import com.querydsl.core.BooleanBuilder;
@@ -15,12 +14,9 @@
1514
import org.tuna.zoopzoop.backend.domain.archive.folder.entity.QFolder;
1615
import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceSearchCondition;
1716
import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceSearchItem;
18-
import org.tuna.zoopzoop.backend.domain.datasource.entity.Category;
19-
import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource;
2017
import org.tuna.zoopzoop.backend.domain.datasource.entity.QDataSource;
2118
import org.tuna.zoopzoop.backend.domain.datasource.entity.QTag;
2219

23-
import java.time.LocalDate;
2420
import java.util.*;
2521
import java.util.stream.Collectors;
2622

@@ -32,75 +28,67 @@ public class DataSourceQRepositoryImpl implements DataSourceQRepository {
3228

3329
@Override
3430
public Page<DataSourceSearchItem> search(Integer memberId, DataSourceSearchCondition cond, Pageable pageable) {
31+
if (memberId == null)
32+
throw new IllegalArgumentException("memberId must not be null");
33+
3534
QDataSource ds = QDataSource.dataSource;
3635
QFolder folder = QFolder.folder;
3736
QPersonalArchive pa = QPersonalArchive.personalArchive;
3837
QTag tag = QTag.tag;
3938

40-
// ===== where 절 구성 =====
39+
// where
4140
BooleanBuilder where = new BooleanBuilder()
42-
.and(ds.isActive.isTrue()); // 활성 자료만
41+
.and(ds.isActive.isTrue());
4342

4443
if (cond.getTitle() != null && !cond.getTitle().isBlank()) {
4544
where.and(ds.title.containsIgnoreCase(cond.getTitle()));
4645
}
4746
if (cond.getSummary() != null && !cond.getSummary().isBlank()) {
4847
where.and(ds.summary.containsIgnoreCase(cond.getSummary()));
4948
}
50-
if (cond.getCreatedAtAfter() != null) { // 원본 작성일(LocalDate) 기준
51-
LocalDate from = cond.getCreatedAtAfter();
52-
where.and(ds.dataCreatedDate.goe(from));
49+
if (cond.getCategory() != null && !cond.getCategory().isBlank()) {
50+
where.and(ds.category.stringValue().containsIgnoreCase(cond.getCategory()));
5351
}
5452
if (cond.getFolderName() != null && !cond.getFolderName().isBlank()) {
5553
where.and(ds.folder.name.eq(cond.getFolderName()));
5654
}
57-
if (cond.getCategory() != null && !cond.getCategory().isBlank()) {
58-
try {
59-
where.and(ds.category.eq(Category.valueOf(cond.getCategory().toUpperCase())));
60-
} catch (IllegalArgumentException e) {
61-
throw new IllegalArgumentException("잘못된 category 값입니다: " + cond.getCategory());
62-
}
63-
}
6455

65-
// 소유권 제한: 해당 멤버의 아카이브 범위
6656
BooleanBuilder ownership = new BooleanBuilder()
6757
.and(pa.member.id.eq(memberId));
6858

69-
// ===== count 쿼리 =====
59+
// count
7060
JPAQuery<Long> countQuery = queryFactory
7161
.select(ds.id.countDistinct())
7262
.from(ds)
7363
.join(ds.folder, folder)
7464
.join(pa).on(pa.archive.eq(folder.archive))
7565
.where(where.and(ownership));
7666

77-
// ===== content 쿼리 =====
67+
// content
7868
JPAQuery<Tuple> contentQuery = queryFactory
7969
.select(ds.id, ds.title, ds.dataCreatedDate, ds.summary, ds.sourceUrl, ds.imageUrl, ds.category)
8070
.from(ds)
8171
.join(ds.folder, folder)
8272
.join(pa).on(pa.archive.eq(folder.archive))
8373
.where(where.and(ownership));
8474

85-
// 정렬 적용
8675
List<OrderSpecifier<?>> orderSpecifiers = toOrderSpecifiers(pageable.getSort());
8776
if (!orderSpecifiers.isEmpty()) {
8877
contentQuery.orderBy(orderSpecifiers.toArray(new OrderSpecifier<?>[0]));
8978
} else {
90-
contentQuery.orderBy(ds.dataCreatedDate.desc()); // 기본 최신순
79+
contentQuery.orderBy(ds.dataCreatedDate.desc());
9180
}
9281

93-
// ===== 실행: 페이지 데이터 =====
82+
// fetch
9483
List<Tuple> tuples = contentQuery
9584
.offset(pageable.getOffset())
9685
.limit(pageable.getPageSize())
9786
.fetch();
9887

99-
// 총 개수
10088
Long totalCount = countQuery.fetchOne();
10189
long total = (totalCount == null ? 0L : totalCount);
10290

103-
// ===== 태그 배치 조회 (응답용) =====
91+
// 태그 배치 조회
10492
List<Integer> ids = tuples.stream().map(t -> t.get(ds.id)).toList();
10593

10694
Map<Integer, List<String>> tagsById = ids.isEmpty() ? Map.of()
@@ -116,39 +104,39 @@ public Page<DataSourceSearchItem> search(Integer memberId, DataSourceSearchCondi
116104
Collectors.mapping(row -> row.get(tag.tagName), Collectors.toList())
117105
));
118106

119-
// ===== Tuple -> DTO (dataCreatedDate는 LocalDate → atStartOfDay로 LocalDateTime 변환) =====
107+
// map to DTO
120108
List<DataSourceSearchItem> content = tuples.stream()
121109
.map(row -> new DataSourceSearchItem(
122110
row.get(ds.id),
123111
row.get(ds.title),
124-
row.get(ds.dataCreatedDate),
112+
row.get(ds.dataCreatedDate), // LocalDate 그대로 내려줌
125113
row.get(ds.summary),
126114
row.get(ds.sourceUrl),
127115
row.get(ds.imageUrl),
128116
tagsById.getOrDefault(row.get(ds.id), List.of()),
129-
Objects.requireNonNull(row.get(ds.category)).name()
117+
row.get(ds.category).name()
130118
))
131119
.toList();
132120

133121
return new PageImpl<>(content, pageable, total);
134122
}
135123

136-
// Q타입 의존 없이 타입별 Path 지정
124+
// createdAt / title 허용. createdAt은 내부적으로 dataCreatedDate로 매핑
137125
private List<OrderSpecifier<?>> toOrderSpecifiers(Sort sort) {
138126
if (sort == null || sort.isEmpty()) return List.of();
139127

140-
// QDataSource.dataSource 의 alias가 "dataSource" 이므로 동일하게 맞춘다.
141-
PathBuilder<DataSource> root =
142-
new PathBuilder<>(DataSource.class, "dataSource");
128+
PathBuilder<org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource> root =
129+
new PathBuilder<>(org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource.class, "dataSource");
143130

144131
List<OrderSpecifier<?>> specs = new ArrayList<>();
145132
for (Sort.Order o : sort) {
146133
Order dir = o.isAscending() ? Order.ASC : Order.DESC;
147134
switch (o.getProperty()) {
148-
case "title" -> specs.add(new OrderSpecifier<>(dir, root.getString("title")));
149-
case "createdAt" -> specs.add(new OrderSpecifier<>(dir, root.getDateTime("createdAt", java.time.LocalDateTime.class)));
150-
case "dataCreatedDate" -> specs.add(new OrderSpecifier<>(dir, root.getDate("dataCreatedDate", java.time.LocalDate.class)));
151-
default -> { /* 화이트리스트 외 필드는 무시 */ }
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 -> { }
152140
}
153141
}
154142
return specs;

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
import org.tuna.zoopzoop.backend.domain.datasource.repository.TagRepository;
2222

2323
import java.io.IOException;
24-
import java.time.LocalDate;
2524
import java.util.*;
2625
import java.util.stream.Collectors;
2726

@@ -223,7 +222,9 @@ public Integer updateDataSource(Integer memberId, Integer dataSourceId, String n
223222
return ds.getId();
224223
}
225224

226-
225+
/**
226+
* 자료 검색
227+
*/
227228
@Transactional
228229
public Page<DataSourceSearchItem> search(Integer memberId, DataSourceSearchCondition cond, Pageable pageable) {
229230
return dataSourceQRepository.search(memberId, cond, pageable);

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,

src/main/resources/application-test.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,9 @@ spring:
1717
init:
1818
mode: never
1919

20+
app:
21+
seed:
22+
enabled: false
23+
2024
liveblocks:
2125
secret-key: test_dummy_liveblocks_secret_key

src/test/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceControllerTest.java

Lines changed: 13 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ class DatasourceControllerTest {
6767
static class StubConfig {
6868
@Bean
6969
@Primary
70-
DataProcessorService stubDataProcessorService() throws Exception {
70+
DataProcessorService stubDataProcessorService(){
7171
return new DataProcessorService(null, null) {
7272
@Override
7373
public DataSourceDto process(String url, List<Tag> tagList) {
@@ -415,10 +415,10 @@ void update_notFound() throws Exception {
415415
.andExpect(jsonPath("$.status").value(404));
416416
}
417417

418-
// ==== [검색 API 테스트 추가] ===============================================
418+
// 검색
419419

420420
@Test
421-
@DisplayName("검색 기본: page, size 기본값 + dataCreatedDate DESC 기본정렬")
421+
@DisplayName("검색 성공: page, size, dataCreatedDate DESC 기본정렬")
422422
@WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD)
423423
void search_default_paging_and_sort() throws Exception {
424424
// 최신/과거 비교용 더미 데이터 추가
@@ -453,13 +453,12 @@ void search_default_paging_and_sort() throws Exception {
453453
.andExpect(jsonPath("$.pageInfo.page").value(0))
454454
.andExpect(jsonPath("$.pageInfo.size").value(8))
455455
.andExpect(jsonPath("$.pageInfo.first").value(true))
456-
.andExpect(jsonPath("$.pageInfo.sorted", containsStringIgnoringCase("dataCreatedDate")))
457-
// 최신(new-doc)이 앞에 오도록 (정렬 검증: 제목 배열의 첫 요소가 new-doc)
456+
.andExpect(jsonPath("$.pageInfo.sorted", containsStringIgnoringCase("createdAt")))
458457
.andExpect(jsonPath("$.data[0].title", anyOf(is("new-doc"), is("spec.pdf"), is("notes.txt"))));
459458
}
460459

461460
@Test
462-
@DisplayName("검색: category=IT 필터")
461+
@DisplayName("검색 성공: category 필터")
463462
@WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD)
464463
void search_filter_by_category() throws Exception {
465464
mockMvc.perform(get("/api/v1/archive")
@@ -470,7 +469,7 @@ void search_filter_by_category() throws Exception {
470469
}
471470

472471
@Test
473-
@DisplayName("검색: title 부분검색")
472+
@DisplayName("검색 성공: title 부분검색")
474473
@WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD)
475474
void search_filter_by_title_contains() throws Exception {
476475
// 준비: 특정 키워드 가진 데이터 보장
@@ -494,7 +493,7 @@ void search_filter_by_title_contains() throws Exception {
494493
}
495494

496495
@Test
497-
@DisplayName("검색: summary 부분검색")
496+
@DisplayName("검색 성공: summary 부분검색")
498497
@WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD)
499498
void search_filter_by_summary_contains() throws Exception {
500499
Folder docsFolder = folderRepository.findById(docsFolderId).orElseThrow();
@@ -517,22 +516,7 @@ void search_filter_by_summary_contains() throws Exception {
517516
}
518517

519518
@Test
520-
@DisplayName("검색: createdAt(=dataCreatedDate 이후) 필터")
521-
@WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD)
522-
void search_filter_by_date_after() throws Exception {
523-
// createdAt 파라미터는 ISO LocalDate로 받음
524-
String after = LocalDate.now().minusDays(1).toString();
525-
526-
mockMvc.perform(get("/api/v1/archive")
527-
.param("createdAt", after))
528-
.andExpect(status().isOk())
529-
.andExpect(jsonPath("$.status").value(200))
530-
// 반환된 항목들의 dataCreatedDate가 after 이상인지 대략적으로 검증 (존재 여부)
531-
.andExpect(jsonPath("$.data").isArray());
532-
}
533-
534-
@Test
535-
@DisplayName("검색: folderName 필터")
519+
@DisplayName("검색 성공: folderName 필터")
536520
@WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD)
537521
void search_filter_by_folderName() throws Exception {
538522
// setup에서 만든 docs 폴더명으로 필터 (폴더 생성시 이름 "docs")
@@ -544,7 +528,7 @@ void search_filter_by_folderName() throws Exception {
544528
}
545529

546530
@Test
547-
@DisplayName("검색: 정렬 title ASC")
531+
@DisplayName("검색 성공: 정렬 title ASC")
548532
@WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD)
549533
void search_sort_by_title_asc() throws Exception {
550534
mockMvc.perform(get("/api/v1/archive")
@@ -555,14 +539,14 @@ void search_sort_by_title_asc() throws Exception {
555539
}
556540

557541
@Test
558-
@DisplayName("검색: 잘못된 category 값 → 400")
542+
@DisplayName("검색 실패: 잘못된 category 값 → 400")
559543
@WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD)
560544
void search_invalid_category() throws Exception {
561545
mockMvc.perform(get("/api/v1/archive")
562546
.param("category", "NOT_A_CATEGORY"))
563-
.andExpect(status().isBadRequest())
564-
.andExpect(jsonPath("$.status").exists())
565-
.andExpect(jsonPath("$.status", either(is(400)).or(is("400"))));
547+
.andExpect(status().isOk())
548+
.andExpect(jsonPath("$.status").value(either(is(200)).or(is("200"))))
549+
.andExpect(jsonPath("$.data").isArray());
566550
}
567551

568552
}

0 commit comments

Comments
 (0)