Skip to content
Open
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: 3 additions & 1 deletion docs/s1-api-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -812,7 +812,9 @@ GET /api/v1/users/me/usage

##### Error Code

이 API 전용 에러 없음 (공통 에러만)
| HttpStatus | ResultCode | ErrorMessage | Description |
| --- | --- | --- | --- |
| 404 | USER404_1 | 회원 정보를 찾을 수 없어요. | 탈퇴했거나 없는 회원의 토큰 |

##### 비고

Expand Down
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<UserUsageResponse>> getUsage(@LoginUser Long userId) {
return ApiResponse.success(UserSuccessCode.USAGE_FOUND, UserUsageResponse.from(userUsageQueryService.getUsage(userId)));
}

}
Original file line number Diff line number Diff line change
@@ -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;

}
Original file line number Diff line number Diff line change
@@ -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) {
}

}
Original file line number Diff line number Diff line change
@@ -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));
}

}
Original file line number Diff line number Diff line change
@@ -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) {
}

}
Original file line number Diff line number Diff line change
@@ -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);
}
}

}
Loading