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
27 changes: 26 additions & 1 deletion .claude/rules/code_style.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,22 @@ Entity entity = repository.findById(id)
- `ErrorCode`에 HTTP 상태코드와 메시지 함께 정의
- `GlobalExceptionHandler`에서 일괄 처리

## 로깅 규칙
- 단순 Not Found는 로그 생략 (GlobalExceptionHandler에서 처리됨)
- 중요한 비즈니스 로직 실패, 시스템 설정 오류, 외부 연동 실패는 예외 던지기 전에 `log.error()` 필수

```java
// 중요한 예외 — log.error 추가
if (activeModels.isEmpty()) {
log.error("사용 가능한 임베딩 모델이 설정되지 않았습니다.");
throw new DocGridException(ErrorCode.EMBEDDING_MODEL_NOT_CONFIGURED);
}

// 단순 Not Found — log 생략
User user = userRepository.findById(id)
.orElseThrow(() -> new DocGridException(ErrorCode.USER_NOT_FOUND));
```

## Service 패턴
```java
@Transactional(readOnly = true)
Expand Down Expand Up @@ -70,9 +86,18 @@ public class XxxController {

## Swagger 컨벤션
- 클래스: `@Tag(name = "도메인명", description = "한 줄 설명")`
- 메서드: `@Operation(summary = "짧은 요약")` — description이 summary와 같으면 생략
- 메서드: `@Operation(summary = "짧은 요약", description = "상세 설명")` — description은 항상 작성
- summary: 한 줄 동사+목적어 (예: "부서 목록 조회")
- description: 호출 시점, 파라미터 조건, 특이사항, 주의사항 등 프론트가 알아야 할 내용 작성
- Response DTO: `@Schema(description = "필드 설명")`

```java
@Operation(
summary = "약 등록",
description = "약을 1개 이상 등록합니다. 단건이면 리스트에 1개, 여러 개면 여러 개 담아서 보내세요. 하나라도 실패하면 전체 롤백됩니다."
)
```

## Converter 패턴
- 위치: `domain/{도메인}/converter/XxxConverter.java`
- `@Component`로 Spring Bean 등록, 서비스에서 주입
Expand Down
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ dependencies {
implementation 'io.minio:minio:8.5.17'
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-database-postgresql'
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'org.postgresql:postgresql'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.opensource.docgrid.domain.auth.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrentUser {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.opensource.docgrid.domain.auth.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.opensource.docgrid.domain.auth.annotation.CurrentUser;
import com.opensource.docgrid.domain.auth.dto.request.LoginRequest;
import com.opensource.docgrid.domain.auth.dto.request.SignupRequest;
import com.opensource.docgrid.domain.auth.dto.response.LoginResponse;
import com.opensource.docgrid.domain.auth.dto.response.MeResponse;
import com.opensource.docgrid.domain.auth.dto.response.SignupResponse;
import com.opensource.docgrid.domain.auth.service.command.AuthCommandService;
import com.opensource.docgrid.domain.auth.service.query.AuthQueryService;
import com.opensource.docgrid.global.common.response.ApiResponse;
import com.opensource.docgrid.global.common.response.ResponseUtils;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

@Tag(name = "Auth", description = "인증 관련 API")
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

private final AuthCommandService authCommandService;
private final AuthQueryService authQueryService;

@Operation(summary = "회원가입", description = "이메일, 비밀번호, 이름, 부서 ID로 신규 계정을 생성합니다. 가입 시 USER 역할이 자동으로 부여됩니다.")
@PostMapping("/signup")
public ResponseEntity<ApiResponse<SignupResponse>> signup(@RequestBody @Valid SignupRequest request) {
return ResponseUtils.created(authCommandService.signup(request));
}

@Operation(summary = "로그인", description = "이메일과 비밀번호로 로그인합니다. 성공 시 Bearer 액세스 토큰을 반환합니다. 이후 인증이 필요한 API는 Authorization: Bearer {token} 헤더에 토큰을 포함하세요.")
@PostMapping("/login")
public ResponseEntity<ApiResponse<LoginResponse>> login(@RequestBody @Valid LoginRequest request) {
return ResponseUtils.ok(authCommandService.login(request));
}

@Operation(summary = "내 정보 조회", description = "현재 로그인한 사용자의 정보를 반환합니다. Authorization: Bearer {token} 헤더가 필요합니다.")
@GetMapping("/me")
public ResponseEntity<ApiResponse<MeResponse>> getMe(@CurrentUser Long userId) {
return ResponseUtils.ok(authQueryService.getMe(userId));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.opensource.docgrid.domain.auth.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public record LoginRequest(
@Schema(description = "이메일") @NotBlank @Email String email,
@Schema(description = "비밀번호") @NotBlank String password
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.opensource.docgrid.domain.auth.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

public record SignupRequest(
@Schema(description = "이메일 (로그인 ID)") @NotBlank @Email String email,
@Schema(description = "비밀번호") @NotBlank String password,
@Schema(description = "이름") @NotBlank String name,
@Schema(description = "부서 ID") @NotNull Long departmentId
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.opensource.docgrid.domain.auth.dto.response;

import java.util.List;

import io.swagger.v3.oas.annotations.media.Schema;

public record LoginResponse(
@Schema(description = "액세스 토큰") String accessToken,
@Schema(description = "토큰 타입") String tokenType,
@Schema(description = "만료 시간 (초)") long expiresIn,
@Schema(description = "사용자 ID") Long userId,
@Schema(description = "이메일") String email,
@Schema(description = "역할 목록") List<String> roles
) {
public static LoginResponse of(String accessToken, long expiresIn, Long userId, String email, List<String> roles) {
return new LoginResponse(accessToken, "Bearer", expiresIn, userId, email, roles);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.opensource.docgrid.domain.auth.dto.response;

import java.time.LocalDateTime;
import java.util.List;

import com.opensource.docgrid.domain.user.entity.User;

import io.swagger.v3.oas.annotations.media.Schema;

public record MeResponse(
@Schema(description = "사용자 ID") Long userId,
@Schema(description = "이메일") String email,
@Schema(description = "이름") String name,
@Schema(description = "닉네임") String nickname,
@Schema(description = "프로필 이미지 URL") String profileImageUrl,
@Schema(description = "부서 ID") Long departmentId,
@Schema(description = "부서명") String departmentName,
@Schema(description = "역할 목록") List<String> roles,
@Schema(description = "계정 상태") String status,
@Schema(description = "가입 시각") LocalDateTime createdAt,
@Schema(description = "마지막 로그인 시각") LocalDateTime lastLoginAt
) {
public static MeResponse of(User user, List<String> roles) {
return new MeResponse(
user.getId(),
user.getEmail(),
user.getName(),
user.getNickname(),
user.getProfileImageUrl(),
user.getDepartment() != null ? user.getDepartment().getId() : null,
user.getDepartment() != null ? user.getDepartment().getName() : null,
roles,
user.getStatus().name(),
user.getCreatedAt(),
user.getLastLoginAt()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.opensource.docgrid.domain.auth.dto.response;

import java.time.LocalDateTime;
import java.util.List;

import com.opensource.docgrid.domain.user.entity.User;

import io.swagger.v3.oas.annotations.media.Schema;

public record SignupResponse(
@Schema(description = "사용자 ID") Long userId,
@Schema(description = "이메일") String email,
@Schema(description = "이름") String name,
@Schema(description = "부서 ID") Long departmentId,
@Schema(description = "부여된 역할 목록") List<String> roles,
@Schema(description = "가입 시각") LocalDateTime createdAt
) {
public static SignupResponse of(User user, List<String> roles) {
return new SignupResponse(
user.getId(),
user.getEmail(),
user.getName(),
user.getDepartment() != null ? user.getDepartment().getId() : null,
roles,
user.getCreatedAt()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.opensource.docgrid.domain.auth.jwt;

import java.io.IOException;
import java.util.List;

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

private final JwtProvider jwtProvider;

@Override
@SuppressWarnings("unchecked")
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = resolveToken(request);

if (StringUtils.hasText(token)) {
Claims claims = jwtProvider.getClaimsIfValid(token);
if (claims != null) {
Long userId = claims.get("userId", Long.class);
String email = claims.getSubject();
List<String> roles = (List<String>) claims.get("roles");

List<SimpleGrantedAuthority> authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.toList();

UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(email, null, authorities);
authentication.setDetails(userId);

SecurityContextHolder.getContext().setAuthentication(authentication);
}
}

filterChain.doFilter(request, response);
}

private String resolveToken(HttpServletRequest request) {
String bearer = request.getHeader("Authorization");
if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
return bearer.substring(7);
}
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.opensource.docgrid.domain.auth.jwt;

import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;

import javax.crypto.SecretKey;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
public class JwtProvider {

private final SecretKey secretKey;
private final long expirationSeconds;

public JwtProvider(
@Value("${jwt.secret}") String secret,
@Value("${jwt.expiration}") long expirationSeconds) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.expirationSeconds = expirationSeconds;
}

public String generateToken(Long userId, String email, List<String> roles) {
Date now = new Date();
Date expiry = new Date(now.getTime() + expirationSeconds * 1000);

return Jwts.builder()
.subject(email)
.claim("userId", userId)
.claim("roles", roles)
.issuedAt(now)
.expiration(expiry)
.signWith(secretKey)
.compact();
}

public Claims getClaimsIfValid(String token) {
try {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
} catch (JwtException | IllegalArgumentException e) {
log.error("JWT 검증 실패: {}", e.getMessage());
return null;
}
}

public long getExpirationSeconds() {
return expirationSeconds;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.opensource.docgrid.domain.auth.resolver;

import org.springframework.core.MethodParameter;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

import com.opensource.docgrid.domain.auth.annotation.CurrentUser;
import com.opensource.docgrid.global.exception.DocGridException;
import com.opensource.docgrid.global.exception.ErrorCode;

@Component
public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver {

@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(CurrentUser.class)
&& parameter.getParameterType().equals(Long.class);
}

@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()
|| !(authentication.getDetails() instanceof Long)) {
throw new DocGridException(ErrorCode.UNAUTHORIZED);
}
return (Long) authentication.getDetails();
}
}
Loading