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
12 changes: 0 additions & 12 deletions backend.iml

This file was deleted.

8 changes: 8 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ dependencies {
// Bean Validation
implementation 'org.springframework.boot:spring-boot-starter-validation'

// QueryDSL JPA + APT (Jakarta)
implementation "com.querydsl:querydsl-jpa:5.0.0:jakarta"
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"

// APT가 jakarta 패키지 인식하도록 추가
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"

// Lombok (compile only)
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
Expand All @@ -11,6 +15,7 @@
import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails;

import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;

@RestController
Expand Down Expand Up @@ -157,5 +162,43 @@ public ResponseEntity<?> updateDataSource(
);
}

@GetMapping("")
public ResponseEntity<?> search(
@RequestParam(required = false) String title,
@RequestParam(required = false) String summary,
@RequestParam(required = false) String category,
@RequestParam(required = false) String folderName,
@PageableDefault(size = 8, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable,
@AuthenticationPrincipal CustomUserDetails userDetails
) {
Integer memberId = userDetails.getMember().getId();

DataSourceSearchCondition cond = DataSourceSearchCondition.builder()
.title(title)
.summary(summary)
.folderName(folderName)
.category(category)
.build();

Page<DataSourceSearchItem> page = dataSourceService.search(memberId, cond, pageable);
String sorted = pageable.getSort().toString().replace(": ", ",");

Map<String, Object> res = new LinkedHashMap<>();
res.put("status", 200);
res.put("msg", "복수개의 자료가 조회됐습니다.");
res.put("data", page.getContent());
res.put("pageInfo", Map.of(
"page", page.getNumber(),
"size", page.getSize(),
"totalElements", page.getTotalElements(),
"totalPages", page.getTotalPages(),
"first", page.isFirst(),
"last", page.isLast(),
"sorted", sorted
));
return ResponseEntity.ok(res);
}

record ApiResponse<T>(int status, String msg, T data) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package org.tuna.zoopzoop.backend.domain.datasource.dto;

import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
public class DataSourceSearchCondition {
private final String title;
private final String summary;
private final String category;
private final String folderName;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package org.tuna.zoopzoop.backend.domain.datasource.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;

import java.time.LocalDate;
import java.util.List;

@Getter
@AllArgsConstructor
public class DataSourceSearchItem {
private Integer dataSourceId;
private String title;
private LocalDate dataCreatedDate;
private String summary;
private String sourceUrl;
private String imageUrl;
private List<String> tags;
private String category;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.tuna.zoopzoop.backend.domain.datasource.repository;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceSearchCondition;
import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceSearchItem;

public interface DataSourceQRepository {
Page<DataSourceSearchItem> search(Integer memberId, DataSourceSearchCondition cond, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package org.tuna.zoopzoop.backend.domain.datasource.repository;

import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.Tuple;
import com.querydsl.core.types.Order;
import com.querydsl.core.types.OrderSpecifier;
import com.querydsl.core.types.dsl.PathBuilder;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.*;
import org.springframework.stereotype.Repository;
import org.tuna.zoopzoop.backend.domain.archive.archive.entity.QPersonalArchive;
import org.tuna.zoopzoop.backend.domain.archive.folder.entity.QFolder;
import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceSearchCondition;
import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceSearchItem;
import org.tuna.zoopzoop.backend.domain.datasource.entity.QDataSource;
import org.tuna.zoopzoop.backend.domain.datasource.entity.QTag;

import java.util.*;
import java.util.stream.Collectors;

@Repository
@RequiredArgsConstructor
public class DataSourceQRepositoryImpl implements DataSourceQRepository {

private final JPAQueryFactory queryFactory;

@Override
public Page<DataSourceSearchItem> search(Integer memberId, DataSourceSearchCondition cond, Pageable pageable) {
if (memberId == null)
throw new IllegalArgumentException("memberId must not be null");

QDataSource ds = QDataSource.dataSource;
QFolder folder = QFolder.folder;
QPersonalArchive pa = QPersonalArchive.personalArchive;
QTag tag = QTag.tag;

// where
BooleanBuilder where = new BooleanBuilder()
.and(ds.isActive.isTrue());

if (cond.getTitle() != null && !cond.getTitle().isBlank()) {
where.and(ds.title.containsIgnoreCase(cond.getTitle()));
}
if (cond.getSummary() != null && !cond.getSummary().isBlank()) {
where.and(ds.summary.containsIgnoreCase(cond.getSummary()));
}
if (cond.getCategory() != null && !cond.getCategory().isBlank()) {
where.and(ds.category.stringValue().containsIgnoreCase(cond.getCategory()));
}
if (cond.getFolderName() != null && !cond.getFolderName().isBlank()) {
where.and(ds.folder.name.eq(cond.getFolderName()));
}

BooleanBuilder ownership = new BooleanBuilder()
.and(pa.member.id.eq(memberId));

// count
JPAQuery<Long> countQuery = queryFactory
.select(ds.id.countDistinct())
.from(ds)
.join(ds.folder, folder)
.join(pa).on(pa.archive.eq(folder.archive))
.where(where.and(ownership));

// content
JPAQuery<Tuple> contentQuery = queryFactory
.select(ds.id, ds.title, ds.dataCreatedDate, ds.summary, ds.sourceUrl, ds.imageUrl, ds.category)
.from(ds)
.join(ds.folder, folder)
.join(pa).on(pa.archive.eq(folder.archive))
.where(where.and(ownership));

List<OrderSpecifier<?>> orderSpecifiers = toOrderSpecifiers(pageable.getSort());
if (!orderSpecifiers.isEmpty()) {
contentQuery.orderBy(orderSpecifiers.toArray(new OrderSpecifier<?>[0]));
} else {
contentQuery.orderBy(ds.dataCreatedDate.desc());
}

// fetch
List<Tuple> tuples = contentQuery
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();

Long totalCount = countQuery.fetchOne();
long total = (totalCount == null ? 0L : totalCount);

// 태그 배치 조회
List<Integer> ids = tuples.stream().map(t -> t.get(ds.id)).toList();

Map<Integer, List<String>> tagsById = ids.isEmpty() ? Map.of()
: queryFactory
.select(ds.id, tag.tagName)
.from(ds)
.leftJoin(ds.tags, tag)
.where(ds.id.in(ids))
.fetch()
.stream()
.collect(Collectors.groupingBy(
row -> row.get(ds.id),
Collectors.mapping(row -> row.get(tag.tagName), Collectors.toList())
));

// map to DTO
List<DataSourceSearchItem> content = tuples.stream()
.map(row -> new DataSourceSearchItem(
row.get(ds.id),
row.get(ds.title),
row.get(ds.dataCreatedDate), // LocalDate 그대로 내려줌
row.get(ds.summary),
row.get(ds.sourceUrl),
row.get(ds.imageUrl),
tagsById.getOrDefault(row.get(ds.id), List.of()),
row.get(ds.category).name()
))
.toList();

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

// createdAt / title 허용. createdAt은 내부적으로 dataCreatedDate로 매핑
private List<OrderSpecifier<?>> toOrderSpecifiers(Sort sort) {
if (sort == null || sort.isEmpty()) return List.of();

PathBuilder<org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource> root =
new PathBuilder<>(org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource.class, "dataSource");

List<OrderSpecifier<?>> specs = new ArrayList<>();
for (Sort.Order o : sort) {
Order dir = o.isAscending() ? Order.ASC : Order.DESC;
switch (o.getProperty()) {
case "title" ->
specs.add(new OrderSpecifier<>(dir, root.getString("title")));
case "createdAt" -> // 요청 키
specs.add(new OrderSpecifier<>(dir, root.getDate("dataCreatedDate", java.time.LocalDate.class)));
default -> { }
}
}
return specs;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,24 @@
import jakarta.persistence.NoResultException;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.tuna.zoopzoop.backend.domain.archive.archive.entity.PersonalArchive;
import org.tuna.zoopzoop.backend.domain.archive.archive.repository.PersonalArchiveRepository;
import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder;
import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository;
import org.tuna.zoopzoop.backend.domain.datasource.dataprocessor.service.DataProcessorService;
import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceDto;
import org.tuna.zoopzoop.backend.domain.datasource.entity.Category;
import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceSearchCondition;
import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceSearchItem;
import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource;
import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag;
import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceQRepository;
import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository;
import org.tuna.zoopzoop.backend.domain.datasource.repository.TagRepository;

import java.io.IOException;
import java.time.LocalDate;
import java.util.*;
import java.util.stream.Collectors;

Expand All @@ -29,6 +32,7 @@ public class DataSourceService {
private final PersonalArchiveRepository personalArchiveRepository;
private final TagRepository tagRepository;
private final DataProcessorService dataProcessorService;
private final DataSourceQRepository dataSourceQRepository;

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

/**
* 자료 검색
*/
@Transactional
public Page<DataSourceSearchItem> search(Integer memberId, DataSourceSearchCondition cond, Pageable pageable) {
return dataSourceQRepository.search(memberId, cond, pageable);
}

public record MoveResult(Integer datasourceId, Integer folderId) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package org.tuna.zoopzoop.backend.global.config;

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

@Configuration
public class QuerydslConfig {

@PersistenceContext
private EntityManager em;

@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(em);
}
}
4 changes: 4 additions & 0 deletions src/main/resources/application-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ spring:
ddl-auto: create-drop
show-sql: true

app:
seed:
enabled: true

sentry:
dsn: https://60f1acad189d2994353d59b7895076ee@o4510100579155968.ingest.us.sentry.io/4510100584923136
# Add data like request headers and IP for users,
Expand Down
4 changes: 4 additions & 0 deletions src/main/resources/application-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,9 @@ spring:
init:
mode: never

app:
seed:
enabled: false

liveblocks:
secret-key: test_dummy_liveblocks_secret_key
Loading