Skip to content

Commit f682e56

Browse files
committed
feat: 유저 태그 생성 API
1 parent 25891a8 commit f682e56

File tree

13 files changed

+399
-3
lines changed

13 files changed

+399
-3
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.capturecat.core.api.user;
2+
3+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
4+
import org.springframework.web.bind.annotation.PostMapping;
5+
import org.springframework.web.bind.annotation.RequestMapping;
6+
import org.springframework.web.bind.annotation.RequestParam;
7+
import org.springframework.web.bind.annotation.RestController;
8+
9+
import lombok.RequiredArgsConstructor;
10+
11+
import com.capturecat.core.service.auth.LoginUser;
12+
import com.capturecat.core.service.image.TagResponse;
13+
import com.capturecat.core.service.user.UserTagService;
14+
import com.capturecat.core.support.response.ApiResponse;
15+
16+
@RestController
17+
@RequiredArgsConstructor
18+
@RequestMapping("/v1/user-tags")
19+
public class UserTagController {
20+
21+
private final UserTagService userTagService;
22+
23+
@PostMapping
24+
public ApiResponse<TagResponse> create(@AuthenticationPrincipal LoginUser loginUser, @RequestParam String tagName) {
25+
TagResponse tagResponse = userTagService.create(loginUser, tagName);
26+
27+
return ApiResponse.success(tagResponse);
28+
}
29+
}

capturecat-core/src/main/java/com/capturecat/core/domain/tag/TagRegister.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,15 @@ public List<Tag> registerTagsFor(List<String> tagNames) {
3838
result.addAll(savedNewTags);
3939
return result;
4040
}
41+
42+
/**
43+
* 등록되지 않은 태그는 등록하고, 이미 존재하는 태그는 그대로 반환합니다.
44+
* @param tagName 태그 이름
45+
* @return 조회되거나 새로 생성된 {@link Tag} 엔티티
46+
*/
47+
@Transactional
48+
public Tag registerTagsFor(String tagName) {
49+
return tagRepository.findByName(tagName)
50+
.orElseGet(() -> tagRepository.save(new Tag(tagName)));
51+
}
4152
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package com.capturecat.core.domain.tag;
22

33
import java.util.List;
4+
import java.util.Optional;
45

56
import org.springframework.data.jpa.repository.JpaRepository;
67

78
public interface TagRepository extends JpaRepository<Tag, Long>, TagCustomRepository {
89

910
List<Tag> findByNameIn(List<String> names);
11+
12+
Optional<Tag> findByName(String name);
1013
}

capturecat-core/src/main/java/com/capturecat/core/domain/user/UserTag.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import jakarta.persistence.UniqueConstraint;
1212

1313
import lombok.AccessLevel;
14+
import lombok.Getter;
1415
import lombok.NoArgsConstructor;
1516

1617
import com.capturecat.core.domain.BaseTimeEntity;
@@ -21,6 +22,7 @@
2122
uniqueConstraints = @UniqueConstraint(name = "uk_user_tag_user_tag", columnNames = {"user_id", "tag_id"})
2223
)
2324
@NoArgsConstructor(access = AccessLevel.PROTECTED)
25+
@Getter
2426
public class UserTag extends BaseTimeEntity {
2527

2628
@Id
@@ -34,4 +36,13 @@ public class UserTag extends BaseTimeEntity {
3436
@ManyToOne(fetch = FetchType.LAZY)
3537
@JoinColumn(name = "tag_id")
3638
private Tag tag;
39+
40+
private UserTag(User user, Tag tag) {
41+
this.user = user;
42+
this.tag = tag;
43+
}
44+
45+
public static UserTag create(User user, Tag tag) {
46+
return new UserTag(user, tag);
47+
}
3748
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.capturecat.core.domain.user;
2+
3+
import org.springframework.data.jpa.repository.JpaRepository;
4+
5+
import com.capturecat.core.domain.tag.Tag;
6+
7+
public interface UserTagRepository extends JpaRepository<UserTag, Long> {
8+
9+
boolean existsByUserAndTag(User user, Tag tag);
10+
11+
long countByUser(User user);
12+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.capturecat.core.service.user;
2+
3+
import org.springframework.dao.DataIntegrityViolationException;
4+
import org.springframework.stereotype.Service;
5+
import org.springframework.transaction.annotation.Transactional;
6+
7+
import lombok.RequiredArgsConstructor;
8+
import lombok.extern.slf4j.Slf4j;
9+
10+
import com.capturecat.core.domain.tag.Tag;
11+
import com.capturecat.core.domain.tag.TagRegister;
12+
import com.capturecat.core.domain.user.User;
13+
import com.capturecat.core.domain.user.UserRepository;
14+
import com.capturecat.core.domain.user.UserTag;
15+
import com.capturecat.core.domain.user.UserTagRepository;
16+
import com.capturecat.core.service.auth.LoginUser;
17+
import com.capturecat.core.service.image.TagResponse;
18+
import com.capturecat.core.support.error.CoreException;
19+
import com.capturecat.core.support.error.ErrorType;
20+
21+
@Slf4j
22+
@Service
23+
@RequiredArgsConstructor
24+
public class UserTagService {
25+
26+
private static final int MAX_USER_TAG_COUNT = 30;
27+
28+
private final UserRepository userRepository;
29+
private final UserTagRepository userTagRepository;
30+
private final TagRegister tagRegister;
31+
32+
@Transactional
33+
public TagResponse create(LoginUser loginUser, String tagName) {
34+
try {
35+
User user = userRepository.findByUsername(loginUser.getUsername())
36+
.orElseThrow(() -> new CoreException(ErrorType.USER_NOT_FOUND));
37+
Tag tag = tagRegister.registerTagsFor(tagName);
38+
39+
validate(user, tag);
40+
41+
userTagRepository.save(UserTag.create(user, tag));
42+
43+
return TagResponse.from(tag);
44+
} catch (DataIntegrityViolationException ex) {
45+
throw new CoreException(ErrorType.USER_TAG_ALREADY_EXISTS);
46+
}
47+
}
48+
49+
private void validate(User user, Tag tag) {
50+
validateDuplicateUserTag(user, tag);
51+
validateUserTagCountLimit(user);
52+
}
53+
54+
private void validateDuplicateUserTag(User user, Tag tag) {
55+
if (userTagRepository.existsByUserAndTag(user, tag)) {
56+
throw new CoreException(ErrorType.USER_TAG_ALREADY_EXISTS);
57+
}
58+
}
59+
60+
private void validateUserTagCountLimit(User user) {
61+
long userTagCount = userTagRepository.countByUser(user);
62+
63+
if (userTagCount >= MAX_USER_TAG_COUNT) {
64+
throw new CoreException(ErrorType.TOO_MANY_USER_TAGS);
65+
}
66+
}
67+
}

capturecat-core/src/main/java/com/capturecat/core/support/error/ErrorCode.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ public enum ErrorCode {
3838
SOCIAL_API_ERROR("소셜 서비스 API 호출 결과 실패를 응답받았습니다."),
3939
MISSING_PARAMETER("필수 파라미터 %s(이)가 누락되었습니다."),
4040
INTERNAL_SERVER_ERROR("서버에서 오류가 발생했습니다."),
41-
INVALID_LOGOUT_AUTH_TOKEN("ACCESS 토큰 또는 REFRESH 토큰이 유효하지 않습니다.");
41+
INVALID_LOGOUT_AUTH_TOKEN("ACCESS 토큰 또는 REFRESH 토큰이 유효하지 않습니다."),
42+
ALREADY_EXISTS_USER_TAG("이미 등록된 유저 태그입니다."),
43+
EXCEED_MAX_USER_TAG_COUNT("태그는 한 계정당 최대 30개까지 추가할 수 있어요.");
4244

4345
private final String message;
4446

capturecat-core/src/main/java/com/capturecat/core/support/error/ErrorType.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,14 @@ public enum ErrorType {
3838
INVALID_LOGOUT_AUTH_TOKEN(HttpStatus.UNAUTHORIZED, ErrorCode.INVALID_LOGOUT_AUTH_TOKEN, LogLevel.WARN),
3939
BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, ErrorCode.NOT_FOUND_BOOKMARK, LogLevel.WARN),
4040
GENERATE_CLIENT_SECRET_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.GENERATE_CLIENT_SECRET_FAIL,
41-
LogLevel.ERROR), UNLINK_SOCIAL_FAIL(HttpStatus.BAD_REQUEST, ErrorCode.UNLINK_SOCIAL_FAIL, LogLevel.WARN),
41+
LogLevel.ERROR),
42+
UNLINK_SOCIAL_FAIL(HttpStatus.BAD_REQUEST, ErrorCode.UNLINK_SOCIAL_FAIL, LogLevel.WARN),
4243
FETCH_SOCIAL_TOKEN_FAIL(HttpStatus.BAD_REQUEST, ErrorCode.FETCH_SOCIAL_TOKEN_FAIL, LogLevel.WARN),
4344
SOCIAL_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.SOCIAL_API_ERROR, LogLevel.WARN),
4445
MISSING_PARAMETER(HttpStatus.BAD_REQUEST, ErrorCode.MISSING_PARAMETER, LogLevel.WARN),
45-
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.INTERNAL_SERVER_ERROR, LogLevel.ERROR);
46+
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.INTERNAL_SERVER_ERROR, LogLevel.ERROR),
47+
USER_TAG_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, ErrorCode.ALREADY_EXISTS_USER_TAG, LogLevel.WARN),
48+
TOO_MANY_USER_TAGS(HttpStatus.BAD_REQUEST, ErrorCode.EXCEED_MAX_USER_TAG_COUNT, LogLevel.WARN);
4649

4750
private final HttpStatus status;
4851

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.capturecat.core.api.user;
2+
3+
import static com.capturecat.core.support.error.ErrorType.TOO_MANY_USER_TAGS;
4+
import static com.capturecat.core.support.error.ErrorType.USER_NOT_FOUND;
5+
import static com.capturecat.core.support.error.ErrorType.USER_TAG_ALREADY_EXISTS;
6+
7+
import java.util.List;
8+
9+
import org.junit.jupiter.api.Test;
10+
11+
import com.capturecat.core.api.error.ErrorCodeDocumentTest;
12+
import com.capturecat.test.snippet.ErrorCodeDescriptor;
13+
14+
class UserErrorCodeControllerTest extends ErrorCodeDocumentTest {
15+
16+
@Test
17+
void 유저_태그_생성_에러_코드_문서화() {
18+
List<ErrorCodeDescriptor> errorCodeDescriptors = generateErrorCodeDescriptors(USER_TAG_ALREADY_EXISTS,
19+
TOO_MANY_USER_TAGS, USER_NOT_FOUND);
20+
generateErrorDocs("errorCode/createUserTag", errorCodeDescriptors);
21+
}
22+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package com.capturecat.core.api.user;
2+
3+
import static com.capturecat.test.api.RestDocsUtil.requestPreprocessor;
4+
import static com.capturecat.test.api.RestDocsUtil.responsePreprocessor;
5+
import static org.mockito.ArgumentMatchers.any;
6+
import static org.mockito.ArgumentMatchers.anyString;
7+
import static org.mockito.Mockito.mock;
8+
import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
9+
import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders;
10+
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
11+
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
12+
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
13+
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
14+
import static org.springframework.restdocs.request.RequestDocumentation.queryParameters;
15+
16+
import org.junit.jupiter.api.BeforeEach;
17+
import org.junit.jupiter.api.Test;
18+
import org.mockito.BDDMockito;
19+
import org.springframework.http.HttpHeaders;
20+
import org.springframework.http.HttpStatus;
21+
import org.springframework.restdocs.payload.JsonFieldType;
22+
23+
import io.restassured.http.ContentType;
24+
25+
import com.capturecat.core.config.jwt.JwtUtil;
26+
import com.capturecat.core.service.image.TagResponse;
27+
import com.capturecat.core.service.user.UserTagService;
28+
import com.capturecat.test.api.RestDocsTest;
29+
30+
class UserTagControllerTest extends RestDocsTest {
31+
32+
private static final String ACCESS_TOKEN = "valid-access-token";
33+
34+
private UserTagController userTagController;
35+
private UserTagService userTagService;
36+
37+
@BeforeEach
38+
void setUp() {
39+
userTagService = mock(UserTagService.class);
40+
userTagController = new UserTagController(userTagService);
41+
mockMvc = mockController(userTagController);
42+
}
43+
44+
@Test
45+
void 유저_태그_생성() {
46+
// given
47+
BDDMockito.given(userTagService.create(any(), anyString())).willReturn(new TagResponse(1L, "java"));
48+
49+
// when & then
50+
given()
51+
.header(HttpHeaders.AUTHORIZATION, JwtUtil.BEARER_PREFIX + ACCESS_TOKEN)
52+
.contentType(ContentType.JSON)
53+
.queryParam("tagName", "java")
54+
.when().post("/v1/user-tags")
55+
.then().status(HttpStatus.OK)
56+
.apply(document("createUserTag", requestPreprocessor(), responsePreprocessor(),
57+
requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("유효한 Access 토큰")),
58+
queryParameters(parameterWithName("tagName").description("태그 이름")),
59+
responseFields(
60+
fieldWithPath("result").type(JsonFieldType.STRING).description("요청 결과"),
61+
fieldWithPath("data").type(JsonFieldType.OBJECT).description("사용자가 등록한 태그 정보"),
62+
fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("태그 ID"),
63+
fieldWithPath("data.name").type(JsonFieldType.STRING).description("태그 이름"))));
64+
}
65+
}

0 commit comments

Comments
 (0)