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
4 changes: 4 additions & 0 deletions capturecat-core/src/docs/asciidoc/error-codes.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ include::{snippets}/errorCode/autocomplete/error-codes.adoc[]
=== 유저 태그 생성
include::{snippets}/errorCode/createUserTag/error-codes.adoc[]

[[유저-태그-조회]]
=== 유저 태그 조회
include::{snippets}/errorCode/getUserTags/error-codes.adoc[]

[[유저-태그-수정]]
=== 유저 태그 수정
include::{snippets}/errorCode/updateUserTag/error-codes.adoc[]
12 changes: 12 additions & 0 deletions capturecat-core/src/docs/asciidoc/user.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ operation::createUserTag[snippets='curl-request,http-request,request-headers,que

<<error-codes#유저-태그-생성, 유저 태그 생성 API에서 발생할 수 있는 에러>>를 살펴보세요.

[[유저-태그-조회]]
=== 유저 태그 조회
사용자의 태그를 조회합니다.

==== 성공
operation::getUserTags[snippets='curl-request,http-request,request-headers,http-response,response-fields']

==== 실패
유저 태그 조회가 실패했다면 HTTP 상태 코드와 함께 <<에러-객체-형식, 에러 객체>>가 돌아옵니다.

<<error-codes#유저-태그-조회, 유저 태그 조회 API에서 발생할 수 있는 에러>>를 살펴보세요.

[[유저-태그-수정]]
=== 유저 태그 수정
==== 성공
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package com.capturecat.core.api.user;

import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
Expand All @@ -15,6 +18,7 @@
import com.capturecat.core.service.image.TagResponse;
import com.capturecat.core.service.user.UserTagService;
import com.capturecat.core.support.response.ApiResponse;
import com.capturecat.core.support.response.CursorResponse;

@RestController
@RequiredArgsConstructor
Expand All @@ -30,6 +34,14 @@ public ApiResponse<TagResponse> create(@AuthenticationPrincipal LoginUser loginU
return ApiResponse.success(tagResponse);
}

@GetMapping
public ApiResponse<CursorResponse<TagResponse>> getAll(@AuthenticationPrincipal LoginUser loginUser,
@PageableDefault Pageable pageable) {
CursorResponse<TagResponse> tagResponse = userTagService.getAll(loginUser, pageable);

return ApiResponse.success(tagResponse);
}
Comment on lines +37 to +43
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Cursor response but offset-based input — switch to true keyset pagination.

The endpoint returns CursorResponse but accepts Pageable (page/offset). With the current repository impl using offset/limit, large offsets will degrade and can cause inconsistent pages under concurrent writes. Expose cursor params (lastCursor, size) and have the service/repo use id-based keyset (id < lastCursor) instead of offset.

Apply this diff (requires coordinated service + repository interface changes):

-	@GetMapping
-	public ApiResponse<CursorResponse<TagResponse>> getAll(@AuthenticationPrincipal LoginUser loginUser,
-		@PageableDefault Pageable pageable) {
-		CursorResponse<TagResponse> tagResponse = userTagService.getAll(loginUser, pageable);
-		return ApiResponse.success(tagResponse);
-	}
+	@GetMapping
+	public ApiResponse<CursorResponse<TagResponse>> getAll(
+		@AuthenticationPrincipal LoginUser loginUser,
+		@RequestParam(name = "lastCursor", required = false) Long lastCursor,
+		@RequestParam(name = "size", defaultValue = "20") int size
+	) {
+		CursorResponse<TagResponse> tagResponse = userTagService.getAll(loginUser, lastCursor, size);
+		return ApiResponse.success(tagResponse);
+	}
📝 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
@GetMapping
public ApiResponse<CursorResponse<TagResponse>> getAll(@AuthenticationPrincipal LoginUser loginUser,
@PageableDefault Pageable pageable) {
CursorResponse<TagResponse> tagResponse = userTagService.getAll(loginUser, pageable);
return ApiResponse.success(tagResponse);
}
@GetMapping
public ApiResponse<CursorResponse<TagResponse>> getAll(
@AuthenticationPrincipal LoginUser loginUser,
@RequestParam(name = "lastCursor", required = false) Long lastCursor,
@RequestParam(name = "size", defaultValue = "20") int size
) {
CursorResponse<TagResponse> tagResponse = userTagService.getAll(loginUser, lastCursor, size);
return ApiResponse.success(tagResponse);
}
🤖 Prompt for AI Agents
In
capturecat-core/src/main/java/com/capturecat/core/api/user/UserTagController.java
around lines 34 to 40, the controller currently returns a CursorResponse but
accepts a Pageable (offset-based) which must be replaced with true keyset/cursor
pagination: change the method signature to accept cursor parameters (e.g., Long
lastCursor, int size) instead of Pageable, validate and default size,
convert/forward those params to the userTagService.getAll call, and update the
service interface to accept (lastCursor, size) so the repository can implement
id-based keyset queries (WHERE id < :lastCursor ORDER BY id DESC LIMIT :size or
similar) returning a CursorResponse<TagResponse>; ensure the controller maps
missing lastCursor to "start from newest" semantics and wraps the returned
CursorResponse in ApiResponse.success before returning.


@PatchMapping
public ApiResponse<TagResponse> update(@AuthenticationPrincipal LoginUser loginUser,
@RequestBody TagRenameRequest request) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.capturecat.core.domain.user;

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

public interface UserTagCustomRepository {

Slice<UserTag> findAllByUser(User user, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.capturecat.core.domain.user;

import static com.capturecat.core.domain.user.QUserTag.*;

import java.util.List;

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

import com.querydsl.jpa.impl.JPAQueryFactory;

import lombok.RequiredArgsConstructor;

import com.capturecat.core.support.util.SliceUtil;

@RequiredArgsConstructor
public class UserTagCustomRepositoryImpl implements UserTagCustomRepository {

private final JPAQueryFactory queryFactory;

@Override
public Slice<UserTag> findAllByUser(User user, Pageable pageable) {
List<UserTag> userTags = queryFactory
.selectFrom(userTag)
.leftJoin(userTag.tag).fetchJoin()
.where(userTag.user.eq(user))
.offset(pageable.getOffset())
.limit(pageable.getPageSize() + 1)
.orderBy(userTag.id.desc())
.fetch();

return SliceUtil.toSlice(userTags, pageable);
}
Comment on lines +21 to +33
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Offset pagination in “cursor” flow — migrate to keyset (id < lastCursor).

Current query uses offset/limit; this breaks true cursor semantics and can suffer O(n) skips for large pages. Prefer keyset on id with size+1 fetch.

Apply this diff (and update the interface + service accordingly):

-	@Override
-	public Slice<UserTag> findAllByUser(User user, Pageable pageable) {
-		List<UserTag> userTags = queryFactory
-			.selectFrom(userTag)
-			.leftJoin(userTag.tag).fetchJoin()
-			.where(userTag.user.eq(user))
-			.offset(pageable.getOffset())
-			.limit(pageable.getPageSize() + 1)
-			.orderBy(userTag.id.desc())
-			.fetch();
-
-		return SliceUtil.toSlice(userTags, pageable);
-	}
+	@Override
+	public Slice<UserTag> findAllByUser(User user, Long lastCursor, int size) {
+		List<UserTag> userTags = queryFactory
+			.selectFrom(userTag)
+			.leftJoin(userTag.tag).fetchJoin()
+			.where(
+				userTag.user.eq(user),
+				lastCursor != null ? userTag.id.lt(lastCursor) : null
+			)
+			.orderBy(userTag.id.desc())
+			.limit(size + 1)
+			.fetch();
+
+		return SliceUtil.toSlice(userTags, org.springframework.data.domain.PageRequest.of(0, size));
+	}

Follow-ups:

  • Change UserTagCustomRepository signature to (User user, Long lastCursor, int size).
  • Adapt UserTagService and controller accordingly.
📝 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
@Override
public Slice<UserTag> findAllByUser(User user, Pageable pageable) {
List<UserTag> userTags = queryFactory
.selectFrom(userTag)
.leftJoin(userTag.tag).fetchJoin()
.where(userTag.user.eq(user))
.offset(pageable.getOffset())
.limit(pageable.getPageSize() + 1)
.orderBy(userTag.id.desc())
.fetch();
return SliceUtil.toSlice(userTags, pageable);
}
@Override
public Slice<UserTag> findAllByUser(User user, Long lastCursor, int size) {
List<UserTag> userTags = queryFactory
.selectFrom(userTag)
.leftJoin(userTag.tag).fetchJoin()
.where(
userTag.user.eq(user),
lastCursor != null ? userTag.id.lt(lastCursor) : null
)
.orderBy(userTag.id.desc())
.limit(size + 1)
.fetch();
return SliceUtil.toSlice(
userTags,
org.springframework.data.domain.PageRequest.of(0, size)
);
}
🤖 Prompt for AI Agents
In
capturecat-core/src/main/java/com/capturecat/core/domain/user/UserTagCustomRepositoryImpl.java
around lines 21 to 33, the current implementation uses offset/limit for
pagination which breaks cursor semantics; change the repository signature to
findAllByUser(User user, Long lastCursor, int size) (and update the
interface/service/controller to match), remove offset usage, add a keyset WHERE
clause userTag.id < lastCursor when lastCursor is non-null, set limit to size +
1, keep orderBy(userTag.id.desc()), fetch the results and return a Slice
constructed from the size+1 sentinel to determine hasNext. Ensure NPE-safe
handling of a null lastCursor (treat as head) and propagate the new parameters
through service and controller method signatures.

}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import com.capturecat.core.domain.tag.Tag;

public interface UserTagRepository extends JpaRepository<UserTag, Long> {
public interface UserTagRepository extends JpaRepository<UserTag, Long>, UserTagCustomRepository {

Optional<UserTag> findByUserAndTag(User user, Tag tag);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package com.capturecat.core.service.user;

import java.util.List;

import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -18,6 +22,8 @@
import com.capturecat.core.service.image.TagResponse;
import com.capturecat.core.support.error.CoreException;
import com.capturecat.core.support.error.ErrorType;
import com.capturecat.core.support.response.CursorResponse;
import com.capturecat.core.support.util.CursorUtil;

@Slf4j
@Service
Expand Down Expand Up @@ -48,6 +54,19 @@ public TagResponse create(LoginUser loginUser, String tagName) {
}
}

@Transactional(readOnly = true)
public CursorResponse<TagResponse> getAll(LoginUser loginUser, Pageable pageable) {
User user = userRepository.findByUsername(loginUser.getUsername())
.orElseThrow(() -> new CoreException(ErrorType.USER_NOT_FOUND));
Slice<UserTag> userTags = userTagRepository.findAllByUser(user, pageable);

List<TagResponse> tags = userTags.stream()
.map(ut -> TagResponse.from(ut.getTag()))
.toList();

return CursorUtil.toCursorResponse(tags, userTags.hasNext(), TagResponse::id);
}

@Transactional
public TagResponse update(LoginUser loginUser, Long currentTagId, String newTagName) {
User user = userRepository.findByUsername(loginUser.getUsername())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ class UserErrorCodeControllerTest extends ErrorCodeDocumentTest {
generateErrorDocs("errorCode/createUserTag", errorCodeDescriptors);
}

@Test
void 유저_태그_조회_에러_코드_문서화() {
List<ErrorCodeDescriptor> errorCodeDescriptors = generateErrorCodeDescriptors(USER_NOT_FOUND);
generateErrorDocs("errorCode/getUserTags", errorCodeDescriptors);
}

@Test
void 유저_태그_수정_에러_코드_문서화() {
List<ErrorCodeDescriptor> errorCodeDescriptors = generateErrorCodeDescriptors(USER_TAG_ALREADY_EXISTS,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.queryParameters;

import java.util.List;
import java.util.Map;

import org.junit.jupiter.api.BeforeEach;
Expand All @@ -29,6 +30,7 @@
import com.capturecat.core.config.jwt.JwtUtil;
import com.capturecat.core.service.image.TagResponse;
import com.capturecat.core.service.user.UserTagService;
import com.capturecat.core.support.response.CursorResponse;
import com.capturecat.test.api.RestDocsTest;

class UserTagControllerTest extends RestDocsTest {
Expand Down Expand Up @@ -67,6 +69,30 @@ void setUp() {
fieldWithPath("data.name").type(JsonFieldType.STRING).description("태그 이름"))));
}

@Test
void 유저_태그_조회() {
// given
BDDMockito.given(userTagService.getAll(any(), any())).willReturn(
new CursorResponse(false, 1L, List.of(new TagResponse(1L, "java"))));

// when & then
given()
.header(HttpHeaders.AUTHORIZATION, JwtUtil.BEARER_PREFIX + ACCESS_TOKEN)
.contentType(ContentType.JSON)
.when().get("/v1/user-tags")
.then().status(HttpStatus.OK)
.apply(document("getUserTags", requestPreprocessor(), responsePreprocessor(),
requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("유효한 Access 토큰")),
responseFields(
fieldWithPath("result").type(JsonFieldType.STRING).description("요청 결과"),
fieldWithPath("data").type(JsonFieldType.OBJECT).description("커서 페이지 응답"),
fieldWithPath("data.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"),
fieldWithPath("data.lastCursor").type(JsonFieldType.NUMBER).description("마지막 커서 ID"),
fieldWithPath("data.items").type(JsonFieldType.ARRAY).description("태그 목록"),
fieldWithPath("data.items[].id").type(JsonFieldType.NUMBER).description("태그 ID"),
fieldWithPath("data.items[].name").type(JsonFieldType.STRING).description("태그 이름"))));
}

@Test
void 유저_태그_수정() {
// given
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,23 @@
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

import java.util.List;
import java.util.Optional;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.SliceImpl;

import com.capturecat.core.DummyObject;
import com.capturecat.core.domain.tag.TagFixture;
import com.capturecat.core.domain.tag.TagRegister;
import com.capturecat.core.domain.tag.TagRepository;
import com.capturecat.core.domain.user.UserRepository;
import com.capturecat.core.domain.user.UserTag;
import com.capturecat.core.domain.user.UserTagFixture;
import com.capturecat.core.domain.user.UserTagRepository;
import com.capturecat.core.service.auth.LoginUser;
Expand Down Expand Up @@ -121,6 +125,37 @@ class UserTagServiceTest {
verify(userTagRepository, never()).save(any());
}

@Test
void 유저_태그를_조회한다() {
// given
var user = DummyObject.newUser("test");
var tag = TagFixture.createTag(1L, "java");

given(userRepository.findByUsername(anyString())).willReturn(Optional.of(user));
given(userTagRepository.findAllByUser(eq(user), any()))
.willReturn(new SliceImpl<>(List.of(UserTag.create(user, tag))));

// when
var response = userTagService.getAll(new LoginUser(user), PageRequest.of(0, 10));

// then
assertThat(response.items()).hasSize(1);
assertThat(response.items().get(0).name()).isEqualTo(tag.getName());
}

@Test
void 유저_태그를_조회_시_사용자가_존재하지_않으면_실패한다() {
// given
var user = DummyObject.newUser("test");

given(userRepository.findByUsername(anyString())).willReturn(Optional.empty());

// when & then
assertThatThrownBy(() -> userTagService.getAll(new LoginUser(user), PageRequest.of(0, 10)))
.isInstanceOf(CoreException.class)
.hasMessage(ErrorType.USER_NOT_FOUND.getCode().getMessage());
}

@Test
void 유저_태그를_수정한다() {
// given
Expand Down