diff --git a/docs/s1-api-spec.md b/docs/s1-api-spec.md index ecdba10..3ac70f4 100644 --- a/docs/s1-api-spec.md +++ b/docs/s1-api-spec.md @@ -812,7 +812,9 @@ GET /api/v1/users/me/usage ##### Error Code -이 API 전용 에러 없음 (공통 에러만) +| HttpStatus | ResultCode | ErrorMessage | Description | +| --- | --- | --- | --- | +| 404 | USER404_1 | 회원 정보를 찾을 수 없어요. | 탈퇴했거나 없는 회원의 토큰 | ##### 비고 diff --git a/src/main/java/com/sniptext/domain/user/controller/UserController.java b/src/main/java/com/sniptext/domain/user/controller/UserController.java new file mode 100644 index 0000000..ddb2f19 --- /dev/null +++ b/src/main/java/com/sniptext/domain/user/controller/UserController.java @@ -0,0 +1,30 @@ +package com.sniptext.domain.user.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.sniptext.domain.user.controller.dto.UserUsageResponse; +import com.sniptext.domain.user.service.UserUsageQueryService; +import com.sniptext.global.response.ApiResponse; +import com.sniptext.global.security.LoginUser; + +import lombok.RequiredArgsConstructor; + +/** + * 회원 (로그인 필요, 캐시 금지). + */ +@RestController +@RequestMapping("/api/v1/users") +@RequiredArgsConstructor +public class UserController { + + private final UserUsageQueryService userUsageQueryService; + + @GetMapping("/me/usage") + public ResponseEntity> getUsage(@LoginUser Long userId) { + return ApiResponse.success(UserSuccessCode.USAGE_FOUND, UserUsageResponse.from(userUsageQueryService.getUsage(userId))); + } + +} diff --git a/src/main/java/com/sniptext/domain/user/controller/UserSuccessCode.java b/src/main/java/com/sniptext/domain/user/controller/UserSuccessCode.java new file mode 100644 index 0000000..ff25d7d --- /dev/null +++ b/src/main/java/com/sniptext/domain/user/controller/UserSuccessCode.java @@ -0,0 +1,20 @@ +package com.sniptext.domain.user.controller; + +import org.springframework.http.HttpStatus; + +import com.sniptext.global.response.ResultCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum UserSuccessCode implements ResultCode { + + USAGE_FOUND(HttpStatus.OK, "USER200_2", "사용량 조회에 성공했습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + +} diff --git a/src/main/java/com/sniptext/domain/user/controller/dto/UserUsageResponse.java b/src/main/java/com/sniptext/domain/user/controller/dto/UserUsageResponse.java new file mode 100644 index 0000000..a1107fa --- /dev/null +++ b/src/main/java/com/sniptext/domain/user/controller/dto/UserUsageResponse.java @@ -0,0 +1,25 @@ +package com.sniptext.domain.user.controller.dto; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.sniptext.domain.user.service.dto.result.UserUsageResult; + +/** 사용량 (명세 6번). 시각은 전역 직렬화 설정이 +09:00을 붙여 내보낸다. */ +public record UserUsageResponse(TemplateUsage template, AiToneUsage aiTone) { + + public static UserUsageResponse from(UserUsageResult result) { + UserUsageResult.TemplateUsage template = result.template(); + UserUsageResult.AiToneUsage aiTone = result.aiTone(); + return new UserUsageResponse( + new TemplateUsage(template.count(), template.limit(), template.remaining(), template.full()), + new AiToneUsage(aiTone.usedCount(), aiTone.limit(), aiTone.remaining(), aiTone.resetAt())); + } + + public record TemplateUsage(long count, int limit, long remaining, @JsonProperty("isFull") boolean isFull) { + } + + public record AiToneUsage(int usedCount, int limit, int remaining, LocalDateTime resetAt) { + } + +} diff --git a/src/main/java/com/sniptext/domain/user/service/UserUsageQueryService.java b/src/main/java/com/sniptext/domain/user/service/UserUsageQueryService.java new file mode 100644 index 0000000..16118d3 --- /dev/null +++ b/src/main/java/com/sniptext/domain/user/service/UserUsageQueryService.java @@ -0,0 +1,38 @@ +package com.sniptext.domain.user.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.sniptext.domain.ai.service.AiToneUsageQueryService; +import com.sniptext.domain.template.service.TemplateQueryService; +import com.sniptext.domain.user.exception.UserErrorCode; +import com.sniptext.domain.user.repository.UserRepository; +import com.sniptext.domain.user.service.dto.result.UserUsageResult; +import com.sniptext.global.exception.BusinessException; +import com.sniptext.global.policy.UsageLimits; + +import lombok.RequiredArgsConstructor; + +/** + * 사용량 조회. template·ai 서비스를 조합하므로 UserQueryService와 분리한다. + * UserQueryService에 넣으면 template·ai가 UserQueryService를 부르게 될 때 생성자 주입이 순환해 앱이 뜨지 않는다. + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserUsageQueryService { + + private final UserRepository userRepository; + private final TemplateQueryService templateQueryService; + private final AiToneUsageQueryService aiToneUsageQueryService; + + /** 없거나 탈퇴한 회원이면 USER404_1. */ + public UserUsageResult getUsage(Long userId) { + if (userRepository.findByIdAndDeletedAtIsNull(userId).isEmpty()) { + throw new BusinessException(UserErrorCode.USER_NOT_FOUND); + } + return UserUsageResult.of(templateQueryService.countActive(userId), UsageLimits.TEMPLATE_LIMIT, + aiToneUsageQueryService.getTodayUsage(userId)); + } + +} diff --git a/src/main/java/com/sniptext/domain/user/service/dto/result/UserUsageResult.java b/src/main/java/com/sniptext/domain/user/service/dto/result/UserUsageResult.java new file mode 100644 index 0000000..6e69f05 --- /dev/null +++ b/src/main/java/com/sniptext/domain/user/service/dto/result/UserUsageResult.java @@ -0,0 +1,23 @@ +package com.sniptext.domain.user.service.dto.result; + +import java.time.LocalDateTime; + +import com.sniptext.domain.ai.service.dto.result.AiToneUsageResult; + +/** 사용량. 템플릿은 휴지통 제외 개수 기준이고, AI 톤 교정은 오늘(KST) 기준이다. */ +public record UserUsageResult(TemplateUsage template, AiToneUsage aiTone) { + + public static UserUsageResult of(long templateCount, int templateLimit, AiToneUsageResult aiTone) { + return new UserUsageResult( + new TemplateUsage(templateCount, templateLimit, Math.max(templateLimit - templateCount, 0), + templateCount >= templateLimit), + new AiToneUsage(aiTone.usedCount(), aiTone.limit(), aiTone.remaining(), aiTone.resetAt())); + } + + public record TemplateUsage(long count, int limit, long remaining, boolean full) { + } + + public record AiToneUsage(int usedCount, int limit, int remaining, LocalDateTime resetAt) { + } + +} diff --git a/src/test/java/com/sniptext/domain/user/controller/UserUsageControllerTest.java b/src/test/java/com/sniptext/domain/user/controller/UserUsageControllerTest.java new file mode 100644 index 0000000..b5f8a9d --- /dev/null +++ b/src/test/java/com/sniptext/domain/user/controller/UserUsageControllerTest.java @@ -0,0 +1,182 @@ +package com.sniptext.domain.user.controller; + +import static org.hamcrest.Matchers.nullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import com.sniptext.domain.ai.repository.AiToneDailyUsageRepository; +import com.sniptext.domain.template.repository.TemplateRepository; +import com.sniptext.domain.user.repository.UserRepository; +import com.sniptext.fixture.TemplateFixture; +import com.sniptext.fixture.UserFixture; +import com.sniptext.global.policy.UsageLimits; +import com.sniptext.support.IntegrationTestSupport; +import com.sniptext.support.MutableClock; +import com.sniptext.support.MutableClockConfiguration; + +/** 명세 6번. 실제 토큰 필터와 template·ai 서비스, MySQL까지 거쳐 확인한다. */ +@Transactional +@Import(MutableClockConfiguration.class) +class UserUsageControllerTest extends IntegrationTestSupport { + + private static final String PATH = "/api/v1/users/me/usage"; + private static final LocalDateTime FIXED = LocalDateTime.of(2026, 11, 6, 10, 0); + private static final LocalDate TODAY = FIXED.toLocalDate(); + + @Autowired + private UserRepository userRepository; + + @Autowired + private TemplateRepository templateRepository; + + @Autowired + private AiToneDailyUsageRepository aiToneDailyUsageRepository; + + @Autowired + private MutableClock clock; + + private Long userId; + + @BeforeEach + void setUp() { + clock.setTo(FIXED); + userId = userRepository.save(UserFixture.user("usage@gmail.com")).getId(); + } + + @Test + @DisplayName("템플릿과 AI 사용 기록이 없으면 0개·0회이고, 다음 날 자정을 +09:00으로 내려주며 캐시하지 않는다") + void noUsage() throws Exception { + getUsage(userId) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")) + .andExpect(jsonPath("$.code").value("USER200_2")) + .andExpect(jsonPath("$.message").value("사용량 조회에 성공했습니다.")) + .andExpect(jsonPath("$.result.template.count").value(0)) + .andExpect(jsonPath("$.result.template.limit").value(50)) + .andExpect(jsonPath("$.result.template.remaining").value(50)) + .andExpect(jsonPath("$.result.template.isFull").value(false)) + .andExpect(jsonPath("$.result.template.full").doesNotExist()) + .andExpect(jsonPath("$.result.aiTone.usedCount").value(0)) + .andExpect(jsonPath("$.result.aiTone.limit").value(5)) + .andExpect(jsonPath("$.result.aiTone.remaining").value(5)) + .andExpect(jsonPath("$.result.aiTone.resetAt").value("2026-11-07T00:00:00+09:00")) + .andExpect(jsonPath("$.result.plan").doesNotExist()) + .andExpect(jsonPath("$.result.aiTone.available").doesNotExist()); + } + + @Test + @DisplayName("템플릿 49개면 1개 남고 한도 도달이 아니다") + void templateBelowLimit() throws Exception { + saveTemplates(userId, UsageLimits.TEMPLATE_LIMIT - 1); + + getUsage(userId) + .andExpect(jsonPath("$.result.template.count").value(49)) + .andExpect(jsonPath("$.result.template.remaining").value(1)) + .andExpect(jsonPath("$.result.template.isFull").value(false)); + } + + @Test + @DisplayName("템플릿 50개면 0개 남고 한도 도달이다") + void templateAtLimit() throws Exception { + saveTemplates(userId, UsageLimits.TEMPLATE_LIMIT); + + getUsage(userId) + .andExpect(jsonPath("$.result.template.count").value(50)) + .andExpect(jsonPath("$.result.template.remaining").value(0)) + .andExpect(jsonPath("$.result.template.isFull").value(true)); + } + + @Test + @DisplayName("휴지통 템플릿과 다른 회원의 템플릿은 세지 않는다") + void countsOnlyMyActiveTemplates() throws Exception { + Long otherId = userRepository.save(UserFixture.user("other@gmail.com")).getId(); + saveTemplates(userId, 2); + templateRepository.save(TemplateFixture.trashedTemplate(userId, "/휴지통", FIXED)); + saveTemplates(otherId, 3); + + getUsage(userId).andExpect(jsonPath("$.result.template.count").value(2)); + } + + @Test + @DisplayName("AI 톤 교정을 오늘 5회 썼으면 남은 횟수가 0이다") + void aiToneAtLimit() throws Exception { + useAiTone(userId, TODAY, UsageLimits.AI_TONE_DAILY_LIMIT); + + getUsage(userId) + .andExpect(jsonPath("$.result.aiTone.usedCount").value(5)) + .andExpect(jsonPath("$.result.aiTone.remaining").value(0)); + } + + @Test + @DisplayName("어제 사용량과 다른 회원의 사용량은 세지 않는다") + void countsOnlyMyTodayAiTone() throws Exception { + Long otherId = userRepository.save(UserFixture.user("other@gmail.com")).getId(); + useAiTone(userId, TODAY, 2); + useAiTone(userId, TODAY.minusDays(1), 3); + useAiTone(otherId, TODAY, 4); + + getUsage(userId) + .andExpect(jsonPath("$.result.aiTone.usedCount").value(2)) + .andExpect(jsonPath("$.result.aiTone.remaining").value(3)); + } + + @Test + @DisplayName("탈퇴한 회원의 토큰은 USER404_1이다") + void deletedUser() throws Exception { + Long deletedId = userRepository.save(UserFixture.deletedUser("deleted@gmail.com", FIXED)).getId(); + + getUsage(deletedId) + .andExpect(status().isNotFound()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")) + .andExpect(jsonPath("$.code").value("USER404_1")) + .andExpect(jsonPath("$.result").value(nullValue())); + } + + @Test + @DisplayName("없는 회원의 토큰은 USER404_1이다") + void unknownUser() throws Exception { + getUsage(Long.MAX_VALUE) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("USER404_1")); + } + + @Test + @DisplayName("토큰이 없으면 COMMON401_1이다") + void withoutToken() throws Exception { + mockMvc.perform(get(PATH)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("COMMON401_1")); + } + + private ResultActions getUsage(Long id) throws Exception { + return mockMvc.perform(get(PATH).header(HttpHeaders.AUTHORIZATION, bearer(id))); + } + + private void saveTemplates(Long ownerId, int count) { + for (int i = 1; i <= count; i++) { + templateRepository.save(TemplateFixture.template(ownerId, "/견적" + i)); + } + } + + private void useAiTone(Long ownerId, LocalDate date, int times) { + aiToneDailyUsageRepository.insertIfAbsent(ownerId, date, FIXED); + for (int i = 0; i < times; i++) { + aiToneDailyUsageRepository.incrementIfBelow(ownerId, date, UsageLimits.AI_TONE_DAILY_LIMIT, FIXED); + } + } + +}