Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ 기록 조회 및 생성 API & Util 및 사용자 정의 함수 적용 #91

Merged
merged 18 commits into from
Feb 1, 2024

Conversation

psychology50
Copy link
Member

@psychology50 psychology50 commented Feb 1, 2024

작업 이유

  • Care Memo Category 및 Memo 생성 및 조회
  • MySQL Full Text Index 도입 및 관련 Util 클래스와 사용자 정의 함수, QueryDsl을 통한 검색 최적화
  • pet_id 없이 Schedule 등록 가능하도록 API 수정
  • 기존에 등록된 반려동물의 root memo category 추가

수정 사항

1️⃣ 일정 등록 시 URL 경로 상의 pet_id 제거

  • 기존 URL: /api/v2/users/{user_id}/schedules
  • 변경 URL: /api/v2/schedules
  • 요청 예시
    • 등록하려는 모든 반려동물 id를 petIds 필드에 삽입
    • petIds의 반려동물 중 하나라도 관리 권한이 없는 경우 403 FORBIDDEN
{
    "scheduleName" : "기깔나게 놀기",
    "location" : "기깔난 집",
    "reservationDate" : "2024-01-29 23:00:00",
    "notifyTime" : 30,
    "petIds" : [4]
}

2️⃣ 일정 관련 API 항목에서 user_id 제거 (2개 변동)

🟡 일정 리스트 조회

  • 기존 URL
    • /api/v2/users/{user_id}/pets/{pet_id}/schedules
    • /api/v2/users/{user_id}/pets/{pet_id}/schedules?count=
  • 변경 URL
    • /api/v2/pets/4/schedules
    • /api/v2/pets/4/schedules?count=
  • 응답은 기존과 동일

🟡 관리 중인 모든 반려동물의 임의의 날짜 조회

  • 기존 URL : api/v2/users/{user_id}/schedules?year=&month=&day=
  • 변경 URL : api/v2/accounts/{user_id}/schedules?year=&month=&day=
  • 응답은 기존과 동일

3️⃣ 기존에 등록된 반려동물의 부모 카테고리 생성

  • 반려동물 생성 시, 자동으로 반려동물의 부모 카테고리 생성 로직 추가
  • 이전에 저장된 데이터에 대해서 부모 카테고리 데이터 추가
INSERT INTO memo_category
SELECT NULL, pet.id, NULL, pet.pet_name, now(), now()
FROM pet;

작업 사항

1️⃣ 서브 메모 카테고리 저장

  • 요청: POST /api/v2/pets/{pet_id}/root-memo-categories/{root_memo_category_id}
    • sub_memo_category_id를 전송하는 경우 403 FORBIDDEN
    {
      "subMemoCategoryName": "딩가딩가"
    }
  • 응답: 201 CREATED

2️⃣ 카테고리 리스트 조회

⚠️ 만들고 보니 의미가 없는 API긴 한데, 검증 혹은 확인용으로 사용하면 좋을 것 같습니다.

  • 요청: GET /api/v2/pets/{pet_id}/memo-categories/{memo_category_id}
  • 응답
    • memo_category_id는 상위/하위 모두 가능합니다.
    • 상위 카테고리인 경우 type=ROOT이며, subMemoCategories 필드가 반드시 존재합니다.
    • 하위 카테고리인 경우 type=SUB이며, subMemoCategories 필드가 존재하지 않습니다.

3️⃣ 유저의 모든 메모 카테고리 리스트 조회

  • 요청: GET /api/v2/accounts/{user_id}/memo-categories
  • 응답
    • data 필드 이후 최상위 필드는 언제나 rootMemoCategories입니다.
    • 이후는 (2)와 동일한 포맷을 따릅니다.

4️⃣ 메모 등록

  • 요청: POST /api/v2/pets/3/memo-categories/4/memos
    {
       "title" : "메모 제목",
       "content" : "메모 내용",
       "memoImageUrls" : ["이미지 경로1", "이미지 경로"] # 없으면 빈 배열
    }
  • 응답
    • 201 CREATED

5️⃣ 메모 단건 조회

  • 요청 : GET /api/v2/pets/{pet_id}/memo-categories/{memo_category_id}/memos/{memo_id}
    • memo_idmemo_category_id에 속해있지 않거나, memo_category_idpet_id의 카테고리가 아닌 경우 모두 403 FORBIDDEN (따로 작성하지 않은 다른 api도 모두 동일한 규칙을 지닙니다.)
  • 응답

⚠️ categorySuffix 필드 주의 사항 (필독!!!!)
memo 조회 시, 해당 메모가 등록된 카테고리명은 줄 건데 루트/서브 포맷이 아님.

예를 들어, 부모 카테고리 "웅이"와 서브 카테고리 "식사"가 있으면
- 루트 카테고리에 속해있는 메모일 경우, `categorySuffix = 웅이`
- 서브 카테고리에 속해있는 메모일 경우, `categorySuffix = 식사`

따라서 카테고리 이름이 반려동물과 일치할 경우엔 그냥 그대로 쓰고, 아니면 반려동물이름/categoryNameSuffix로 문자열 생성해주어야 함.
메모를 조회하는 경우 반려동물을 통해서 확인하는 경우와 카테고리 페이지에서 확인하는 경우가 있는데,
두 가지 모두 반려동물 이름을 추론할 수 있다는 가정 하에 작성. (불가능하면 말씀해주세요)


6️⃣ 카테고리 내 메모 리스트 조회 및 검색 (Pagenation 적용)

  • 요청
    • 단순 카테고리 내 메모 리스트 조회 시 -> GET /api/v2/pets/{pet_id}/memo-categories/{memo_category_id}/memos
    • 검색 기능 사용 시 -> GET /api/v2/pets/{pet_id}/memo-categories/{memo_category_id}/memos?search=
    • 기본 Page 요청 값: size = 15, page = 0, sort = "memo.createdAt", direction = Sort.Direction.DESC
    • search param 미입력 시 전체 조회, 입력 시 해당 단어 기반 검색
  • 응답:
    • 카테고리 내 메모 리스트 조회(search 파라미터 X)
  • 카테고리 내 메모 단어 검색 결과 리스트 조회(search 파라미터 O)

🟡 Pagenation QueryParameter 사용법

  • Pagenation이 적용된 API에서 제공하는 QueryParameter
    • size: 한 번에 불러올 데이터 크기 설정 (기본값은 API 요청 참고)
    • page: 불러올 페이지 설정 (가장 처음은 0)
    • sort: 정렬 방식 (memo.id, mamoCategory.createdAt 등등)
    • direction: 정렬 방향 (ASC, DESC)
  • 예시
    • size = 2, page = 1, mome.id 기준 ASC
    • GET /api/v2/pets/{pet_id}/memo-categories/{memo_category_id}/memos?size=2&page=1&sort=memo.id,ASC

7️⃣ 반려동물의 가장 최근 메모 조회 (Pagenation 적용)

  • 요청: GET /api/v2/pets/3/memos
    • 기본 Page 요청 값: size = 5, page = 0, sort = "memo.createdAt", direction = Sort.Direction.DESC
  • 응답

개발 이슈

1️⃣ MySQL Full Text Index 설정

  • 단어 검색 속도 개선을 위한 Full Text Index 설정
  • titlecontent 필드를 포함하여 전체 검색 가능
  • 느슨한 검색 조건을 위해 boolean mode 탐색 선정
CREATE FULLTEXT INDEX memo_title_content_fulltext_index ON memo(title, content);
SELECT c.id, m.id, LEFT(m.title, 19), LEFT(m.content, 16) content, m.created_at, i.id, i.img_url
FROM memo m
INNER JOIN memo_category c ON c.id = m.category_id
LEFT JOIN memo_image i ON i.memo_id = m.id
WHERE c.id = 4
AND MATCH(m.title, m.content) AGAINST('병원*' IN BOOLEAN MODE)
ORDER BY m.created_at DESC
;

2️⃣ MySQL 방언 사용을 위한 Spring Boot 사용자 정의 함수

  • QueryDsl에서 MySQL 방언을 지원하지 않는 이슈 발생
  • 사용자 정의 함수를 통해 match againstleft 사용자 정의 함수 작성
public class MySqlFunctionContributor implements FunctionContributor {
    private static final String FUNCTION_NAME = "match_against";
    private static final String TWO_COLUMN_BOOLEAN_PATTERN = "match(?1, ?2) against(?3 in boolean mode)";

    @Override
    public void contributeFunctions(final FunctionContributions functionContributions) {
        SqmFunctionRegistry registry = functionContributions.getFunctionRegistry();
        TypeConfiguration typeConfiguration = functionContributions.getTypeConfiguration();

        registry.registerPattern( FUNCTION_NAME, TWO_COLUMN_BOOLEAN_PATTERN, typeConfiguration.getBasicTypeRegistry().resolve(StandardBasicTypes.BOOLEAN) );
        registry.registerPattern( "left", "left(?1, ?2)", typeConfiguration.getBasicTypeRegistry().resolve(StandardBasicTypes.STRING) );
    }
}

3️⃣ 무한 스크롤 기능을 위한 Slice 및 관련 Util 클래스 구현

  • pageSize + 1만큼 데이터 조회
  • 조회한 데이터에서 pageSize + 1에 데이터가 있다면 hasNext = true로 설정한 후, 잉여 데이터 제거
public class RepositorySliceHelper {
    public static <T> Slice<T> toSlice(List<T> contents, Pageable pageable) {
        boolean hasNext = isContentSizeGreaterThanPageSize(contents, pageable);
        return new SliceImpl<>(hasNext ? subListLastContent(contents, pageable) : contents, pageable, hasNext);
    }

    private static <T> boolean isContentSizeGreaterThanPageSize(List<T> content, Pageable pageable) {
        return pageable.isPaged() && content.size() > pageable.getPageSize();
    }

    // 데이터 1개 빼고 반환
    private static <T> List<T> subListLastContent(List<T> content, Pageable pageable) {
        return content.subList(0, pageable.getPageSize());
    }
}

4️⃣ Pageable OrderSpecifier 추론 및 편의 메서드를 위한 Util 클래스 구현

  • 사용자 정의 함수 적용을 위한 도우미 메서드
  • Pageablesort 규칙을 적용하기 위한 OrderSpecifier 타입 추론 메서드
@Slf4j
public class QueryDslUtil {
    private static final Function<Sort.NullHandling, OrderSpecifier.NullHandling> castToQueryDsl = nullHandling -> switch (nullHandling) {
        case NATIVE -> OrderSpecifier.NullHandling.Default;
        case NULLS_FIRST -> OrderSpecifier.NullHandling.NullsFirst;
        case NULLS_LAST -> OrderSpecifier.NullHandling.NullsLast;
    };

    /**
     * match_against 함수를 사용하여 memo 테이블의 title, content 컬럼과 target을 비교한다.
     * @param c1 : memo.title
     * @param c2 : memo.content
     * @param target : 검색어
     */
    public static BooleanExpression matchAgainst(final StringPath c1, final StringPath c2, final String target) {
        if (!StringUtils.hasText(target)) { return null; }
        String template = "'" + target + "*'";
        log.info("template: {}", template);
        return Expressions.booleanTemplate( "function('match_against', {0}, {1}, {2})", c1, c2, template);
    }

    /**
     * LEFT 함수로 문자열을 c2 길이만큼 잘라서 반환한다.
     */
    public static StringExpression left(final StringPath c1, final Expression<Integer> c2) {
        return Expressions.stringTemplate("function('left', {0}, {1})", c1, c2);
    }

    /**
     * Pageable의 sort를 QueryDsl의 OrderSpecifier로 변환한다.
     * @param sort : Pageable의 sort
     */
    public static List<OrderSpecifier<?>> getOrderSpecifier(Sort sort) {
        List<OrderSpecifier<?>> orders = new ArrayList<>();

        for (Sort.Order order : sort) {
            OrderSpecifier.NullHandling nullHandling = castToQueryDsl.apply(order.getNullHandling());
            orders.add(getOrderSpecifier(order, nullHandling));
        }

        return orders;
    }

    private static OrderSpecifier<?> getOrderSpecifier(Sort.Order order, OrderSpecifier.NullHandling nullHandling) {
        Order orderBy = order.isAscending() ? Order.ASC : Order.DESC;
        log.info("isAscending: {}", order.isAscending());

        return createOrderSpecifier(orderBy, Expressions.stringPath(order.getProperty()), nullHandling);
    }

    @SuppressWarnings({ "rawtypes", "unchecked" })
    private static OrderSpecifier<?> createOrderSpecifier(Order orderBy, Expression<?> expression, OrderSpecifier.NullHandling queryDslNullHandling) {
        if (expression instanceof Operation && ((Operation<?>) expression).getOperator() == Ops.ALIAS) {
            return new OrderSpecifier<>(orderBy, Expressions.stringPath(((Operation<?>) expression).getArg(1).toString()), queryDslNullHandling);
        } else {
            return new OrderSpecifier(orderBy, expression, queryDslNullHandling);
        }
    }
}

이슈 연결

close #90 #83

Copy link
Contributor

@heejinnn heejinnn left a comment

Choose a reason for hiding this comment

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

구현하시느라 수고 많으셨어요~~!!

@heejinnn heejinnn merged commit cf0444d into develop Feb 1, 2024
1 check passed
@heejinnn heejinnn deleted the feat/83 branch February 1, 2024 12:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

✨ 일정 등록 시, pet 선택 없이 등록 가능 API
2 participants