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
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ public enum ErrorCode implements ResponseCode {
*/
INVALID_SEARCH_TYPE(HttpStatus.BAD_REQUEST, 900000,"알맞은 검색어 타입을 찾을 수 없습니다."),


/**
* 100000 : room error
*/
Expand All @@ -72,6 +71,8 @@ public enum ErrorCode implements ResponseCode {
ROOM_PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, 100002, "비밀번호가 일치하지 않습니다."),
ROOM_PASSWORD_NOT_REQUIRED(HttpStatus.BAD_REQUEST, 100003, "공개방은 비밀번호가 필요하지 않습니다."),
ROOM_RECRUITMENT_PERIOD_EXPIRED(HttpStatus.BAD_REQUEST, 100004, "모집기간이 만료된 방입니다."),
INVALID_ROOM_SEARCH_SORT(HttpStatus.BAD_REQUEST, 100005, "방 검색 시 정렬 조건이 잘못되었습니다."),

/**
* 110000 : vote error
*/
Expand Down
31 changes: 31 additions & 0 deletions src/main/java/konkuk/thip/common/util/DateUtilsss.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package konkuk.thip.common.util;

import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;

public class DateUtilsss {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

클래스 이름의 오타를 수정하세요.

클래스 이름에 DateUtilsss라고 되어 있는데, DateUtils로 수정해야 합니다.

-public class DateUtilsss {
+public class DateUtils {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public class DateUtilsss {
-public class DateUtilsss {
+public class DateUtils {
🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/common/util/DateUtilsss.java at line 7, the class
name is misspelled as DateUtilsss. Rename the class to DateUtils to correct the
typo and ensure consistency with naming conventions.


public static String formatAfterTime(LocalDate date) {
LocalDateTime now = LocalDateTime.now();
LocalDateTime dateTime = date.atStartOfDay();
Duration d = Duration.between(now, dateTime);

if (d.isNegative() || d.isZero()) {
return "??";
}

long days = d.toDays();
if (days > 0) {
return days + "일 뒤 ";
}

long hours = d.toHours();
if (hours > 0) {
return hours + "시간 뒤 ";
}

long minutes = d.toMinutes();
return minutes + "분 뒤 ";
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,32 @@
package konkuk.thip.room.adapter.in.web;

import jakarta.validation.Valid;
import konkuk.thip.common.dto.BaseResponse;
import konkuk.thip.room.adapter.in.web.response.RoomSearchResponse;
import konkuk.thip.room.application.port.in.RoomSearchUseCase;
import jakarta.validation.Valid;
import konkuk.thip.room.adapter.in.web.request.RoomVerifyPasswordRequest;
import konkuk.thip.room.application.port.in.RoomVerifyPasswordUseCase;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
public class RoomQueryController {

private final RoomSearchUseCase roomSearchUseCase;

@GetMapping("/rooms/search")
public BaseResponse<RoomSearchResponse> searchRooms(
@RequestParam(value = "keyword", required = false, defaultValue = "") final String keyword,
@RequestParam(value = "category", required = false, defaultValue = "") final String category,
@RequestParam("sort") final String sort,
@RequestParam("page") final int page
) {
return BaseResponse.ok(roomSearchUseCase.searchRoom(keyword, category, sort, page));
}
private final RoomVerifyPasswordUseCase roomVerifyPasswordUseCase;

//비공개 방 비밀번호 입력 검증
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package konkuk.thip.room.adapter.in.web.response;

import java.util.List;

public record RoomSearchResponse(
List<RoomSearchResult> roomList,
int page, // 현재 페이지
int size, // 현재 페이지에 포함된 데이터 수
boolean last,
boolean first
) {

public record RoomSearchResult(
Long roomId,
String bookImageUrl,
String roomName,
int memberCount,
int recruitCount,
String deadlineDate,
String category
) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package konkuk.thip.room.adapter.out.persistence;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

import java.util.Arrays;

@Getter
@RequiredArgsConstructor
public enum CategoryName {

/**
* DB에 저장되어 있는 모든 카테고리들의 이름
* TODO : DB에서 value를 통해 카테고리를 조회하는것보다 id로 조회하는게 성능상 좋으니, id 값도 같이 보관 ??
*/
SCIENCE_IT("과학/IT"),
Literature("문학"),
ART("예술"),
SOCIAL_SCIENCE("사회과확"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

카테고리 이름의 오타를 수정하세요.

"사회과확"이 "사회과학"으로 수정되어야 합니다.

-    SOCIAL_SCIENCE("사회과확"),
+    SOCIAL_SCIENCE("사회과학"),
🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/room/adapter/out/persistence/CategoryName.java at
line 19, correct the typo in the category name from "사회과확" to "사회과학" by updating
the string literal accordingly.

HUMANITY("인문학");

private final String value;

public static CategoryName from(String value) {
return Arrays.stream(CategoryName.values())
.filter(categoryName -> categoryName.getValue().equals(value))
.findFirst()
.orElseThrow(
() -> new IllegalArgumentException("현재 카테고리 이름 : " + value)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@

import java.time.LocalDate;

public interface RoomJpaRepository extends JpaRepository<RoomJpaEntity, Long> {
public interface RoomJpaRepository extends JpaRepository<RoomJpaEntity, Long>, RoomQueryRepository {

int countByBookJpaEntity_BookIdAndStartDateAfter(Long bookId, LocalDate currentDate);


}
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package konkuk.thip.room.adapter.out.persistence;

import konkuk.thip.room.adapter.in.web.response.RoomSearchResponse;
import konkuk.thip.room.adapter.out.mapper.RoomMapper;
import konkuk.thip.room.application.port.out.RoomQueryPort;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;

import java.time.LocalDate;
Expand All @@ -18,4 +21,9 @@ public class RoomQueryPersistenceAdapter implements RoomQueryPort {
public int countRecruitingRoomsByBookAndStartDateAfter(Long bookId, LocalDate currentDate) {
return roomJpaRepository.countByBookJpaEntity_BookIdAndStartDateAfter(bookId, currentDate);
}

@Override
public Page<RoomSearchResponse.RoomSearchResult> searchRoom(String keyword, String category, Pageable pageable) {
return roomJpaRepository.searchRoom(keyword, category, pageable);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package konkuk.thip.room.adapter.out.persistence;

import konkuk.thip.room.adapter.in.web.response.RoomSearchResponse;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

레이어 의존성 검토가 필요합니다.

Persistence layer의 인터페이스에서 web layer의 response DTO를 직접 의존하고 있습니다. 이는 레이어 간 의존성을 증가시킬 수 있습니다.

가능하다면 persistence layer에서 사용할 별도의 DTO나 도메인 객체를 정의하는 것을 고려해보세요.

🤖 Prompt for AI Agents
In
src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryRepository.java
at line 3, the persistence layer is directly importing a web layer response DTO,
which increases layer dependency. To fix this, remove the import of the web
layer DTO and instead define and use a separate DTO or domain object within the
persistence layer that represents the data needed. This will decouple the
persistence layer from the web layer and maintain proper layer separation.

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public interface RoomQueryRepository {

Page<RoomSearchResponse.RoomSearchResult> searchRoom(String keyword, String category, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package konkuk.thip.room.adapter.out.persistence;

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.CaseBuilder;
import com.querydsl.core.types.dsl.NumberExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import konkuk.thip.book.adapter.out.jpa.QBookJpaEntity;
import konkuk.thip.common.util.DateUtilsss;
import konkuk.thip.room.adapter.in.web.response.RoomSearchResponse;
import konkuk.thip.room.adapter.out.jpa.QRoomJpaEntity;
import konkuk.thip.user.adapter.out.jpa.QUserRoomJpaEntity;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Repository;

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

@Repository
@RequiredArgsConstructor
public class RoomQueryRepositoryImpl implements RoomQueryRepository {

private final JPAQueryFactory queryFactory;
private final QRoomJpaEntity room = QRoomJpaEntity.roomJpaEntity;
private final QBookJpaEntity book = QBookJpaEntity.bookJpaEntity;
private final QUserRoomJpaEntity userRoom = QUserRoomJpaEntity.userRoomJpaEntity;

@Override
public Page<RoomSearchResponse.RoomSearchResult> searchRoom(String keyword, String category, Pageable pageable) {
// 1. 검색 조건(where) 조립 : 방이름 or 첵제목에 keyword 포함, category 필터 적용, 멤버 모집중인(= 활동 시작전인) 방만 검색
BooleanBuilder where = new BooleanBuilder();
// keyword 필터 (빈 문자열이면 생략)

@hd0rable hd0rable Jul 9, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p2: 저희 서비스에서 모임방 검색후 검색어와 일치하는 모임방을 카테고리/정렬 필터에 따른 값으로 보여주는 것으로 알고있는데 이렇게 되면 keyword 필터가 필수이지않을까요..? 현재 구현하신것으로는 키워드가 없으면 키워드 없이 필터나/정렬으로만 결과가 반환되서 전체 책의 정렬 결과가 반환 될 것같은데 키워드포함 정렬결과가 반환되어야할것같습니다..!

if (keyword != null && !keyword.isBlank()) {
where.and(room.title.containsIgnoreCase(keyword).or(book.title.containsIgnoreCase(keyword)));
}
// category 필터 (빈 문자열이면 생략)
if (category != null && !category.isBlank()) {
where.and(room.categoryJpaEntity.value.eq(category));
}
// 모집중인 방만
where.and(room.startDate.after(LocalDate.now()));

// 2. 페이징된 content 조회
// 우선순위 표현식 : keyword가 방 제목에 매칭되면 1, 아니면 0
NumberExpression<Integer> priorityExpr = new CaseBuilder()
.when(room.title.containsIgnoreCase(keyword)).then(1)
.otherwise(0);
Comment thread
hd0rable marked this conversation as resolved.

NumberExpression<Long> memberCountExpr = userRoom.userRoomId.count(); // 방 별 멤버수 표현식

List<Tuple> tuples = queryFactory
.select(
room.roomId,
book.imageUrl,
room.title,
memberCountExpr,
room.recruitCount,
room.startDate,
room.categoryJpaEntity.value
)
.from(room)
.join(room.bookJpaEntity, book)
.leftJoin(userRoom).on(userRoom.roomJpaEntity.eq(room))
.where(where)
.groupBy(
room.roomId,
book.imageUrl,
room.title,
room.recruitCount,
room.startDate,
room.categoryJpaEntity.value
)
.orderBy(
// 1차 정렬 : 설정된 정렬 조건, 2차 정렬 : 방이름으로 방 검색 > 책제목으로 방 검색
toOrderSpecifier(pageable.getSort(), room, memberCountExpr),
priorityExpr.desc()
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
// TODO : 추후에 오프셋 페이징이 아니라, 키셋 페이징 기법 도입 검토

// 3. Tuple → DTO 매핑
List<RoomSearchResponse.RoomSearchResult> content = tuples.stream()
.map(t -> new RoomSearchResponse.RoomSearchResult(
t.get(room.roomId),
t.get(book.imageUrl),
t.get(room.title),
// 참여자 수를 int로 캐스팅
t.get(memberCountExpr).intValue(),
t.get(room.recruitCount),
// 모집마감일 까지 남은 시간 포맷
DateUtilsss.formatAfterTime(t.get(room.startDate)),
t.get(room.categoryJpaEntity.value)
))
.toList();

// 4. 전체 개수 조회 (페이징 정보 계산용)
Long totalCount = queryFactory
.select(room.count())
.from(room)
.join(room.bookJpaEntity, book)
.where(where)
.fetchOne();
long total = (totalCount != null) ? totalCount : 0L;

// 5. PageImpl 생성하여 반환
return new PageImpl<>(content, pageable, total);
}

/**
* 지원하는 정렬 키에 대해, 미리 정의된 Q 필드를 반환합니다.
* sort가 없으면 '마감 임박순' 으로 기본 처리합니다.
*/
private OrderSpecifier<?> toOrderSpecifier(Sort sort, QRoomJpaEntity room, NumberExpression<Long> memberCountExpr) {
// sort 파라미터가 없으면 기본 마감 임박순
if (sort.isUnsorted()) {
return room.startDate.asc();
}

// 클라이언트가 보낸 첫 번째 sort 키를 꺼냅니다.
String key = sort.stream().findFirst().get().getProperty();

switch (key) {
case "memberCount":
// user_rooms 테이블에서 현재 참여자 수 집계 → 내림차순
return new OrderSpecifier<>(Order.DESC, memberCountExpr);
case "deadLine":
default:
// deadLine: 마감 임박순 = startDate 빠른 순서대로(오름차순)
return room.startDate.asc();
}
}
Comment on lines +121 to +139

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

정렬 키 추출 시 안전성을 개선해주세요.

findFirst().get() 사용은 NoSuchElementException을 발생시킬 수 있습니다.

  private OrderSpecifier<?> toOrderSpecifier(Sort sort, QRoomJpaEntity room, NumberExpression<Long> memberCountExpr) {
      // sort 파라미터가 없으면 기본 마감 임박순
      if (sort.isUnsorted()) {
          return room.startDate.asc();
      }

      // 클라이언트가 보낸 첫 번째 sort 키를 꺼냅니다.
-     String key = sort.stream().findFirst().get().getProperty();
+     String key = sort.stream()
+             .findFirst()
+             .map(Sort.Order::getProperty)
+             .orElse("deadLine");

      switch (key) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private OrderSpecifier<?> toOrderSpecifier(Sort sort, QRoomJpaEntity room, NumberExpression<Long> memberCountExpr) {
// sort 파라미터가 없으면 기본 마감 임박순
if (sort.isUnsorted()) {
return room.startDate.asc();
}
// 클라이언트가 보낸 첫 번째 sort 키를 꺼냅니다.
String key = sort.stream().findFirst().get().getProperty();
switch (key) {
case "memberCount":
// user_rooms 테이블에서 현재 참여자 수 집계 → 내림차순
return new OrderSpecifier<>(Order.DESC, memberCountExpr);
case "deadLine":
default:
// deadLine: 마감 임박순 = startDate 빠른 순서대로(오름차순)
return room.startDate.asc();
}
}
private OrderSpecifier<?> toOrderSpecifier(Sort sort, QRoomJpaEntity room, NumberExpression<Long> memberCountExpr) {
// sort 파라미터가 없으면 기본 마감 임박순
if (sort.isUnsorted()) {
return room.startDate.asc();
}
// 클라이언트가 보낸 첫 번째 sort 키를 꺼냅니다.
String key = sort.stream()
.findFirst()
.map(Sort.Order::getProperty)
.orElse("deadLine");
switch (key) {
case "memberCount":
// user_rooms 테이블에서 현재 참여자 수 집계 → 내림차순
return new OrderSpecifier<>(Order.DESC, memberCountExpr);
case "deadLine":
default:
// deadLine: 마감 임박순 = startDate 빠른 순서대로(오름차순)
return room.startDate.asc();
}
}
🤖 Prompt for AI Agents
In
src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryRepositoryImpl.java
around lines 121 to 139, the code uses findFirst().get() on the sort stream
which can throw NoSuchElementException if no elements exist. To fix this, safely
check if the first sort key is present using isPresent() or use orElse to
provide a default value before accessing it. This will prevent exceptions when
the sort parameter is empty.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package konkuk.thip.room.adapter.out.persistence;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

import java.util.Arrays;

@Getter
@RequiredArgsConstructor
public enum RoomSearchSortParam {

DEADLINE("deadline"),
MEMBER_COUNT("memberCount"),
RECOMMEND("인플루언서, 작가 추천"); // 개발 미정

private final String value;

public static RoomSearchSortParam from(String value) {
return Arrays.stream(RoomSearchSortParam.values())
.filter(param -> param.getValue().equals(value))
.findFirst()
.orElseThrow(
() -> new IllegalArgumentException("현재 정렬 조건 param : " + value)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package konkuk.thip.room.application.port.in;

import konkuk.thip.room.adapter.in.web.response.RoomSearchResponse;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

UseCase에서 web layer 의존성 검토가 필요합니다.

Application layer의 UseCase에서 web layer의 response DTO를 직접 의존하고 있습니다. 이는 Clean Architecture의 의존성 규칙을 위반할 수 있습니다.

Application layer에서 사용할 독립적인 DTO나 도메인 객체를 정의하는 것을 고려해보세요.

🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/room/application/port/in/RoomSearchUseCase.java at
line 3, the import of RoomSearchResponse from the web layer creates an unwanted
dependency from the application layer to the web layer, violating Clean
Architecture principles. To fix this, remove the import of the web layer DTO and
define a new independent DTO or domain object within the application layer that
represents the response data. Refactor the UseCase interface and its
implementations to use this new application-layer DTO instead of the web layer
response class.


public interface RoomSearchUseCase {

RoomSearchResponse searchRoom(String keyword, String category, String sort, int page);
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
package konkuk.thip.room.application.port.out;

import konkuk.thip.room.adapter.in.web.response.RoomSearchResponse;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

아키텍처 레이어 의존성 검토가 필요합니다.

Port 인터페이스(application layer)에서 web layer의 response DTO를 직접 의존하고 있습니다. Clean Architecture 원칙에 따르면 application layer는 adapter layer에 의존하지 않아야 합니다.

도메인/application layer에서 사용할 별도의 DTO나 도메인 객체를 정의하는 것을 고려해보세요.

🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/room/application/port/out/RoomQueryPort.java at
line 3, the import statement brings in a web layer response DTO into the
application layer port interface, violating Clean Architecture principles. To
fix this, remove the dependency on the web layer DTO by defining a separate DTO
or domain object within the domain or application layer that represents the data
needed. Then update the port interface to use this new internal DTO instead of
the web response class.

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

import java.time.LocalDate;

public interface RoomQueryPort {
int countRecruitingRoomsByBookAndStartDateAfter(Long bookId, LocalDate currentDate);

Page<RoomSearchResponse.RoomSearchResult> searchRoom(String keyword, String category, Pageable pageable);
}
Loading