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
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ closes 같은 키워드가 없으면 병합해도 이슈가 닫히지 않습니

## 테스트

<!-- 무엇을 어떻게 검증했는지 적어 주세요. 예: 본인 소유가 아니면 404, 한도 경계 4→5·5→6 -->
<!-- 무엇을 어떻게 검증했는지 적어 주세요. 예: 본인 소유가 아니면 404, 한도 경계 49→50·50→51 -->

## 확인 체크리스트

Expand Down
26 changes: 12 additions & 14 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,18 @@ docker compose up -d # 로컬 MySQL
com.sniptext
├── domain
│ ├── auth # 로그인·가입·재발급·로그아웃. SocialAccount, RefreshToken
│ ├── user # User, UserTermsAgreement. 내 정보·설정·사용량, 가입 시 회원 생성
│ ├── user # User, UserTermsAgreement. 내 정보·사용량, 가입 시 회원 생성
│ ├── template # 내 템플릿 + 휴지통 + 휴지통 정리 배치
│ ├── gallery # 갤러리 템플릿, 내 템플릿으로 저장
│ ├── ai # AI 톤 교정, 하루 사용량, 핵심 정보 검사
│ └── plan # 현재 플랜 조회 (PlanQueryService, 테이블 없음)
│ └── ai # AI 톤 교정, 하루 사용량, 핵심 정보 검사
└── global
├── security # 보안 설정, 토큰 필터·발급 도구, 로그인 회원 주입, 개발용 임시 토큰
├── client # 외부 호출 (google: 코드 교환·ID 토큰 검증, llm: 언어모델)
├── config # JPA, 출처 허용, 스케줄, 시각 직렬화
├── entity # 공통 시각 부모 엔티티
├── exception # 전역 예외 처리, 공통 에러 코드
├── response # 공통 응답, 결과 코드 인터페이스
├── policy # 사용 한도 숫자 (UsageLimits)
└── crypto # 본문 암호화 변환기
```

Expand Down Expand Up @@ -87,7 +87,7 @@ domain/{도메인}/
- 버전 구분(`V2`)이 필요하면 컨트롤러의 요청·응답 객체에만 붙인다.
- 엔티티의 규칙을 쓰는 요청 검증 어노테이션은 `annotation`에 둔다. `controller/dto`에 두면 컨트롤러가 엔티티를 쓰게 되어 ArchUnit에 걸린다. `annotation`은 `entity`·`exception`만 쓰고 `controller`·`service`는 부르지 않는다.
- `support`에는 다른 도메인이 써도 되는 순수 규칙만 둔다 (상태·DB·스프링 빈 없음). `service`·`repository`·`controller`를 참조하지 않는다 (ArchUnit이 강제).
- 값을 나타내는 열거형(정렬 기준, 플랜, 카테고리 등)은 그 값을 저장하는 엔티티의 도메인 `entity` 패키지에 둔다. 저장하지 않는 값은 그 값을 쓰는 도메인의 `entity` 패키지에 둔다.
- 값을 나타내는 열거형(카테고리, 약관 종류 등)은 그 값을 저장하는 엔티티의 도메인 `entity` 패키지에 둔다. 저장하지 않는 값은 그 값을 쓰는 도메인의 `entity` 패키지에 둔다.
- `entity` 패키지의 열거형은 컨트롤러와 다른 도메인에서 직접 써도 된다. 엔티티 클래스는 안 된다.
- 열거형 상수 이름은 API 값이 된다. 명세를 먼저 바꾸지 않고 상수 이름을 바꾸지 않는다.
- 의존 규칙은 ArchUnit 테스트가 강제한다. 테스트가 깨지면 구조를 고치고, 테스트를 고치지 않는다.
Expand All @@ -96,16 +96,14 @@ domain/{도메인}/

| 연결 | 메서드 | 만드는 쪽 → 부르는 쪽 |
| --- | --- | --- |
| 현재 플랜 조회 | `PlanResult getCurrentPlan(Long userId)` | plan → template, user, ai |
| 갤러리에서 템플릿 생성 | `TemplateShortcutAssignResult createFromGallery(TemplateCreateFromGalleryCommand)` (호출자 트랜잭션 밖에서만) | template → gallery |
| 보유 템플릿 개수 | `long countActive(Long userId)` | template → user |
| AI 오늘 사용량 | `AiToneUsageResult getTodayUsage(Long userId)` (`AiToneUsageQueryService`) | ai → user |
| 가입 시 회원 생성 | `Long signUp(UserSignUpCommand)` | user → auth |
| 플랜 지정값 조회 | `Optional<Plan> findPlanOverride(Long userId)` (`UserQueryService`) | user → plan |
| 회원 행 잠금 | `void lockUser(Long userId)` (`UserCommandService`, 호출자 트랜잭션 필수) | user → template |

- **다른 도메인 서비스를 조합하는 조회는 별도 서비스로 분리한다.** 예: 사용량 API는 `UserUsageQueryService`
- 순환 참조를 막기 위해서다. plan → `UserQueryService`인데 사용량 때문에 `UserQueryService` → plan을 넣으면 생성자 주입 순환으로 앱이 뜨지 않는다
- 순환 참조를 막기 위해서다. 사용량 조합(template·ai 서비스 호출)을 `UserQueryService`에 넣으면, template·ai가 `UserQueryService`를 부르게 될 때 생성자 주입 순환으로 앱이 뜨지 않는다

## 코드 규칙

Expand Down Expand Up @@ -183,16 +181,16 @@ domain/{도메인}/
- `updated_at`은 확장 동기화 기준이라 빠지면 S2에서 동기화가 조용히 틀어진다
- 사용 횟수는 저장하지 않는다.

**한도와 플랜**
- 템플릿 한도는 휴지통 제외 개수 기준 (FREE 5 / PRO 50)
**한도** (플랜 구분 없음, 모든 사용자 동일)
- 템플릿 한도는 휴지통 제외 개수 기준 50개
- 생성·복구·갤러리 저장은 같은 트랜잭션에서 회원 행을 비관적 잠금으로 잡은 뒤 개수 검사 → 저장
- 한도 검사는 `lockUser`를 먼저 부른 같은 트랜잭션 안에서 한다
- `lockUser`는 그 트랜잭션의 첫 조회여야 한다. 대상 조회는 잠금 뒤에 한다 (MySQL 기본 격리 수준은 첫 일반 조회 시점의 스냅샷을 쓰므로, 먼저 읽으면 잠금 뒤 개수 검사가 옛 값을 봐서 한도를 넘는다)
- 현재 플랜 판정과 한도 숫자(템플릿 5/50, AI 하루 10)는 `plan` 도메인의 `PlanQueryService` 한 곳에서만 한다. 지금은 `users.plan_override` 값, 없으면 FREE. 다른 곳에서 플랜이나 한도 숫자를 직접 쓰지 않는다.
- 한도 숫자(템플릿 50, AI 하루 5)는 `global/policy/UsageLimits` 한 곳에만 둔다. 다른 곳에서 한도 숫자를 직접 쓰지 않는다.

**AI 톤 교정**
- PRO 전용, 하루 10회 (KST 날짜별 1행)
- 요청 시 `used_count < 10` 조건부 증가로 1회를 먼저 잡고, 외부 AI 오류·20초 타임아웃·혐오 표현 차단이면 되돌린다.
- 모든 사용자 하루 5회 (KST 날짜별 1행)
- 요청 시 `used_count < 한도` 조건부 증가로 1회를 먼저 잡고, 외부 AI 오류·20초 타임아웃·혐오 표현 차단이면 되돌린다.
- 출력 토큰 상한 4000을 반드시 설정한다.
- **원문과 결과는 저장하지도, 로그에 남기지도 않는다.** 예외 로그에도 넣지 않는다.

Expand All @@ -204,7 +202,7 @@ domain/{도메인}/
- 웹의 재발급 토큰은 HttpOnly·Secure 쿠키로만 주고받는다.
- 로그아웃은 접근 토큰 없이 쿠키만으로 처리하고, 쿠키가 없거나 이미 폐기돼도 성공으로 응답한다.
- 개발용 임시 토큰 발급 경로는 `local`·`dev` 프로필에서만 켠다. 프로필 지정은 나열 방식(`@Profile({"local", "dev"})`)으로 쓰고 `"!prod"`는 금지 (프로필을 빠뜨린 서버에서 `!prod`가 참이 되어 운영에 열린다)
- 로컬에서 인증 API 확인: `local` 프로필로 실행 → `POST /api/v1/auth/dev-token` `{"userId":1}` → 받은 토큰을 스웨거 Authorize에 입력. 시드 회원 1번은 FREE, 2번은 PRO
- 로컬에서 인증 API 확인: `local` 프로필로 실행 → `POST /api/v1/auth/dev-token` `{"userId":1}` → 받은 토큰을 스웨거 Authorize에 입력. 시드 회원 1번·2번 모두 일반 회원(한도 동일)
- 컨트롤러에서 로그인 회원은 `@LoginUser Long userId`로 받는다. 인증 정보(`SecurityContextHolder`)에서 직접 꺼내지 않는다. Long만 허용한다 (ArchUnit이 강제). 다른 타입으로 쓰면 쿼리 값이 들어가는 구멍이 생긴다.
- 접근 토큰이 없거나 만료되면 `COMMON401_1`, 위조·형식 오류·종류가 다른 토큰이면 `COMMON401_2`다. 필터는 사유만 요청 속성에 남기고, 응답은 진입점이 한다.
- 통합 테스트에서 인증된 요청은 `IntegrationTestSupport.bearer(userId)`로 만든다 (실제 발급 도구 사용). `@WithMockUser`로는 `@LoginUser`가 채워지지 않는다.
Expand Down Expand Up @@ -235,7 +233,7 @@ domain/{도메인}/
- 테스트 이름은 `@DisplayName`에 한국어로 쓴다.
- 서비스 단위 테스트는 모키토, 레포지토리·통합 테스트는 테스트컨테이너 MySQL (H2 쓰지 않음. 생성 컬럼·잠금 동작이 다르다)
- 테스트에서 엔티티는 `src/test/.../fixture/{엔티티}Fixture`로 만든다. 생성 인자가 바뀌어도 테스트가 한꺼번에 깨지지 않게 하기 위해서다
- 반드시 검증할 것: 본인 소유가 아니면 404, 한도 경계(4→5, 5→6), 단축어 정규화·대소문자 중복, 동시 요청 시 한도 초과 방지
- 반드시 검증할 것: 본인 소유가 아니면 404, 한도 경계(49→50, 50→51), 단축어 정규화·대소문자 중복, 동시 요청 시 한도 초과 방지

## 깃·PR

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import com.sniptext.domain.ai.repository.AiToneDailyUsageRepository;
import com.sniptext.domain.ai.service.dto.result.AiToneReserveResult;
import com.sniptext.global.exception.BusinessException;
import com.sniptext.global.policy.UsageLimits;

import lombok.RequiredArgsConstructor;

Expand All @@ -33,17 +34,17 @@ public class AiToneUsageCommandService {
private final TransactionTemplate transactionTemplate;

/**
* 오늘(KST) 횟수를 한도 안에서 1 올려 잡는다. 이미 한도면 AI429_1.
* 한도는 부르는 쪽이 현재 플랜 조회로 얻어 넘긴다 (회원 존재·플랜 검사도 부르는 쪽에서 먼저 한다).
* 오늘(KST) 횟수를 하루 한도(UsageLimits) 안에서 1 올려 잡는다. 이미 한도면 AI429_1.
* 회원 존재 검사는 부르는 쪽에서 먼저 한다.
*/
public AiToneReserveResult reserve(Long userId, int limit) {
public AiToneReserveResult reserve(Long userId) {
requireNoOuterTransaction();
return transactionTemplate.execute(status -> {
LocalDateTime now = LocalDateTime.now(clock);
LocalDate usageDate = now.toLocalDate();

aiToneDailyUsageRepository.insertIfAbsent(userId, usageDate, now);
if (aiToneDailyUsageRepository.incrementIfBelow(userId, usageDate, limit, now) == 0) {
if (aiToneDailyUsageRepository.incrementIfBelow(userId, usageDate, UsageLimits.AI_TONE_DAILY_LIMIT, now) == 0) {
throw new BusinessException(AiErrorCode.DAILY_LIMIT_EXCEEDED);
}
int usedCountAfter = aiToneDailyUsageRepository.findUsedCount(userId, usageDate)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import com.sniptext.domain.ai.repository.AiToneDailyUsageRepository;
import com.sniptext.domain.ai.service.dto.result.AiToneUsageResult;
import com.sniptext.domain.plan.service.PlanQueryService;
import com.sniptext.global.policy.UsageLimits;

import lombok.RequiredArgsConstructor;

Expand All @@ -18,19 +18,14 @@
public class AiToneUsageQueryService {

private final AiToneDailyUsageRepository aiToneDailyUsageRepository;
private final PlanQueryService planQueryService;
private final Clock clock;

/**
* 오늘(KST) 사용량. 한도는 현재 플랜 조회에서 얻는다. 없는 회원·탈퇴 회원이면 USER404_1.
* 한도가 0(쓸 수 없는 플랜)이면 오늘 행이 있어도 사용 횟수를 0으로 내려준다. 명세처럼 FREE는 항상 0/0/0이고,
* 같은 날 PRO로 쓰다 FREE가 된 회원이 "3/0"으로 보이지 않게 하기 위해서다. 행은 그대로라 다시 PRO가 되면 이어서 센다.
*/
/** 오늘(KST) 사용량. 한도는 모든 사용자에게 같다 (UsageLimits). 오늘 행이 없으면 사용 0이다. */
public AiToneUsageResult getTodayUsage(Long userId) {
int limit = planQueryService.getCurrentPlan(userId).aiToneDailyLimit();
int limit = UsageLimits.AI_TONE_DAILY_LIMIT;
LocalDate today = LocalDate.now(clock);
int usedCount = limit > 0 ? aiToneDailyUsageRepository.findUsedCount(userId, today).orElse(0) : 0;
return new AiToneUsageResult(limit > 0, usedCount, limit, Math.max(limit - usedCount, 0),
int usedCount = aiToneDailyUsageRepository.findUsedCount(userId, today).orElse(0);
return new AiToneUsageResult(usedCount, limit, Math.max(limit - usedCount, 0),
today.plusDays(1).atStartOfDay());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,10 @@
/**
* 오늘 AI 톤 교정 사용량.
*
* @param available 현재 플랜에서 쓸 수 있는지 (하루 한도가 0보다 큰지)
* @param usedCount 오늘 사용 횟수
* @param limit 하루 한도
* @param remaining 오늘 남은 횟수 (0 미만으로 내려가지 않는다)
* @param resetAt 다음 리셋 시각 (다음 날 00:00 KST)
*/
public record AiToneUsageResult(boolean available, int usedCount, int limit, int remaining, LocalDateTime resetAt) {
public record AiToneUsageResult(int usedCount, int limit, int remaining, LocalDateTime resetAt) {
}

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;

import com.sniptext.domain.plan.service.PlanQueryService;
import com.sniptext.domain.template.entity.Shortcut;
import com.sniptext.domain.template.entity.Template;
import com.sniptext.domain.template.entity.TemplateContent;
Expand All @@ -23,6 +22,7 @@
import com.sniptext.domain.template.service.dto.result.TemplateShortcutAssignResult;
import com.sniptext.domain.user.service.UserCommandService;
import com.sniptext.global.exception.BusinessException;
import com.sniptext.global.policy.UsageLimits;

import lombok.RequiredArgsConstructor;

Expand All @@ -35,7 +35,6 @@ public class TemplateCommandService {
private final TemplateRepository templateRepository;

private final UserCommandService userCommandService;
private final PlanQueryService planQueryService;

private final TransactionTemplate transactionTemplate;

Expand Down Expand Up @@ -99,8 +98,7 @@ private static void requireNoOuterTransaction() {
}

private void validateTemplateLimit(Long userId) {
int templateLimit = planQueryService.getCurrentPlan(userId).templateLimit();
if (templateRepository.countByUserIdAndDeletedAtIsNull(userId) >= templateLimit) {
if (templateRepository.countByUserIdAndDeletedAtIsNull(userId) >= UsageLimits.TEMPLATE_LIMIT) {
throw new BusinessException(TemplateErrorCode.TEMPLATE_LIMIT_REACHED);
}
}
Expand Down
9 changes: 0 additions & 9 deletions src/main/java/com/sniptext/domain/user/entity/Plan.java

This file was deleted.

11 changes: 0 additions & 11 deletions src/main/java/com/sniptext/domain/user/entity/TemplateSort.java

This file was deleted.

16 changes: 1 addition & 15 deletions src/main/java/com/sniptext/domain/user/entity/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
Expand All @@ -17,10 +15,7 @@
import lombok.Getter;
import lombok.NoArgsConstructor;

/**
* 회원. 가입 완료 시점에만 만든다 (약관 동의 전에는 회원을 저장하지 않는다).
* 정렬 기준은 RECENT_USED, 플랜 지정값은 없음(FREE 판정)으로 시작한다.
*/
/** 회원. 가입 완료 시점에만 만든다 (약관 동의 전에는 회원을 저장하지 않는다). */
@Getter
@Entity
@Table(name = "users")
Expand All @@ -44,14 +39,6 @@ public class User extends BaseTimeEntity {
@Column(name = "profile_image_url", length = PROFILE_IMAGE_URL_MAX_LENGTH)
private String profileImageUrl;

@Enumerated(EnumType.STRING)
@Column(name = "template_sort", nullable = false, length = 20)
private TemplateSort templateSort;

@Enumerated(EnumType.STRING)
@Column(name = "plan_override", length = 10)
private Plan planOverride;

@Column(name = "last_login_at")
private LocalDateTime lastLoginAt;

Expand All @@ -63,7 +50,6 @@ private User(String email, String nickname, String profileImageUrl) {
this.email = email;
this.nickname = nickname;
this.profileImageUrl = profileImageUrl;
this.templateSort = TemplateSort.RECENT_USED;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,33 +1,18 @@
package com.sniptext.domain.user.service;

import java.util.Optional;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.sniptext.domain.user.entity.Plan;
import com.sniptext.domain.user.entity.User;
import com.sniptext.domain.user.exception.UserErrorCode;
import com.sniptext.domain.user.repository.UserRepository;
import com.sniptext.global.exception.BusinessException;

import lombok.RequiredArgsConstructor;

/** 회원 조회 (다른 도메인이 회원 정보를 읽을 때 쓰는 진입점). */
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserQueryService {

private final UserRepository userRepository;

/**
* 회원의 플랜 지정값(plan_override). 지정값이 없으면 빈 값이다.
* 현재 플랜 판정은 plan 도메인의 PlanQueryService가 하므로 여기서 기본값을 정하지 않는다.
*/
public Optional<Plan> findPlanOverride(Long userId) {
User user = userRepository.findByIdAndDeletedAtIsNull(userId)
.orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND));
return Optional.ofNullable(user.getPlanOverride());
}

}
18 changes: 18 additions & 0 deletions src/main/java/com/sniptext/global/policy/UsageLimits.java

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

보유 템플릿 한도, AI 톤 교정 같은 경우는 각각 템플릿, ai 패키지로 이동해도 좋을 것 같기도 하구
둘이 분리되어있으면 너무 분리되어 있는 것 아닌가 싶기도 하네요 ㅎㅎ

한도라는 관점에서 전체적으로 사용될 여지가 있다는 생각이 들기도 하고, 한도라는 책임으로 묶는 게 나은 것 같아서
위치 좋은 것 같은 생각입니다!

Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.sniptext.global.policy;

/**
* 사용 한도. 모든 사용자에게 같다 (플랜 구분 없음). 한도 숫자는 여기 한 곳에서만 쓴다.
* 화면은 숫자를 직접 쓰지 않고 사용량 API의 limit을 쓴다.
*/
public final class UsageLimits {

/** 보유 템플릿 한도 (휴지통 제외 개수 기준) */
public static final int TEMPLATE_LIMIT = 50;

/** AI 톤 교정 하루 한도 (KST 날짜 기준) */
public static final int AI_TONE_DAILY_LIMIT = 5;

private UsageLimits() {
}

}
Loading
Loading