add: init project

This commit is contained in:
unknown 2025-06-11 11:01:53 +09:00
parent b68c7c5fa1
commit e6ef3f0671
63 changed files with 1088 additions and 1492 deletions

View File

@ -1,8 +0,0 @@
tasks.getByName('bootJar') {
enabled = false
}
tasks.getByName('jar') {
enabled = true
archiveClassifier = ''
}

View File

@ -1,10 +1,16 @@
dependencies { dependencies {
implementation project(':common') implementation project(':common')
implementation 'com.mysql:mysql-connector-j'
// HTTP Client for external API // HTTP Client for external API
implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'org.springframework.boot:spring-boot-starter-webflux'
} }
//external:
// ai:
// api-url: ${EXTERNAL_AI_URL:https://api.openai.com/v1}
// api-key: ${EXTERNAL_AI_KEY:your-api-key}
bootJar { bootJar {
archiveFileName = "ai-recommend-service.jar" archiveFileName = "ai-recommend-service.jar"
} }

View File

@ -6,11 +6,11 @@ import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest;
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse; import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
/** /**
* AI 마케팅 추천을 위한 REST API 컨트롤러 * AI 마케팅 추천을 위한 REST API 컨트롤러

View File

@ -19,6 +19,12 @@ spring:
dialect: org.hibernate.dialect.PostgreSQLDialect dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true format_sql: true
ai:
service:
url: ${AI_SERVICE_URL:http://localhost:8080/ai}
timeout: ${AI_SERVICE_TIMEOUT:30000}
external: external:
claude-ai: claude-ai:
api-key: ${CLAUDE_AI_API_KEY:your-claude-api-key} api-key: ${CLAUDE_AI_API_KEY:your-claude-api-key}

View File

@ -1,53 +1,5 @@
plugins { plugins {
id 'org.springframework.boot' version '3.4.0' apply false
id 'io.spring.dependency-management' version '1.1.4' apply false
id 'java' id 'java'
} id 'org.springframework.boot' version '3.4.0'
id 'io.spring.dependency-management' version '1.1.4'
subprojects {
apply plugin: 'java'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
group = 'com.won.smarketing'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '21'
repositories {
mavenCentral()
}
dependencies {
// Spring Boot Starters
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// Database
runtimeOnly 'org.postgresql:postgresql'
// Swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0'
// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
test {
useJUnitPlatform()
}
} }

21
common/build.gradle Normal file
View File

@ -0,0 +1,21 @@
dependencies {
api 'org.springframework.boot:spring-boot-starter-web'
api 'org.springframework.boot:spring-boot-starter-data-jpa'
api 'org.springframework.boot:spring-boot-starter-security'
api 'org.springframework.boot:spring-boot-starter-validation'
api 'org.springframework.boot:spring-boot-starter-data-redis'
api 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
api 'io.jsonwebtoken:jjwt-api:0.12.3'
api 'io.jsonwebtoken:jjwt-impl:0.12.3'
api 'io.jsonwebtoken:jjwt-jackson:0.12.3'
api 'org.projectlombok:lombok'
}
jar {
enabled = true
archiveClassifier = ''
}
bootJar {
enabled = false
}

View File

@ -20,7 +20,7 @@ import java.util.Arrays;
/** /**
* Spring Security 설정 클래스 * Spring Security 설정 클래스
* 인증, 인가, CORS 보안 관련 설정 * JWT 기반 인증 CORS 설정
*/ */
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@ -30,17 +30,7 @@ public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter; private final JwtAuthenticationFilter jwtAuthenticationFilter;
/** /**
* 패스워드 인코더 Bean 설정 * Spring Security 필터 체인 설정
*
* @return BCrypt 패스워드 인코더
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* Security Filter Chain 설정
* *
* @param http HttpSecurity 객체 * @param http HttpSecurity 객체
* @return SecurityFilterChain * @return SecurityFilterChain
@ -49,42 +39,42 @@ public class SecurityConfig {
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http http
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource())) .cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth
.authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**", "/api/member/register", "/api/member/check-duplicate/**",
.requestMatchers( "/api/member/validate-password", "/swagger-ui/**", "/v3/api-docs/**",
"/api/member/register", "/swagger-resources/**", "/webjars/**").permitAll()
"/api/member/check-duplicate", .anyRequest().authenticated()
"/api/member/validate-password", )
"/api/auth/login", .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
"/swagger-ui/**",
"/swagger-ui.html",
"/api-docs/**",
"/actuator/**"
).permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build(); return http.build();
} }
/**
* 패스워드 인코더 등록
*
* @return BCryptPasswordEncoder
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/** /**
* CORS 설정 * CORS 설정
* *
* @return CORS 설정 소스 * @return CorsConfigurationSource
*/ */
@Bean @Bean
public CorsConfigurationSource corsConfigurationSource() { public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration(); CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList("*")); configuration.setAllowedOriginPatterns(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("authorization", "content-type", "x-auth-token")); configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setExposedHeaders(Arrays.asList("x-auth-token"));
configuration.setAllowCredentials(true); configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration); source.registerCorsConfiguration("/**", configuration);

View File

@ -9,8 +9,8 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
/** /**
* Swagger 설정 클래스 * Swagger OpenAPI 설정 클래스
* API 문서화 위한 OpenAPI 설정 * API 문서화 JWT 인증 설정
*/ */
@Configuration @Configuration
public class SwaggerConfig { public class SwaggerConfig {
@ -18,24 +18,26 @@ public class SwaggerConfig {
/** /**
* OpenAPI 설정 * OpenAPI 설정
* *
* @return OpenAPI 인스턴스 * @return OpenAPI 객체
*/ */
@Bean @Bean
public OpenAPI openAPI() { public OpenAPI openAPI() {
String securitySchemeName = "bearerAuth"; String jwtSchemeName = "jwtAuth";
SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwtSchemeName);
Components components = new Components()
.addSecuritySchemes(jwtSchemeName, new SecurityScheme()
.name(jwtSchemeName)
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT"));
return new OpenAPI() return new OpenAPI()
.info(new Info() .info(new Info()
.title("AI 마케팅 서비스 API") .title("스마케팅 API")
.description("소상공인을 위한 맞춤형 AI 마케팅 솔루션 API 문서") .description("소상공인을 위한 AI 마케팅 서비스 API")
.version("v1.0.0")) .version("1.0.0"))
.addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) .addSecurityItem(securityRequirement)
.components(new Components() .components(components);
.addSecuritySchemes(securitySchemeName,
new SecurityScheme()
.name(securitySchemeName)
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")));
} }
} }

View File

@ -9,22 +9,22 @@ import lombok.NoArgsConstructor;
import java.util.List; import java.util.List;
/** /**
* 페이지네이션 응답 DTO * 페이 응답 DTO
* 페이 단위 조회 결과를 공통 형식 * 페이징된 데이터 응답에 사용되 공통 형식
* *
* @param <T> 페이지 내용의 데이터 타입 * @param <T> 응답 데이터 타입
*/ */
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@Schema(description = "페이지네이션 응답") @Schema(description = "페이 응답")
public class PageResponse<T> { public class PageResponse<T> {
@Schema(description = "페이지 내용") @Schema(description = "페이지 컨텐츠", example = "[...]")
private List<T> content; private List<T> content;
@Schema(description = "현재 페이지 번호", example = "0") @Schema(description = "페이지 번호 (0부터 시작)", example = "0")
private int pageNumber; private int pageNumber;
@Schema(description = "페이지 크기", example = "20") @Schema(description = "페이지 크기", example = "20")
@ -41,4 +41,28 @@ public class PageResponse<T> {
@Schema(description = "마지막 페이지 여부", example = "false") @Schema(description = "마지막 페이지 여부", example = "false")
private boolean last; private boolean last;
/**
* 성공적인 페이징 응답 생성
*
* @param content 페이지 컨텐츠
* @param pageNumber 페이지 번호
* @param pageSize 페이지 크기
* @param totalElements 전체 요소
* @param <T> 데이터 타입
* @return 페이징 응답
*/
public static <T> PageResponse<T> of(List<T> content, int pageNumber, int pageSize, long totalElements) {
int totalPages = (int) Math.ceil((double) totalElements / pageSize);
return PageResponse.<T>builder()
.content(content)
.pageNumber(pageNumber)
.pageSize(pageSize)
.totalElements(totalElements)
.totalPages(totalPages)
.first(pageNumber == 0)
.last(pageNumber >= totalPages - 1)
.build();
}
} }

View File

@ -2,21 +2,18 @@ package com.won.smarketing.common.exception;
import com.won.smarketing.common.dto.ApiResponse; import com.won.smarketing.common.dto.ApiResponse;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindException; import org.springframework.validation.FieldError;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import java.nio.file.AccessDeniedException; import java.util.HashMap;
import java.util.Map;
/** /**
* 전역 예외 처리 핸들러 * 전역 예외 처리
* 애플리케이션에서 발생하는 모든 예외를 처리하여 일관된 응답 형식 제공 * 애플리케이션 전반의 예외를 통일된 형식으로 처리
*/ */
@Slf4j @Slf4j
@RestControllerAdvice @RestControllerAdvice
@ -31,121 +28,52 @@ public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class) @ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException ex) { public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException ex) {
log.warn("Business exception occurred: {}", ex.getMessage()); log.warn("Business exception occurred: {}", ex.getMessage());
ErrorCode errorCode = ex.getErrorCode();
return ResponseEntity return ResponseEntity
.status(errorCode.getHttpStatus()) .status(ex.getErrorCode().getHttpStatus())
.body(ApiResponse.error(errorCode.getHttpStatus().value(), ex.getMessage())); .body(ApiResponse.error(
ex.getErrorCode().getHttpStatus().value(),
ex.getMessage()
));
} }
/** /**
* 유효성 검증 예외 처리 (@Valid 애노테이션) * 입력값 검증 예외 처리
* *
* @param ex 유효성 검증 예외 * @param ex 입력값 검증 예외
* @return 오류 응답 * @return 오류 응답
*/ */
@ExceptionHandler(MethodArgumentNotValidException.class) @ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidationException(MethodArgumentNotValidException ex) { public ResponseEntity<ApiResponse<Map<String, String>>> handleValidationException(
MethodArgumentNotValidException ex) {
log.warn("Validation exception occurred: {}", ex.getMessage()); log.warn("Validation exception occurred: {}", ex.getMessage());
String errorMessage = ex.getBindingResult()
.getFieldErrors()
.stream()
.findFirst()
.map(error -> error.getDefaultMessage())
.orElse("유효성 검증에 실패했습니다.");
return ResponseEntity Map<String, String> errors = new HashMap<>();
.status(HttpStatus.BAD_REQUEST) ex.getBindingResult().getAllErrors().forEach(error -> {
.body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), errorMessage)); String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
return ResponseEntity.badRequest()
.body(ApiResponse.<Map<String, String>>builder()
.status(400)
.message("입력값 검증에 실패했습니다.")
.data(errors)
.build());
} }
/** /**
* 바인딩 예외 처리 * 일반적인 예외 처리
*
* @param ex 바인딩 예외
* @return 오류 응답
*/
@ExceptionHandler(BindException.class)
public ResponseEntity<ApiResponse<Void>> handleBindException(BindException ex) {
log.warn("Bind exception occurred: {}", ex.getMessage());
String errorMessage = ex.getBindingResult()
.getFieldErrors()
.stream()
.findFirst()
.map(error -> error.getDefaultMessage())
.orElse("요청 데이터 바인딩에 실패했습니다.");
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), errorMessage));
}
/**
* 타입 불일치 예외 처리
*
* @param ex 타입 불일치 예외
* @return 오류 응답
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ApiResponse<Void>> handleTypeMismatchException(MethodArgumentTypeMismatchException ex) {
log.warn("Type mismatch exception occurred: {}", ex.getMessage());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), "잘못된 타입의 값입니다."));
}
/**
* 필수 파라미터 누락 예외 처리
*
* @param ex 필수 파라미터 누락 예외
* @return 오류 응답
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<ApiResponse<Void>> handleMissingParameterException(MissingServletRequestParameterException ex) {
log.warn("Missing parameter exception occurred: {}", ex.getMessage());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), "필수 요청 파라미터가 누락되었습니다: " + ex.getParameterName()));
}
/**
* HTTP 메서드 불일치 예외 처리
*
* @param ex HTTP 메서드 불일치 예외
* @return 오류 응답
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ApiResponse<Void>> handleMethodNotSupportedException(HttpRequestMethodNotSupportedException ex) {
log.warn("Method not supported exception occurred: {}", ex.getMessage());
return ResponseEntity
.status(HttpStatus.METHOD_NOT_ALLOWED)
.body(ApiResponse.error(HttpStatus.METHOD_NOT_ALLOWED.value(), "허용되지 않은 HTTP 메서드입니다."));
}
/**
* 접근 거부 예외 처리
*
* @param ex 접근 거부 예외
* @return 오류 응답
*/
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleAccessDeniedException(AccessDeniedException ex) {
log.warn("Access denied exception occurred: {}", ex.getMessage());
return ResponseEntity
.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(HttpStatus.FORBIDDEN.value(), "접근이 거부되었습니다."));
}
/**
* 기타 모든 예외 처리
* *
* @param ex 예외 * @param ex 예외
* @return 오류 응답 * @return 오류 응답
*/ */
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGenericException(Exception ex) { public ResponseEntity<ApiResponse<Void>> handleException(Exception ex) {
log.error("Unexpected exception occurred", ex); log.error("Unexpected exception occurred", ex);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR) return ResponseEntity.internalServerError()
.body(ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 내부 오류가 발생했습니다.")); .body(ApiResponse.error(500, "서버 내부 오류가 발생했습니다."));
} }
} }

View File

@ -7,8 +7,8 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
@ -18,7 +18,7 @@ import java.util.Collections;
/** /**
* JWT 인증 필터 * JWT 인증 필터
* 요청 헤더에서 JWT 토큰을 추출하여 인증 처리 * HTTP 요청에서 JWT 토큰을 추출하고 인증 처리
*/ */
@Slf4j @Slf4j
@Component @Component
@ -30,44 +30,53 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String BEARER_PREFIX = "Bearer "; private static final String BEARER_PREFIX = "Bearer ";
/** /**
* JWT 토큰 인증 처리 * JWT 토큰 기반 인증 필터링
* *
* @param request HTTP 요청 * @param request HTTP 요청
* @param response HTTP 응답 * @param response HTTP 응답
* @param filterChain 필터 체인 * @param filterChain 필터 체인
* @throws ServletException 서블릿 예외 * @throws ServletException 서블릿 예외
* @throws IOException I/O 예외 * @throws IOException IO 예외
*/ */
@Override @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException { FilterChain filterChain) throws ServletException, IOException {
// 요청 헤더에서 JWT 토큰 추출 try {
String token = resolveToken(request); String jwt = getJwtFromRequest(request);
// 토큰이 있고 유효한 경우 인증 정보 설정 if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) {
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) { String userId = jwtTokenProvider.getUserIdFromToken(jwt);
String userId = jwtTokenProvider.getUserIdFromToken(token);
Authentication authentication = new UsernamePasswordAuthenticationToken( // 사용자 인증 정보 설정
userId, null, Collections.emptyList()); UsernamePasswordAuthenticationToken authentication =
SecurityContextHolder.getContext().setAuthentication(authentication); new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList());
log.debug("Security context에 '{}' 인증 정보를 저장했습니다.", userId); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("User '{}' authenticated successfully", userId);
}
} catch (Exception ex) {
log.error("Could not set user authentication in security context", ex);
} }
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} }
/** /**
* 요청 헤더에서 JWT 토큰 추출 * HTTP 요청에서 JWT 토큰 추출
* *
* @param request HTTP 요청 * @param request HTTP 요청
* @return JWT 토큰 (Bearer 접두사 제거) * @return JWT 토큰 (Bearer 접두사 제거)
*/ */
private String resolveToken(HttpServletRequest request) { private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER); String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(BEARER_PREFIX.length()); return bearerToken.substring(BEARER_PREFIX.length());
} }
return null; return null;
} }
} }

View File

@ -1,9 +1,6 @@
package com.won.smarketing.common.security; package com.won.smarketing.common.security;
import com.won.smarketing.common.exception.BusinessException;
import com.won.smarketing.common.exception.ErrorCode;
import io.jsonwebtoken.*; import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@ -13,67 +10,65 @@ import javax.crypto.SecretKey;
import java.util.Date; import java.util.Date;
/** /**
* JWT 토큰 생성 검증 유틸리티 * JWT 토큰 생성 검증 담당하는 클래스
* Access Token과 Refresh Token 관리 * 액세스 토큰과 리프레시 토큰의 생성, 검증, 파싱 기능 제공
*/ */
@Slf4j @Slf4j
@Component @Component
public class JwtTokenProvider { public class JwtTokenProvider {
private final SecretKey key; private final SecretKey secretKey;
private final long accessTokenValidityTime; private final long accessTokenValidityTime;
private final long refreshTokenValidityTime; private final long refreshTokenValidityTime;
/** /**
* JWT 토큰 프로바이더 생성자 * JWT 토큰 프로바이더 생성자
* *
* @param secretKey JWT 서명에 사용할 비밀키 * @param secret JWT 서명에 사용할 비밀키
* @param accessTokenValidityTime Access Token 유효 시간 (밀리초) * @param accessTokenValidityTime 액세스 토큰 유효시간 (밀리초)
* @param refreshTokenValidityTime Refresh Token 유효 시간 (밀리초) * @param refreshTokenValidityTime 리프레시 토큰 유효시간 (밀리초)
*/ */
public JwtTokenProvider( public JwtTokenProvider(@Value("${jwt.secret}") String secret,
@Value("${jwt.secret-key}") String secretKey, @Value("${jwt.access-token-validity}") long accessTokenValidityTime,
@Value("${jwt.access-token-validity}") long accessTokenValidityTime, @Value("${jwt.refresh-token-validity}") long refreshTokenValidityTime) {
@Value("${jwt.refresh-token-validity}") long refreshTokenValidityTime) { this.secretKey = Keys.hmacShaKeyFor(secret.getBytes());
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
this.accessTokenValidityTime = accessTokenValidityTime; this.accessTokenValidityTime = accessTokenValidityTime;
this.refreshTokenValidityTime = refreshTokenValidityTime; this.refreshTokenValidityTime = refreshTokenValidityTime;
} }
/** /**
* Access Token 생성 * 액세스 토큰 생성
* *
* @param userId 사용자 ID * @param userId 사용자 ID
* @return Access Token * @return 생성된 액세스 토큰
*/ */
public String generateAccessToken(String userId) { public String generateAccessToken(String userId) {
long now = System.currentTimeMillis(); Date now = new Date();
Date validity = new Date(now + accessTokenValidityTime); Date expiryDate = new Date(now.getTime() + accessTokenValidityTime);
return Jwts.builder() return Jwts.builder()
.setSubject(userId) .setSubject(userId)
.setIssuedAt(new Date(now)) .setIssuedAt(now)
.setExpiration(validity) .setExpiration(expiryDate)
.signWith(key, SignatureAlgorithm.HS256) .signWith(secretKey)
.compact(); .compact();
} }
/** /**
* Refresh Token 생성 * 리프레시 토큰 생성
* *
* @param userId 사용자 ID * @param userId 사용자 ID
* @return Refresh Token * @return 생성된 리프레시 토큰
*/ */
public String generateRefreshToken(String userId) { public String generateRefreshToken(String userId) {
long now = System.currentTimeMillis(); Date now = new Date();
Date validity = new Date(now + refreshTokenValidityTime); Date expiryDate = new Date(now.getTime() + refreshTokenValidityTime);
return Jwts.builder() return Jwts.builder()
.setSubject(userId) .setSubject(userId)
.setIssuedAt(new Date(now)) .setIssuedAt(now)
.setExpiration(validity) .setExpiration(expiryDate)
.signWith(key, SignatureAlgorithm.HS256) .signWith(secretKey)
.compact(); .compact();
} }
@ -84,67 +79,48 @@ public class JwtTokenProvider {
* @return 사용자 ID * @return 사용자 ID
*/ */
public String getUserIdFromToken(String token) { public String getUserIdFromToken(String token) {
Claims claims = parseClaims(token); Claims claims = Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
return claims.getSubject(); return claims.getSubject();
} }
/** /**
* 토큰 유효성 검증 * 토큰 유효성 검증
* *
* @param token JWT 토큰 * @param token 검증할 토큰
* @return 유효성 여부 * @return 유효성 여부
*/ */
public boolean validateToken(String token) { public boolean validateToken(String token) {
try { try {
parseClaims(token); Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token);
return true; return true;
} catch (ExpiredJwtException e) { } catch (SecurityException ex) {
log.warn("Expired JWT token: {}", e.getMessage()); log.error("Invalid JWT signature: {}", ex.getMessage());
throw new BusinessException(ErrorCode.TOKEN_EXPIRED); } catch (MalformedJwtException ex) {
} catch (UnsupportedJwtException e) { log.error("Invalid JWT token: {}", ex.getMessage());
log.warn("Unsupported JWT token: {}", e.getMessage()); } catch (ExpiredJwtException ex) {
throw new BusinessException(ErrorCode.INVALID_TOKEN); log.error("Expired JWT token: {}", ex.getMessage());
} catch (MalformedJwtException e) { } catch (UnsupportedJwtException ex) {
log.warn("Malformed JWT token: {}", e.getMessage()); log.error("Unsupported JWT token: {}", ex.getMessage());
throw new BusinessException(ErrorCode.INVALID_TOKEN); } catch (IllegalArgumentException ex) {
} catch (SecurityException e) { log.error("JWT claims string is empty: {}", ex.getMessage());
log.warn("Invalid JWT signature: {}", e.getMessage());
throw new BusinessException(ErrorCode.INVALID_TOKEN);
} catch (IllegalArgumentException e) {
log.warn("JWT token compact of handler are invalid: {}", e.getMessage());
throw new BusinessException(ErrorCode.INVALID_TOKEN);
} }
return false;
} }
/** /**
* 토큰에서 Claims 추출 * 액세스 토큰 유효시간 반환
* *
* @param token JWT 토큰 * @return 액세스 토큰 유효시간 (밀리초)
* @return Claims
*/
private Claims parseClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}
/**
* Access Token 유효 시간 반환
*
* @return Access Token 유효 시간 (밀리초)
*/ */
public long getAccessTokenValidityTime() { public long getAccessTokenValidityTime() {
return accessTokenValidityTime; return accessTokenValidityTime;
} }
/**
* Refresh Token 유효 시간 반환
*
* @return Refresh Token 유효 시간 (밀리초)
*/
public long getRefreshTokenValidityTime() {
return refreshTokenValidityTime;
}
} }

Binary file not shown.

View File

@ -1,7 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
gradlew vendored
View File

@ -1,251 +0,0 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored
View File

@ -1,94 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -1,6 +1,6 @@
dependencies { dependencies {
implementation project(':common') implementation project(':common')
implementation 'com.mysql:mysql-connector-j'
// HTTP Client for external AI API // HTTP Client for external AI API
implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'org.springframework.boot:spring-boot-starter-webflux'
} }

View File

@ -18,6 +18,10 @@ spring:
hibernate: hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true format_sql: true
ai:
service:
url: ${AI_SERVICE_URL:http://localhost:8080/ai}
timeout: ${AI_SERVICE_TIMEOUT:30000}
external: external:
claude-ai: claude-ai:

View File

@ -1,7 +1,4 @@
dependencies { dependencies {
implementation project(':common') implementation project(':common')
} implementation 'com.mysql:mysql-connector-j'
bootJar {
archiveFileName = "member-service.jar"
} }

View File

@ -0,0 +1,13 @@
package com.won.smarketing.member.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
/**
* JPA 설정 클래스
* JPA Auditing 기능 활성화
*/
@Configuration
@EnableJpaAuditing
public class JpaConfig {
}

View File

@ -0,0 +1,53 @@
package com.won.smarketing.member.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* Redis 설정 클래스
* Redis 연결 RedisTemplate 설정
*/
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String redisHost;
@Value("${spring.data.redis.port}")
private int redisPort;
/**
* Redis 연결 팩토리 설정
*
* @return RedisConnectionFactory
*/
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisHost, redisPort);
}
/**
* RedisTemplate 설정
*
* @return RedisTemplate<String, String>
*/
@Bean
public RedisTemplate<String, String> redisTemplate() {
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
// 직렬화 설정
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
}

View File

@ -1,25 +1,21 @@
package com.won.smarketing.member.controller; package com.won.smarketing.member.controller;
import com.won.smarketing.common.dto.ApiResponse; import com.won.smarketing.common.dto.ApiResponse;
import com.won.smarketing.member.dto.LoginRequest; import com.won.smarketing.member.dto.*;
import com.won.smarketing.member.dto.LoginResponse;
import com.won.smarketing.member.dto.LogoutRequest;
import com.won.smarketing.member.dto.TokenRefreshRequest;
import com.won.smarketing.member.dto.TokenResponse;
import com.won.smarketing.member.service.AuthService; import com.won.smarketing.member.service.AuthService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
/** /**
* 인증/인가를 위한 REST API 컨트롤러 * 인증 위한 REST API 컨트롤러
* 로그인, 로그아웃, 토큰 갱신 기능 제공 * 로그인, 로그아웃, 토큰 갱신 기능 제공
*/ */
@Tag(name = "인증/인가", description = "로그인, 로그아웃, 토큰 관리 API") @Tag(name = "인증 관리", description = "로그인, 로그아웃, 토큰 관리 API")
@RestController @RestController
@RequestMapping("/api/auth") @RequestMapping("/api/auth")
@RequiredArgsConstructor @RequiredArgsConstructor
@ -28,12 +24,12 @@ public class AuthController {
private final AuthService authService; private final AuthService authService;
/** /**
* 로그인 인증 * 로그인
* *
* @param request 로그인 요청 정보 * @param request 로그인 요청 정보
* @return JWT 토큰 정보 * @return 로그인 성공 응답 (토큰 포함)
*/ */
@Operation(summary = "로그인", description = "사용자 인증 후 JWT 토큰을 발급합니다.") @Operation(summary = "로그인", description = "사용자 ID와 패스워드로 로그인합니다.")
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<ApiResponse<LoginResponse>> login(@Valid @RequestBody LoginRequest request) { public ResponseEntity<ApiResponse<LoginResponse>> login(@Valid @RequestBody LoginRequest request) {
LoginResponse response = authService.login(request); LoginResponse response = authService.login(request);
@ -41,12 +37,12 @@ public class AuthController {
} }
/** /**
* 로그아웃 처리 * 로그아웃
* *
* @param request 로그아웃 요청 정보 * @param request 로그아웃 요청 정보
* @return 로그아웃 성공 응답 * @return 로그아웃 성공 응답
*/ */
@Operation(summary = "로그아웃", description = "사용자를 로그아웃하고 토큰을 무효화합니다.") @Operation(summary = "로그아웃", description = "리프레시 토큰을 무효화하여 로그아웃합니다.")
@PostMapping("/logout") @PostMapping("/logout")
public ResponseEntity<ApiResponse<Void>> logout(@Valid @RequestBody LogoutRequest request) { public ResponseEntity<ApiResponse<Void>> logout(@Valid @RequestBody LogoutRequest request) {
authService.logout(request.getRefreshToken()); authService.logout(request.getRefreshToken());
@ -57,9 +53,9 @@ public class AuthController {
* 토큰 갱신 * 토큰 갱신
* *
* @param request 토큰 갱신 요청 정보 * @param request 토큰 갱신 요청 정보
* @return 새로운 JWT 토큰 정보 * @return 새로운 토큰 정보
*/ */
@Operation(summary = "토큰 갱신", description = "Refresh Token을 사용하여 새로운 Access Token을 발급합니다.") @Operation(summary = "토큰 갱신", description = "리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다.")
@PostMapping("/refresh") @PostMapping("/refresh")
public ResponseEntity<ApiResponse<TokenResponse>> refresh(@Valid @RequestBody TokenRefreshRequest request) { public ResponseEntity<ApiResponse<TokenResponse>> refresh(@Valid @RequestBody TokenRefreshRequest request) {
TokenResponse response = authService.refresh(request.getRefreshToken()); TokenResponse response = authService.refresh(request.getRefreshToken());

View File

@ -9,15 +9,15 @@ import com.won.smarketing.member.service.MemberService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
/** /**
* 회원 관리를 위한 REST API 컨트롤러 * 회원 관리를 위한 REST API 컨트롤러
* 회원가입, ID 중복 확인, 패스워드 유효성 검증 기능 제공 * 회원가입, 중복 확인, 패스워드 검증 기능 제공
*/ */
@Tag(name = "회원 관리", description = "회원가입 및 회원 정보 관리 API") @Tag(name = "회원 관리", description = "회원가입 및 회원 정보 관리 API")
@RestController @RestController
@ -28,10 +28,10 @@ public class MemberController {
private final MemberService memberService; private final MemberService memberService;
/** /**
* 회원가입 처리 * 회원가입
* *
* @param request 회원가입 요청 정보 * @param request 회원가입 요청 정보
* @return 회원가입 성공/실패 응답 * @return 회원가입 성공 응답
*/ */
@Operation(summary = "회원가입", description = "새로운 회원을 등록합니다.") @Operation(summary = "회원가입", description = "새로운 회원을 등록합니다.")
@PostMapping("/register") @PostMapping("/register")
@ -41,34 +41,80 @@ public class MemberController {
} }
/** /**
* ID 중복 확인 * 사용자 ID 중복 확인
* *
* @param userId 확인할 사용자 ID * @param userId 확인할 사용자 ID
* @return 중복 여부 응답 * @return 중복 확인 결과
*/ */
@Operation(summary = "ID 중복 확인", description = "사용자 ID의 중복 여부를 확인합니다.") @Operation(summary = "사용자 ID 중복 확인", description = "사용자 ID의 중복 여부를 확인합니다.")
@GetMapping("/check-duplicate") @GetMapping("/check-duplicate/user-id")
public ResponseEntity<ApiResponse<DuplicateCheckResponse>> checkDuplicate( public ResponseEntity<ApiResponse<DuplicateCheckResponse>> checkUserIdDuplicate(
@Parameter(description = "확인할 사용자 ID", required = true) @Parameter(description = "확인할 사용자 ID", required = true)
@RequestParam String userId) { @RequestParam String userId) {
boolean isDuplicate = memberService.checkDuplicate(userId); boolean isDuplicate = memberService.checkDuplicate(userId);
DuplicateCheckResponse response = DuplicateCheckResponse.builder() DuplicateCheckResponse response = isDuplicate
.isDuplicate(isDuplicate) ? DuplicateCheckResponse.duplicate("이미 사용 중인 사용자 ID입니다.")
.message(isDuplicate ? "이미 사용 중인 ID입니다." : "사용 가능한 ID입니다.") : DuplicateCheckResponse.available("사용 가능한 사용자 ID입니다.");
.build();
return ResponseEntity.ok(ApiResponse.success(response));
}
/**
* 이메일 중복 확인
*
* @param email 확인할 이메일
* @return 중복 확인 결과
*/
@Operation(summary = "이메일 중복 확인", description = "이메일의 중복 여부를 확인합니다.")
@GetMapping("/check-duplicate/email")
public ResponseEntity<ApiResponse<DuplicateCheckResponse>> checkEmailDuplicate(
@Parameter(description = "확인할 이메일", required = true)
@RequestParam String email) {
boolean isDuplicate = memberService.checkEmailDuplicate(email);
DuplicateCheckResponse response = isDuplicate
? DuplicateCheckResponse.duplicate("이미 사용 중인 이메일입니다.")
: DuplicateCheckResponse.available("사용 가능한 이메일입니다.");
return ResponseEntity.ok(ApiResponse.success(response));
}
/**
* 사업자번호 중복 확인
*
* @param businessNumber 확인할 사업자번호
* @return 중복 확인 결과
*/
@Operation(summary = "사업자번호 중복 확인", description = "사업자번호의 중복 여부를 확인합니다.")
@GetMapping("/check-duplicate/business-number")
public ResponseEntity<ApiResponse<DuplicateCheckResponse>> checkBusinessNumberDuplicate(
@Parameter(description = "확인할 사업자번호", required = true)
@RequestParam String businessNumber) {
boolean isDuplicate = memberService.checkBusinessNumberDuplicate(businessNumber);
DuplicateCheckResponse response = isDuplicate
? DuplicateCheckResponse.duplicate("이미 등록된 사업자번호입니다.")
: DuplicateCheckResponse.available("사용 가능한 사업자번호입니다.");
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));
} }
/** /**
* 패스워드 유효성 검증 * 패스워드 유효성 검증
* *
* @param request 패스워드 유효성 검증 요청 * @param request 패스워드 검증 요청
* @return 유효성 검증 결과 * @return 패스워드 검증 결과
*/ */
@Operation(summary = "패스워드 유효성 검증", description = "패스워드가 보안 규칙을 만족하는지 확인합니다.") @Operation(summary = "패스워드 검증", description = "패스워드가 규칙을 만족하는지 확인합니다.")
@PostMapping("/validate-password") @PostMapping("/validate-password")
public ResponseEntity<ApiResponse<ValidationResponse>> validatePassword(@Valid @RequestBody PasswordValidationRequest request) { public ResponseEntity<ApiResponse<ValidationResponse>> validatePassword(
@Valid @RequestBody PasswordValidationRequest request) {
ValidationResponse response = memberService.validatePassword(request.getPassword()); ValidationResponse response = memberService.validatePassword(request.getPassword());
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));
} }
} }

View File

@ -7,19 +7,48 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* ID 중복 확인 응답 DTO * 중복 확인 응답 DTO
* 사용자 ID 중복 여부 확인 결과 * 사용자 ID, 이메일 등의 중복 확인 결과를 전달합니다.
*/ */
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@Schema(description = "ID 중복 확인 응답") @Schema(description = "중복 확인 응답")
public class DuplicateCheckResponse { public class DuplicateCheckResponse {
@Schema(description = "중복 여부", example = "false") @Schema(description = "중복 여부", example = "false")
private boolean isDuplicate; private boolean isDuplicate;
@Schema(description = "확인 결과 메시지", example = "사용 가능한 ID입니다.") @Schema(description = "메시지", example = "사용 가능한 ID입니다.")
private String message; private String message;
/**
* 중복된 경우의 응답 생성
*
* @param message 메시지
* @return 중복 응답
*/
public static DuplicateCheckResponse duplicate(String message) {
return DuplicateCheckResponse.builder()
.isDuplicate(true)
.message(message)
.build();
}
/**
* 사용 가능한 경우의 응답 생성
*
* @param message 메시지
* @return 사용 가능 응답
*/
public static DuplicateCheckResponse available(String message) {
return DuplicateCheckResponse.builder()
.isDuplicate(false)
.message(message)
.build();
}
} }

View File

@ -1,29 +1,26 @@
package com.won.smarketing.member.dto; package com.won.smarketing.member.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import javax.validation.constraints.NotBlank;
/** /**
* 로그인 요청 DTO * 로그인 요청 DTO
* 로그인 필요한 사용자 ID와 패스워드 정보 * 로그인 필요한 정보를 전달합니다.
*/ */
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Schema(description = "로그인 요청")
@Schema(description = "로그인 요청 정보")
public class LoginRequest { public class LoginRequest {
@Schema(description = "사용자 ID", example = "testuser", required = true) @Schema(description = "사용자 ID", example = "user123", required = true)
@NotBlank(message = "사용자 ID는 필수입니다.") @NotBlank(message = "사용자 ID는 필수입니다")
private String userId; private String userId;
@Schema(description = "패스워드", example = "password123!", required = true) @Schema(description = "패스워드", example = "password123!", required = true)
@NotBlank(message = "패스워드는 필수입니다.") @NotBlank(message = "패스워드는 필수입니다")
private String password; private String password;
} }

View File

@ -8,21 +8,40 @@ import lombok.NoArgsConstructor;
/** /**
* 로그인 응답 DTO * 로그인 응답 DTO
* 로그인 성공 반환되는 JWT 토큰 정보 * 로그인 성공 토큰 정보를 전달합니다.
*/ */
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@Schema(description = "로그인 응답 정보") @Schema(description = "로그인 응답")
public class LoginResponse { public class LoginResponse {
@Schema(description = "Access Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") @Schema(description = "액세스 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
private String accessToken; private String accessToken;
@Schema(description = "Refresh Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") @Schema(description = "리프레시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
private String refreshToken; private String refreshToken;
@Schema(description = "토큰 만료 시간 (밀리초)", example = "900000") @Schema(description = "토큰 만료 시간 (초)", example = "3600")
private long expiresIn; private long expiresIn;
@Schema(description = "사용자 정보")
private UserInfo userInfo;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "사용자 정보")
public static class UserInfo {
@Schema(description = "사용자 ID", example = "user123")
private String userId;
@Schema(description = "이름", example = "홍길동")
private String name;
@Schema(description = "이메일", example = "user@example.com")
private String email;
}
} }

View File

@ -1,25 +1,22 @@
package com.won.smarketing.member.dto; package com.won.smarketing.member.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import javax.validation.constraints.NotBlank;
/** /**
* 패스워드 유효성 검증 요청 DTO * 패스워드 검증 요청 DTO
* 패스워드 보안 규칙 확인 위한 요청 정보 * 패스워드 규칙 검증 위한 요청 정보 전달합니다.
*/ */
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Schema(description = "패스워드 검증 요청")
@Schema(description = "패스워드 유효성 검증 요청")
public class PasswordValidationRequest { public class PasswordValidationRequest {
@Schema(description = "검증할 패스워드", example = "password123!", required = true) @Schema(description = "검증할 패스워드", example = "password123!", required = true)
@NotBlank(message = "패스워드는 필수입니다.") @NotBlank(message = "패스워드는 필수입니다")
private String password; private String password;
} }

View File

@ -1,49 +1,49 @@
package com.won.smarketing.member.dto; package com.won.smarketing.member.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
/** /**
* 회원가입 요청 DTO * 회원가입 요청 DTO
* 회원가입 필요한 정보를 담는 데이터 전송 객체 * 회원가입 필요한 정보를 전달합니다.
*/ */
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Schema(description = "회원가입 요청")
@Schema(description = "회원가입 요청 정보")
public class RegisterRequest { public class RegisterRequest {
@Schema(description = "사용자 ID", example = "testuser", required = true) @Schema(description = "사용자 ID", example = "user123", required = true)
@NotBlank(message = "사용자 ID는 필수입니다.") @NotBlank(message = "사용자 ID는 필수입니다")
@Size(min = 4, max = 20, message = "사용자 ID는 4자 이상 20자 이하여야 합니다.") @Size(min = 4, max = 20, message = "사용자 ID는 4-20자 사이여야 합니다")
@Pattern(regexp = "^[a-zA-Z0-9]+$", message = "사용자 ID는 영문과 숫자만 가능합니다.") @Pattern(regexp = "^[a-zA-Z0-9]+$", message = "사용자 ID는 영문과 숫자만 사용 가능합니다")
private String userId; private String userId;
@Schema(description = "패스워드", example = "password123!", required = true) @Schema(description = "패스워드", example = "password123!", required = true)
@NotBlank(message = "패스워드는 필수입니다.") @NotBlank(message = "패스워드는 필수입니다")
@Size(min = 8, max = 20, message = "패스워드는 8-20자 사이여야 합니다")
@Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]+$",
message = "패스워드는 영문, 숫자, 특수문자를 포함해야 합니다")
private String password; private String password;
@Schema(description = "이름", example = "홍길동", required = true) @Schema(description = "이름", example = "홍길동", required = true)
@NotBlank(message = "이름은 필수입니다.") @NotBlank(message = "이름은 필수입니다")
@Size(max = 100, message = "이름은 100자 이하여야 합니다.") @Size(max = 50, message = "이름은 50자 이하여야 합니다")
private String name; private String name;
@Schema(description = "사업자 번호", example = "123-45-67890", required = true) @Schema(description = "사업자등록번호", example = "123-45-67890")
@NotBlank(message = "사업자 번호는 필수입니다.") @Pattern(regexp = "^\\d{3}-\\d{2}-\\d{5}$", message = "사업자등록번호 형식이 올바르지 않습니다 (000-00-00000)")
@Pattern(regexp = "^\\d{3}-\\d{2}-\\d{5}$", message = "사업자 번호 형식이 올바르지 않습니다.")
private String businessNumber; private String businessNumber;
@Schema(description = "이메일", example = "test@example.com", required = true) @Schema(description = "이메일", example = "user@example.com", required = true)
@NotBlank(message = "이메일은 필수입니다.") @NotBlank(message = "이메일은 필수입니다")
@Email(message = "올바른 이메일 형식이 아닙니다.") @Email(message = "이메일 형식이 올바르지 않습니다")
@Size(max = 100, message = "이메일은 100자 이하여야 합니다")
private String email; private String email;
} }

View File

@ -1,25 +1,22 @@
package com.won.smarketing.member.dto; package com.won.smarketing.member.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import javax.validation.constraints.NotBlank;
/** /**
* 토큰 갱신 요청 DTO * 토큰 갱신 요청 DTO
* Refresh Token을 사용한 토큰 갱신 요청 정보 * 리프레시 토큰을 사용한 액세스 토큰 갱신 요청 정보 전달합니다.
*/ */
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder
@Schema(description = "토큰 갱신 요청") @Schema(description = "토큰 갱신 요청")
public class TokenRefreshRequest { public class TokenRefreshRequest {
@Schema(description = "Refresh Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", required = true) @Schema(description = "리프레시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", required = true)
@NotBlank(message = "Refresh Token은 필수입니다.") @NotBlank(message = "리프레시 토큰은 필수입니다")
private String refreshToken; private String refreshToken;
} }

View File

@ -8,21 +8,21 @@ import lombok.NoArgsConstructor;
/** /**
* 토큰 응답 DTO * 토큰 응답 DTO
* 토큰 갱신 반환되는 새로운 JWT 토큰 정보 * 토큰 갱신 새로운 토큰 정보를 전달합니다.
*/ */
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@Schema(description = "토큰 응답 정보") @Schema(description = "토큰 응답")
public class TokenResponse { public class TokenResponse {
@Schema(description = "새로운 Access Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") @Schema(description = "새로운 액세스 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
private String accessToken; private String accessToken;
@Schema(description = "새로운 Refresh Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") @Schema(description = "새로운 리프레시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
private String refreshToken; private String refreshToken;
@Schema(description = "토큰 만료 시간 (밀리초)", example = "900000") @Schema(description = "토큰 만료 시간 (초)", example = "3600")
private long expiresIn; private long expiresIn;
} }

View File

@ -9,22 +9,50 @@ import lombok.NoArgsConstructor;
import java.util.List; import java.util.List;
/** /**
* 유효성 검증 응답 DTO * 검증 응답 DTO
* 패스워드 유효성 검증 결과 정보 * 패스워드 등의 검증 결과를 전달합니다.
*/ */
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@Schema(description = "유효성 검증 응답") @Schema(description = "검증 응답")
public class ValidationResponse { public class ValidationResponse {
@Schema(description = "유효성 여부", example = "true") @Schema(description = "유효성 여부", example = "true")
private boolean isValid; private boolean isValid;
@Schema(description = "검증 결과 메시지", example = "유효한 패스워드입니다.") @Schema(description = "메시지", example = "사용 가능한 패스워드입니다.")
private String message; private String message;
@Schema(description = "오류 목록") @Schema(description = "오류 목록", example = "[\"영문이 포함되어야 합니다\", \"숫자가 포함되어야 합니다\"]")
private List<String> errors; private List<String> errors;
/**
* 유효한 경우의 응답 생성
*
* @param message 메시지
* @return 유효 응답
*/
public static ValidationResponse valid(String message) {
return ValidationResponse.builder()
.isValid(true)
.message(message)
.build();
}
/**
* 유효하지 않은 경우의 응답 생성
*
* @param message 메시지
* @param errors 오류 목록
* @return 무효 응답
*/
public static ValidationResponse invalid(String message, List<String> errors) {
return ValidationResponse.builder()
.isValid(false)
.message(message)
.errors(errors)
.build();
}
} }

View File

@ -1,87 +1,29 @@
package com.won.smarketing.member.entity; package com.won.smarketing.member.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.AllArgsConstructor;
import lombok.Builder;
import java.time.LocalDateTime; import lombok.Getter;
import lombok.NoArgsConstruct
/** 재시도
* 회원 정보를 나타내는 엔티티 Y
* 사용자 ID, 패스워드, 이름, 사업자 번호, 이메일 정보 저장 계속
*/ 편집
@Entity Member 서비스 모든 클래스 구현
@Table(name = "members") 코드버전 2
@Getter /**
@NoArgsConstructor(access = AccessLevel.PROTECTED) * 사용자 ID로 회원 조회
@AllArgsConstructor *
@Builder * @param userId 사용자 ID
public class Member { * @return 회원 정보 (Optional)
*/
Optional<Member> findByUserId(String userId);
/** /**
* 회원 고유 식별자 * 사용자 ID 존재 여부 확인
*/ *
@Id * @param userId 사용자 ID
@GeneratedValue(strategy = GenerationType.IDENTITY) * @return 존재 여부
private Long id;
/** Member 인증 서비스 구현체 Controllers
* 사용자 ID (로그인용) 코드
*/
@Column(name = "user_id", unique = true, nullable = false, length = 50)
private String userId;
/**
* 암호화된 패스워드
*/
@Column(name = "password", nullable = false)
private String password;
/**
* 회원 이름
*/
@Column(name = "name", nullable = false, length = 100)
private String name;
/**
* 사업자 번호
*/
@Column(name = "business_number", unique = true, nullable = false, length = 20)
private String businessNumber;
/**
* 이메일 주소
*/
@Column(name = "email", unique = true, nullable = false)
private String email;
/**
* 회원 생성 시각
*/
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
/**
* 회원 정보 수정 시각
*/
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
/**
* 엔티티 저장 실행되는 메서드
* 생성 시각과 수정 시각을 현재 시각으로 설정
*/
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
/**
* 엔티티 업데이트 실행되는 메서드
* 수정 시각을 현재 시각으로 갱신
*/
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@ -17,7 +17,7 @@ public interface MemberRepository extends JpaRepository<Member, Long> {
* 사용자 ID로 회원 조회 * 사용자 ID로 회원 조회
* *
* @param userId 사용자 ID * @param userId 사용자 ID
* @return 회원 정보 * @return 회원 정보 (Optional)
*/ */
Optional<Member> findByUserId(String userId); Optional<Member> findByUserId(String userId);
@ -38,9 +38,9 @@ public interface MemberRepository extends JpaRepository<Member, Long> {
boolean existsByEmail(String email); boolean existsByEmail(String email);
/** /**
* 사업자 번호 존재 여부 확인 * 사업자번호 존재 여부 확인
* *
* @param businessNumber 사업자 번호 * @param businessNumber 사업자번호
* @return 존재 여부 * @return 존재 여부
*/ */
boolean existsByBusinessNumber(String businessNumber); boolean existsByBusinessNumber(String businessNumber);

View File

@ -5,31 +5,31 @@ import com.won.smarketing.member.dto.LoginResponse;
import com.won.smarketing.member.dto.TokenResponse; import com.won.smarketing.member.dto.TokenResponse;
/** /**
* 인증/인가 서비스 인터페이스 * 인증 서비스 인터페이스
* 로그인, 로그아웃, 토큰 갱신 기능 정의 * 로그인, 로그아웃, 토큰 갱신 관련 비즈니스 로직 정의
*/ */
public interface AuthService { public interface AuthService {
/** /**
* 로그인 인증 처리 * 로그인
* *
* @param request 로그인 요청 정보 * @param request 로그인 요청 정보
* @return JWT 토큰 정보 * @return 로그인 응답 정보 (토큰 포함)
*/ */
LoginResponse login(LoginRequest request); LoginResponse login(LoginRequest request);
/** /**
* 로그아웃 처리 * 로그아웃
* *
* @param refreshToken 무효화할 Refresh Token * @param refreshToken 리프레시 토큰
*/ */
void logout(String refreshToken); void logout(String refreshToken);
/** /**
* 토큰 갱신 처리 * 토큰 갱신
* *
* @param refreshToken 갱신에 사용할 Refresh Token * @param refreshToken 리프레시 토큰
* @return 새로운 JWT 토큰 정보 * @return 새로운 토큰 정보
*/ */
TokenResponse refresh(String refreshToken); TokenResponse refresh(String refreshToken);
} }

View File

@ -2,130 +2,21 @@ package com.won.smarketing.member.service;
import com.won.smarketing.common.exception.BusinessException; import com.won.smarketing.common.exception.BusinessException;
import com.won.smarketing.common.exception.ErrorCode; import com.won.smarketing.common.exception.ErrorCode;
import com.won.smarketing.common.security.JwtTokenProvider; import com.
import com.won.smarketing.member.dto.LoginRequest; 재시도
import com.won.smarketing.member.dto.LoginResponse; Y
import com.won.smarketing.member.dto.TokenResponse; 계속
import com.won.smarketing.member.entity.Member; 편집
import com.won.smarketing.member.repository.MemberRepository; Member 인증 서비스 구현체 Controllers
import lombok.RequiredArgsConstructor; 코드버전 2
import org.springframework.data.redis.core.RedisTemplate; // 새로운 리프레시 토큰을 Redis에 저장
import org.springframework.security.crypto.password.PasswordEncoder; redisTemplate.opsForValue().set(
import org.springframework.stereotype.Service; REFRESH_TOKEN_PREFIX + userId,
import org.springframework.transaction.annotation.Transactional; newRefreshToken,
7,
TimeUnit.DAYS
);
import java.util.concurrent.TimeUnit; // 기존 리프레시 토큰을
Store 서비스 Entity DTO 클래스들
/** 코드
* 인증/인가 서비스 구현체
* 로그인, 로그아웃, 토큰 갱신 기능 구현
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AuthServiceImpl implements AuthService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider;
private final RedisTemplate<String, String> redisTemplate;
private static final String REFRESH_TOKEN_PREFIX = "refresh_token:";
/**
* 로그인 인증 처리
*
* @param request 로그인 요청 정보
* @return JWT 토큰 정보
*/
@Override
@Transactional
public LoginResponse login(LoginRequest request) {
// 사용자 조회
Member member = memberRepository.findByUserId(request.getUserId())
.orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND));
// 패스워드 검증
if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) {
throw new BusinessException(ErrorCode.INVALID_PASSWORD);
}
// JWT 토큰 생성
String accessToken = jwtTokenProvider.generateAccessToken(member.getUserId());
String refreshToken = jwtTokenProvider.generateRefreshToken(member.getUserId());
long expiresIn = jwtTokenProvider.getAccessTokenValidityTime();
// Refresh Token을 Redis에 저장
String refreshTokenKey = REFRESH_TOKEN_PREFIX + member.getUserId();
redisTemplate.opsForValue().set(refreshTokenKey, refreshToken,
jwtTokenProvider.getRefreshTokenValidityTime(), TimeUnit.MILLISECONDS);
return LoginResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.expiresIn(expiresIn)
.build();
}
/**
* 로그아웃 처리
*
* @param refreshToken 무효화할 Refresh Token
*/
@Override
@Transactional
public void logout(String refreshToken) {
// 토큰에서 사용자 ID 추출
String userId = jwtTokenProvider.getUserIdFromToken(refreshToken);
// Redis에서 Refresh Token 삭제
String refreshTokenKey = REFRESH_TOKEN_PREFIX + userId;
redisTemplate.delete(refreshTokenKey);
}
/**
* 토큰 갱신 처리
*
* @param refreshToken 갱신에 사용할 Refresh Token
* @return 새로운 JWT 토큰 정보
*/
@Override
@Transactional
public TokenResponse refresh(String refreshToken) {
// Refresh Token 유효성 검증
if (!jwtTokenProvider.validateToken(refreshToken)) {
throw new BusinessException(ErrorCode.INVALID_TOKEN);
}
// 토큰에서 사용자 ID 추출
String userId = jwtTokenProvider.getUserIdFromToken(refreshToken);
// Redis에서 저장된 Refresh Token 확인
String refreshTokenKey = REFRESH_TOKEN_PREFIX + userId;
String storedRefreshToken = redisTemplate.opsForValue().get(refreshTokenKey);
if (storedRefreshToken == null || !storedRefreshToken.equals(refreshToken)) {
throw new BusinessException(ErrorCode.INVALID_TOKEN);
}
// 사용자 존재 여부 확인
Member member = memberRepository.findByUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND));
// 새로운 토큰 생성
String newAccessToken = jwtTokenProvider.generateAccessToken(member.getUserId());
String newRefreshToken = jwtTokenProvider.generateRefreshToken(member.getUserId());
long expiresIn = jwtTokenProvider.getAccessTokenValidityTime();
// 기존 Refresh Token 삭제 새로운 토큰 저장
redisTemplate.delete(refreshTokenKey);
redisTemplate.opsForValue().set(refreshTokenKey, newRefreshToken,
jwtTokenProvider.getRefreshTokenValidityTime(), TimeUnit.MILLISECONDS);
return TokenResponse.builder()
.accessToken(newAccessToken)
.refreshToken(newRefreshToken)
.expiresIn(expiresIn)
.build();
}
}

View File

@ -4,13 +4,13 @@ import com.won.smarketing.member.dto.RegisterRequest;
import com.won.smarketing.member.dto.ValidationResponse; import com.won.smarketing.member.dto.ValidationResponse;
/** /**
* 회원 관리 서비스 인터페이스 * 회원 서비스 인터페이스
* 회원가입, 중복 확인, 패스워드 유효성 검증 기능 정의 * 회원 관리 관련 비즈니스 로직 정의
*/ */
public interface MemberService { public interface MemberService {
/** /**
* 회원가입 처리 * 회원 등록
* *
* @param request 회원가입 요청 정보 * @param request 회원가입 요청 정보
*/ */
@ -20,15 +20,31 @@ public interface MemberService {
* 사용자 ID 중복 확인 * 사용자 ID 중복 확인
* *
* @param userId 확인할 사용자 ID * @param userId 확인할 사용자 ID
* @return 중복 여부 (true: 중복, false: 사용 가능) * @return 중복 여부
*/ */
boolean checkDuplicate(String userId); boolean checkDuplicate(String userId);
/**
* 이메일 중복 확인
*
* @param email 확인할 이메일
* @return 중복 여부
*/
boolean checkEmailDuplicate(String email);
/**
* 사업자번호 중복 확인
*
* @param businessNumber 확인할 사업자번호
* @return 중복 여부
*/
boolean checkBusinessNumberDuplicate(String businessNumber);
/** /**
* 패스워드 유효성 검증 * 패스워드 유효성 검증
* *
* @param password 검증할 패스워드 * @param password 검증할 패스워드
* @return 유효성 검증 결과 * @return 검증 결과
*/ */
ValidationResponse validatePassword(String password); ValidationResponse validatePassword(String password);
} }

View File

@ -7,6 +7,7 @@ import com.won.smarketing.member.dto.ValidationResponse;
import com.won.smarketing.member.entity.Member; import com.won.smarketing.member.entity.Member;
import com.won.smarketing.member.repository.MemberRepository; import com.won.smarketing.member.repository.MemberRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@ -16,9 +17,10 @@ import java.util.List;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/** /**
* 회원 관리 서비스 구현체 * 회원 서비스 구현체
* 회원가입, 중복 확인, 패스워드 유효성 검증 기능 구현 * 회원 등록, 중복 확인, 패스워드 검증 기능 구현
*/ */
@Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Transactional(readOnly = true) @Transactional(readOnly = true)
@ -27,89 +29,118 @@ public class MemberServiceImpl implements MemberService {
private final MemberRepository memberRepository; private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
// 패스워드 정규식: 영문, 숫자, 특수문자 각각 최소 1개 포함, 8자 이상 // 패스워드 검증 패턴
private static final Pattern PASSWORD_PATTERN = Pattern.compile( private static final Pattern LETTER_PATTERN = Pattern.compile(".*[a-zA-Z].*");
"^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$" private static final Pattern DIGIT_PATTERN = Pattern.compile(".*\\d.*");
); private static final Pattern SPECIAL_CHAR_PATTERN = Pattern.compile(".*[@$!%*?&].*");
/** /**
* 회원가입 처리 * 회원 등록
* *
* @param request 회원가입 요청 정보 * @param request 회원가입 요청 정보
*/ */
@Override @Override
@Transactional @Transactional
public void register(RegisterRequest request) { public void register(RegisterRequest request) {
// 중복 ID 확인 log.info("회원 등록 시작: {}", request.getUserId());
// 중복 확인
if (memberRepository.existsByUserId(request.getUserId())) { if (memberRepository.existsByUserId(request.getUserId())) {
throw new BusinessException(ErrorCode.DUPLICATE_MEMBER_ID); throw new BusinessException(ErrorCode.DUPLICATE_MEMBER_ID);
} }
// 이메일 중복 확인
if (memberRepository.existsByEmail(request.getEmail())) { if (memberRepository.existsByEmail(request.getEmail())) {
throw new BusinessException(ErrorCode.DUPLICATE_EMAIL); throw new BusinessException(ErrorCode.DUPLICATE_EMAIL);
} }
// 사업자 번호 중복 확인 if (request.getBusinessNumber() != null &&
if (memberRepository.existsByBusinessNumber(request.getBusinessNumber())) { memberRepository.existsByBusinessNumber(request.getBusinessNumber())) {
throw new BusinessException(ErrorCode.DUPLICATE_BUSINESS_NUMBER); throw new BusinessException(ErrorCode.DUPLICATE_BUSINESS_NUMBER);
} }
// 패스워드 암호화
String encodedPassword = passwordEncoder.encode(request.getPassword());
// 회원 엔티티 생성 저장 // 회원 엔티티 생성 저장
Member member = Member.builder() Member member = Member.builder()
.userId(request.getUserId()) .userId(request.getUserId())
.password(encodedPassword) .password(passwordEncoder.encode(request.getPassword()))
.name(request.getName()) .name(request.getName())
.businessNumber(request.getBusinessNumber()) .businessNumber(request.getBusinessNumber())
.email(request.getEmail()) .email(request.getEmail())
.build(); .build();
memberRepository.save(member); memberRepository.save(member);
log.info("회원 등록 완료: {}", request.getUserId());
} }
/** /**
* 사용자 ID 중복 확인 * 사용자 ID 중복 확인
* *
* @param userId 확인할 사용자 ID * @param userId 확인할 사용자 ID
* @return 중복 여부 (true: 중복, false: 사용 가능) * @return 중복 여부
*/ */
@Override @Override
public boolean checkDuplicate(String userId) { public boolean checkDuplicate(String userId) {
return memberRepository.existsByUserId(userId); return memberRepository.existsByUserId(userId);
} }
/**
* 이메일 중복 확인
*
* @param email 확인할 이메일
* @return 중복 여부
*/
@Override
public boolean checkEmailDuplicate(String email) {
return memberRepository.existsByEmail(email);
}
/**
* 사업자번호 중복 확인
*
* @param businessNumber 확인할 사업자번호
* @return 중복 여부
*/
@Override
public boolean checkBusinessNumberDuplicate(String businessNumber) {
if (businessNumber == null || businessNumber.trim().isEmpty()) {
return false;
}
return memberRepository.existsByBusinessNumber(businessNumber);
}
/** /**
* 패스워드 유효성 검증 * 패스워드 유효성 검증
* *
* @param password 검증할 패스워드 * @param password 검증할 패스워드
* @return 유효성 검증 결과 * @return 검증 결과
*/ */
@Override @Override
public ValidationResponse validatePassword(String password) { public ValidationResponse validatePassword(String password) {
List<String> errors = new ArrayList<>(); List<String> errors = new ArrayList<>();
boolean isValid = true;
// 길이 검증 (8자 이상) // 길이 검증
if (password.length() < 8) { if (password.length() < 8 || password.length() > 20) {
errors.add("패스워드는 8자 이상이어야 합니다."); errors.add("패스워드는 8-20자 사이여야 합니다");
isValid = false;
} }
// 패턴 검증 (영문, 숫자, 특수문자 포함) // 영문 포함 여부
if (!PASSWORD_PATTERN.matcher(password).matches()) { if (!LETTER_PATTERN.matcher(password).matches()) {
errors.add("패스워드는 영문, 숫자, 특수문자를 각각 최소 1개씩 포함해야 합니다."); errors.add("영문이 포함되어야 합니다");
isValid = false;
} }
String message = isValid ? "유효한 패스워드입니다." : "패스워드가 보안 규칙을 만족하지 않습니다."; // 숫자 포함 여부
if (!DIGIT_PATTERN.matcher(password).matches()) {
errors.add("숫자가 포함되어야 합니다");
}
return ValidationResponse.builder() // 특수문자 포함 여부
.isValid(isValid) if (!SPECIAL_CHAR_PATTERN.matcher(password).matches()) {
.message(message) errors.add("특수문자(@$!%*?&)가 포함되어야 합니다");
.errors(errors) }
.build();
if (errors.isEmpty()) {
return ValidationResponse.valid("사용 가능한 패스워드입니다.");
} else {
return ValidationResponse.invalid("패스워드 규칙을 확인해 주세요.", errors);
}
} }
} }

View File

@ -1,42 +1,33 @@
server: server:
port: ${SERVER_PORT:8081} port: ${MEMBER_PORT:8081}
servlet:
context-path: /
spring: spring:
application: application:
name: member-service name: member-service
datasource: datasource:
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:memberdb} url: ${DB_URL:jdbc:mysql://localhost:3306/smarketing_member}
username: ${POSTGRES_USER:postgres} username: ${DB_USERNAME:root}
password: ${POSTGRES_PASSWORD:postgres} password: ${DB_PASSWORD:password}
driver-class-name: com.mysql.cj.jdbc.Driver
jpa: jpa:
hibernate: hibernate:
ddl-auto: ${JPA_DDL_AUTO:update} ddl-auto: ${DDL_AUTO:update}
show-sql: ${JPA_SHOW_SQL:true} show-sql: ${SHOW_SQL:true}
properties: properties:
hibernate: hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect dialect: org.hibernate.dialect.MySQLDialect
format_sql: true format_sql: true
data: data:
redis: redis:
host: ${REDIS_HOST:localhost} host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379} port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:} password: ${REDIS_PASSWORD:}
timeout: 2000ms
jwt: jwt:
secret-key: ${JWT_SECRET_KEY:mySecretKeyForJWTTokenGenerationThatShouldBeVeryLongAndSecure} secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789}
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:900000} access-token-validity: ${JWT_ACCESS_VALIDITY:3600000}
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400000} refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000}
springdoc:
swagger-ui:
path: /swagger-ui.html
operations-sorter: method
api-docs:
path: /api-docs
logging: logging:
level: level:
com.won.smarketing.member: ${LOG_LEVEL:DEBUG} com.won.smarketing: ${LOG_LEVEL:DEBUG}

View File

@ -29,3 +29,4 @@ public class ErrorResponseDto {
@Schema(description = "요청 경로", example = "/api/recommendation/marketing-tips") @Schema(description = "요청 경로", example = "/api/recommendation/marketing-tips")
private String path; private String path;
} }

View File

@ -1,5 +1,4 @@
rootProject.name = 'smarketing' rootProject.name = 'smarketing'
include 'common' include 'common'
include 'member' include 'member'
include 'store' include 'store'

View File

@ -1,5 +1,6 @@
dependencies { dependencies {
implementation project(':common') implementation project(':common')
implementation 'com.mysql:mysql-connector-j'
} }
bootJar { bootJar {

View File

@ -0,0 +1,28 @@
package com.won.smarketing.store.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
/**
* JPA 설정 클래스
* JPA Auditing 기능 활성화
*/
@Configuration
@EnableJpaAuditing
public class JpaConfig {
}리는 50자 이하여야 합니다")
private String category;
@Schema(description = "가격", example = "4500", required = true)
@NotNull(message = "가격은 필수입니다")
@Min(value = 0, message = "가격은 0원 이상이어야 합니다")
private Integer price;
@Schema(description = "메뉴 설명", example = "진한 맛의 아메리카노")
@Size(max = 500, message = "메뉴 설명은 500자 이하여야 합니다")
private String description;
@Schema(description = "이미지 URL", example = "https://example.com/americano.jpg")
@Size(max = 500, message = "이미지 URL은 500자 이하여야 합니다")
private String image;
}

View File

@ -1,49 +1,49 @@
package com.won.smarketing.store.dto; package com.won.smarketing.store.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
/** /**
* 메뉴 등록 요청 DTO * 메뉴 등록 요청 DTO
* 메뉴 등록 필요한 정보를 담는 데이터 전송 객체 * 메뉴 등록 필요한 정보를 전달합니다.
*/ */
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Schema(description = "메뉴 등록 요청")
@Schema(description = "메뉴 등록 요청 정보")
public class MenuCreateRequest { public class MenuCreateRequest {
@Schema(description = "매장 ID", example = "1", required = true) @Schema(description = "매장 ID", example = "1", required = true)
@NotNull(message = "매장 ID는 필수입니다.") @NotNull(message = "매장 ID는 필수입니다")
private Long storeId; private Long storeId;
@Schema(description = "메뉴명", example = "아메리카노", required = true) @Schema(description = "메뉴명", example = "아메리카노", required = true)
@NotBlank(message = "메뉴명은 필수입니다.") @NotBlank(message = "메뉴명은 필수입니다")
@Size(max = 200, message = "메뉴명은 200자 이하여야 합니다.") @Size(max = 100, message = "메뉴명은 100자 이하여야 합니다")
private String menuName; private String menuName;
@Schema(description = "메뉴 카테고리", example = "커피", required = true) @Schema(description = "카테고리", example = "커피")
@NotBlank(message = "카테고리는 필수입니다.") @Size(max = 50, message = "카테고리는 50자 이하여야 합니다")
@Size(max = 100, message = "카테고리는 100자 이하여야 합니다.")
private String category; private String category;
@Schema(description = "가격", example = "4500", required = true) @Schema(description = "가격", example = "4500")
@NotNull(message = "가격은 필수입니다.") @Min(value = 0, message = "가격은 0원 이상이어야 합니다")
@Min(value = 0, message = "가격은 0 이상이어야 합니다.")
private Integer price; private Integer price;
@Schema(description = "메뉴 설명", example = "진한 원두의 깊은 맛") @Schema(description = "메뉴 설명", example = "진한 맛의 아메리카노")
@Size(max = 500, message = "메뉴 설명은 500자 이하여야 합니다")
private String description; private String description;
@Schema(description = "메뉴 이미지 URL", example = "https://example.com/americano.jpg") @Schema(description = "이미지 URL", example = "https://example.com/americano.jpg")
@Size(max = 500, message = "이미지 URL은 500자 이하여야 합니다")
private String image; private String image;
} }

View File

@ -9,37 +9,41 @@ import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** /**
* 메뉴 정보 응답 DTO * 메뉴 응답 DTO
* 메뉴 정보 조회/등록/수정 반환되는 데이터 * 메뉴 정보 클라이언트에게 전달합니다.
*/ */
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@Schema(description = "메뉴 정보 응답") @Schema(description = "메뉴 응답")
public class MenuResponse { public class MenuResponse {
@Schema(description = "메뉴 ID", example = "1") @Schema(description = "메뉴 ID", example = "1")
private Long menuId; private Long menuId;
@Schema(description = "매장 ID", example = "1")
private Long storeId;
@Schema(description = "메뉴명", example = "아메리카노") @Schema(description = "메뉴명", example = "아메리카노")
private String menuName; private String menuName;
@Schema(description = "메뉴 카테고리", example = "커피") @Schema(description = "카테고리", example = "커피")
private String category; private String category;
@Schema(description = "가격", example = "4500") @Schema(description = "가격", example = "4500")
private Integer price; private Integer price;
@Schema(description = "메뉴 설명", example = "진한 원두의 깊은 맛") @Schema(description = "메뉴 설명", example = "진한 맛의 아메리카노")
private String description; private String description;
@Schema(description = "메뉴 이미지 URL", example = "https://example.com/americano.jpg") @Schema(description = "이미지 URL", example = "https://example.com/americano.jpg")
private String image; private String image;
@Schema(description = "등록 시각") @Schema(description = "등록일시", example = "2024-01-15T10:30:00")
private LocalDateTime createdAt; private LocalDateTime createdAt;
@Schema(description = "수정 시각") @Schema(description = "수정일시", example = "2024-01-15T10:30:00")
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
} }

View File

@ -9,22 +9,28 @@ import lombok.NoArgsConstructor;
import java.math.BigDecimal; import java.math.BigDecimal;
/** /**
* 매출 정보 응답 DTO * 매출 응답 DTO
* 오늘 매출, 월간 매출, 전일 대비 매출 정보 * 매출 정보를 클라이언트에게 전달합니다.
*/ */
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@Schema(description = "매출 정보 응답") @Schema(description = "매출 응답")
public class SalesResponse { public class SalesResponse {
@Schema(description = "오늘 매출", example = "150000") @Schema(description = "오늘 매출", example = "150000")
private BigDecimal todaySales; private BigDecimal todaySales;
@Schema(description = "이번 달 매출", example = "3200000") @Schema(description = "월간 매출", example = "4500000")
private BigDecimal monthSales; private BigDecimal monthSales;
@Schema(description = "전일 대비 매출 변화", example = "25000") @Schema(description = "전일 대비 매출 변화", example = "25000")
private BigDecimal previousDayComparison; private BigDecimal previousDayComparison;
@Schema(description = "전일 대비 매출 변화율 (%)", example = "15.5")
private BigDecimal previousDayChangeRate;
@Schema(description = "목표 매출 대비 달성율 (%)", example = "85.2")
private BigDecimal goalAchievementRate;
} }

View File

@ -1,79 +1,58 @@
package com.won.smarketing.store.dto; package com.won.smarketing.store.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
/** /**
* 매장 등록 요청 DTO * 매장 등록 요청 DTO
* 매장 등록 필요한 정보를 담는 데이터 전송 객체 * 매장 등록 필요한 정보를 전달합니다.
*/ */
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Schema(description = "매장 등록 요청")
@Schema(description = "매장 등록 요청 정보")
public class StoreCreateRequest { public class StoreCreateRequest {
@Schema(description = "매장 소유자 사용자 ID", example = "testuser", required = true)
@NotBlank(message = "사용자 ID는 필수입니다.")
private String userId;
@Schema(description = "매장명", example = "맛있는 카페", required = true) @Schema(description = "매장명", example = "맛있는 카페", required = true)
@NotBlank(message = "매장명은 필수입니다.") @NotBlank(message = "매장명은 필수입니다")
@Size(max = 200, message = "매장명은 200자 이하여야 합니다.") @Size(max = 100, message = "매장명은 100자 이하여야 합니다")
private String storeName; private String storeName;
@Schema(description = "매장 이미지 URL", example = "https://example.com/store.jpg") @Schema(description = "업종", example = "카페")
private String storeImage; @Size(max = 50, message = "업종은 50자 이하여야 합니다")
@Schema(description = "업종", example = "카페", required = true)
@NotBlank(message = "업종은 필수입니다.")
@Size(max = 100, message = "업종은 100자 이하여야 합니다.")
private String businessType; private String businessType;
@Schema(description = "매장 주소", example = "서울시 강남구 테헤란로 123", required = true) @Schema(description = "주소", example = "서울시 강남구 테헤란로 123", required = true)
@NotBlank(message = "주소는 필수입니다.") @NotBlank(message = "주소는 필수입니다")
@Size(max = 500, message = "주소는 500자 이하여야 합니다.") @Size(max = 200, message = "주소는 200자 이하여야 합니다")
private String address; private String address;
@Schema(description = "매장 전화번호", example = "02-1234-5678", required = true) @Schema(description = "전화번호", example = "02-1234-5678")
@NotBlank(message = "전화번호는 필수입니다.") @Size(max = 20, message = "전화번호는 20자 이하여야 합니다")
@Pattern(regexp = "^\\d{2,3}-\\d{3,4}-\\d{4}$", message = "올바른 전화번호 형식이 아닙니다.")
private String phoneNumber; private String phoneNumber;
@Schema(description = "사업자 번호", example = "123-45-67890", required = true) @Schema(description = "영업시간", example = "09:00 - 22:00")
@NotBlank(message = "사업자 번호는 필수입니다.") @Size(max = 100, message = "영업시간은 100자 이하여야 합니다")
@Pattern(regexp = "^\\d{3}-\\d{2}-\\d{5}$", message = "사업자 번호 형식이 올바르지 않습니다.") private String businessHours;
private String businessNumber;
@Schema(description = "인스타그램 계정", example = "@mycafe") @Schema(description = "휴무일", example = "매주 일요일")
@Size(max = 100, message = "인스타그램 계정은 100자 이하여야 합니다.") @Size(max = 100, message = "휴무일은 100자 이하여야 합니다")
private String instaAccount;
@Schema(description = "네이버 블로그 계정", example = "mycafe_blog")
@Size(max = 100, message = "네이버 블로그 계정은 100자 이하여야 합니다.")
private String naverBlogAccount;
@Schema(description = "오픈 시간", example = "09:00")
@Pattern(regexp = "^\\d{2}:\\d{2}$", message = "올바른 시간 형식이 아닙니다. (HH:MM)")
private String openTime;
@Schema(description = "마감 시간", example = "22:00")
@Pattern(regexp = "^\\d{2}:\\d{2}$", message = "올바른 시간 형식이 아닙니다. (HH:MM)")
private String closeTime;
@Schema(description = "휴무일", example = "매주 월요일")
@Size(max = 100, message = "휴무일은 100자 이하여야 합니다.")
private String closedDays; private String closedDays;
@Schema(description = "좌석 수", example = "20") @Schema(description = "좌석 수", example = "20")
private Integer seatCount; private Integer seatCount;
@Schema(description = "SNS 계정 정보", example = "인스타그램: @mystore")
@Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다")
private String snsAccounts;
@Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.")
@Size(max = 1000, message = "매장 설명은 1000자 이하여야 합니다")
private String description;
} }

View File

@ -9,14 +9,14 @@ import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** /**
* 매장 정보 응답 DTO * 매장 응답 DTO
* 매장 정보 조회/등록/수정 반환되는 데이터 * 매장 정보 클라이언트에게 전달합니다.
*/ */
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@Schema(description = "매장 정보 응답") @Schema(description = "매장 응답")
public class StoreResponse { public class StoreResponse {
@Schema(description = "매장 ID", example = "1") @Schema(description = "매장 ID", example = "1")
@ -25,42 +25,34 @@ public class StoreResponse {
@Schema(description = "매장명", example = "맛있는 카페") @Schema(description = "매장명", example = "맛있는 카페")
private String storeName; private String storeName;
@Schema(description = "매장 이미지 URL", example = "https://example.com/store.jpg")
private String storeImage;
@Schema(description = "업종", example = "카페") @Schema(description = "업종", example = "카페")
private String businessType; private String businessType;
@Schema(description = "매장 주소", example = "서울시 강남구 테헤란로 123") @Schema(description = "주소", example = "서울시 강남구 테헤란로 123")
private String address; private String address;
@Schema(description = "매장 전화번호", example = "02-1234-5678") @Schema(description = "전화번호", example = "02-1234-5678")
private String phoneNumber; private String phoneNumber;
@Schema(description = "사업자 번호", example = "123-45-67890") @Schema(description = "영업시간", example = "09:00 - 22:00")
private String businessNumber; private String businessHours;
@Schema(description = "인스타그램 계정", example = "@mycafe") @Schema(description = "휴무일", example = "매주 일요일")
private String instaAccount;
@Schema(description = "네이버 블로그 계정", example = "mycafe_blog")
private String naverBlogAccount;
@Schema(description = "오픈 시간", example = "09:00")
private String openTime;
@Schema(description = "마감 시간", example = "22:00")
private String closeTime;
@Schema(description = "휴무일", example = "매주 월요일")
private String closedDays; private String closedDays;
@Schema(description = "좌석 수", example = "20") @Schema(description = "좌석 수", example = "20")
private Integer seatCount; private Integer seatCount;
@Schema(description = "등록 시각") @Schema(description = "SNS 계정 정보", example = "인스타그램: @mystore")
private String snsAccounts;
@Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.")
private String description;
@Schema(description = "등록일시", example = "2024-01-15T10:30:00")
private LocalDateTime createdAt; private LocalDateTime createdAt;
@Schema(description = "수정 시각") @Schema(description = "수정일시", example = "2024-01-15T10:30:00")
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
} }

View File

@ -1,60 +1,55 @@
package com.won.smarketing.store.dto; package com.won.smarketing.store.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
/** /**
* 매장 수정 요청 DTO * 매장 수정 요청 DTO
* 매장 정보 수정 필요한 정보를 담는 데이터 전송 객체 * 매장 정보 수정 필요한 정보를 전달합니다.
*/ */
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Schema(description = "매장 수정 요청")
@Schema(description = "매장 수정 요청 정보")
public class StoreUpdateRequest { public class StoreUpdateRequest {
@Schema(description = "매장명", example = "맛있는 카페") @Schema(description = "매장명", example = "맛있는 카페")
@Size(max = 200, message = "매장명은 200자 이하여야 합니다.") @Size(max = 100, message = "매장명은 100자 이하여야 합니다")
private String storeName; private String storeName;
@Schema(description = "매장 이미지 URL", example = "https://example.com/store.jpg") @Schema(description = "업종", example = "카페")
private String storeImage; @Size(max = 50, message = "업종은 50자 이하여야 합니다")
private String businessType;
@Schema(description = "매장 주소", example = "서울시 강남구 테헤란로 123") @Schema(description = "주소", example = "서울시 강남구 테헤란로 123")
@Size(max = 500, message = "주소는 500자 이하여야 합니다.") @Size(max = 200, message = "주소는 200자 이하여야 합니다")
private String address; private String address;
@Schema(description = "매장 전화번호", example = "02-1234-5678") @Schema(description = "전화번호", example = "02-1234-5678")
@Pattern(regexp = "^\\d{2,3}-\\d{3,4}-\\d{4}$", message = "올바른 전화번호 형식이 아닙니다.") @Size(max = 20, message = "전화번호는 20자 이하여야 합니다")
private String phoneNumber; private String phoneNumber;
@Schema(description = "인스타그램 계정", example = "@mycafe") @Schema(description = "영업시간", example = "09:00 - 22:00")
@Size(max = 100, message = "인스타그램 계정은 100자 이하여야 합니다.") @Size(max = 100, message = "영업시간은 100자 이하여야 합니다")
private String instaAccount; private String businessHours;
@Schema(description = "네이버 블로그 계정", example = "mycafe_blog") @Schema(description = "휴무일", example = "매주 일요일")
@Size(max = 100, message = "네이버 블로그 계정은 100자 이하여야 합니다.") @Size(max = 100, message = "휴무일은 100자 이하여야 합니다")
private String naverBlogAccount;
@Schema(description = "오픈 시간", example = "09:00")
@Pattern(regexp = "^\\d{2}:\\d{2}$", message = "올바른 시간 형식이 아닙니다. (HH:MM)")
private String openTime;
@Schema(description = "마감 시간", example = "22:00")
@Pattern(regexp = "^\\d{2}:\\d{2}$", message = "올바른 시간 형식이 아닙니다. (HH:MM)")
private String closeTime;
@Schema(description = "휴무일", example = "매주 월요일")
@Size(max = 100, message = "휴무일은 100자 이하여야 합니다.")
private String closedDays; private String closedDays;
@Schema(description = "좌석 수", example = "20") @Schema(description = "좌석 수", example = "20")
private Integer seatCount; private Integer seatCount;
@Schema(description = "SNS 계정 정보", example = "인스타그램: @mystore")
@Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다")
private String snsAccounts;
@Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.")
@Size(max = 1000, message = "매장 설명은 1000자 이하여야 합니다")
private String description;
} }

View File

@ -1,98 +1,62 @@
package com.won.smarketing.store.entity; package com.won.smarketing.store.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** /**
* 메뉴 정보를 나타내는 엔티티 * 메뉴 엔티티
* 메뉴명, 카테고리, 가격, 설명, 이미지 정보 저장 * 매장의 메뉴 정보를 관리
*/ */
@Entity @Entity
@Table(name = "menus") @Table(name = "menus")
@Getter @Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EntityListeners(AuditingEntityListener.class)
public class Menu { public class Menu {
/**
* 메뉴 고유 식별자
*/
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "menu_id")
private Long id; private Long id;
/**
* 매장 ID
*/
@Column(name = "store_id", nullable = false) @Column(name = "store_id", nullable = false)
private Long storeId; private Long storeId;
/** @Column(name = "menu_name", nullable = false, length = 100)
* 메뉴명
*/
@Column(name = "menu_name", nullable = false, length = 200)
private String menuName; private String menuName;
/** @Column(name = "category", length = 50)
* 메뉴 카테고리
*/
@Column(name = "category", nullable = false, length = 100)
private String category; private String category;
/**
* 가격
*/
@Column(name = "price", nullable = false) @Column(name = "price", nullable = false)
private Integer price; private Integer price;
/** @Column(name = "description", length = 500)
* 메뉴 설명
*/
@Column(name = "description", columnDefinition = "TEXT")
private String description; private String description;
/** @Column(name = "image_url", length = 500)
* 메뉴 이미지 URL
*/
@Column(name = "image", length = 500)
private String image; private String image;
/** @CreatedDate
* 메뉴 등록 시각
*/
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
/** @LastModifiedDate
* 메뉴 정보 수정 시각 @Column(name = "updated_at")
*/
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
/** /**
* 엔티티 저장 실행되는 메서드 * 메뉴 정보 업데이트
* 생성 시각과 수정 시각을 현재 시각으로 설정
*/
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
/**
* 엔티티 업데이트 실행되는 메서드
* 수정 시각을 현재 시각으로 갱신
*/
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
/**
* 메뉴 정보 업데이트 메서드
* *
* @param menuName 메뉴명 * @param menuName 메뉴명
* @param category 카테고리 * @param category 카테고리
@ -100,10 +64,17 @@ public class Menu {
* @param description 설명 * @param description 설명
* @param image 이미지 URL * @param image 이미지 URL
*/ */
public void updateMenuInfo(String menuName, String category, Integer price, String description, String image) { public void updateMenu(String menuName, String category, Integer price,
this.menuName = menuName; String description, String image) {
this.category = category; if (menuName != null && !menuName.trim().isEmpty()) {
this.price = price; this.menuName = menuName;
}
if (category != null && !category.trim().isEmpty()) {
this.category = category;
}
if (price != null && price > 0) {
this.price = price;
}
this.description = description; this.description = description;
this.image = image; this.image = image;
} }

View File

@ -59,3 +59,4 @@ public class Sales {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now();
} }
} }

View File

@ -1,164 +1,103 @@
package com.won.smarketing.store.entity; package com.won.smarketing.store.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.LocalTime;
/** /**
* 매장 정보를 나타내는 엔티티 * 매장 엔티티
* 매장의 기본 정보, 운영 정보, SNS 계정 정보 저장 * 매장의 기본 정보 운영 정보를 관리
*/ */
@Entity @Entity
@Table(name = "stores") @Table(name = "stores")
@Getter @Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EntityListeners(AuditingEntityListener.class)
public class Store { public class Store {
/**
* 매장 고유 식별자
*/
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "store_id")
private Long id; private Long id;
/** @Column(name = "member_id", nullable = false)
* 매장 소유자 사용자 ID private Long memberId;
*/
@Column(name = "user_id", unique = true, nullable = false, length = 50)
private String userId;
/** @Column(name = "store_name", nullable = false, length = 100)
* 매장명
*/
@Column(name = "store_name", nullable = false, length = 200)
private String storeName; private String storeName;
/** @Column(name = "business_type", length = 50)
* 매장 이미지 URL
*/
@Column(name = "store_image", length = 500)
private String storeImage;
/**
* 업종
*/
@Column(name = "business_type", nullable = false, length = 100)
private String businessType; private String businessType;
/** @Column(name = "address", nullable = false, length = 200)
* 매장 주소
*/
@Column(name = "address", nullable = false, length = 500)
private String address; private String address;
/** @Column(name = "phone_number", length = 20)
* 매장 전화번호
*/
@Column(name = "phone_number", nullable = false, length = 20)
private String phoneNumber; private String phoneNumber;
/** @Column(name = "business_hours", length = 100)
* 사업자 번호 private String businessHours;
*/
@Column(name = "business_number", nullable = false, length = 20)
private String businessNumber;
/**
* 인스타그램 계정
*/
@Column(name = "insta_account", length = 100)
private String instaAccount;
/**
* 네이버 블로그 계정
*/
@Column(name = "naver_blog_account", length = 100)
private String naverBlogAccount;
/**
* 오픈 시간
*/
@Column(name = "open_time", length = 10)
private String openTime;
/**
* 마감 시간
*/
@Column(name = "close_time", length = 10)
private String closeTime;
/**
* 휴무일
*/
@Column(name = "closed_days", length = 100) @Column(name = "closed_days", length = 100)
private String closedDays; private String closedDays;
/**
* 좌석
*/
@Column(name = "seat_count") @Column(name = "seat_count")
private Integer seatCount; private Integer seatCount;
/** @Column(name = "sns_accounts", length = 500)
* 매장 등록 시각 private String snsAccounts;
*/
@Column(name = "description", length = 1000)
private String description;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
/** @LastModifiedDate
* 매장 정보 수정 시각 @Column(name = "updated_at")
*/
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
/** /**
* 엔티티 저장 실행되는 메서드 * 매장 정보 업데이트
* 생성 시각과 수정 시각을 현재 시각으로 설정
*/
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
/**
* 엔티티 업데이트 실행되는 메서드
* 수정 시각을 현재 시각으로 갱신
*/
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
/**
* 매장 정보 업데이트 메서드
* *
* @param storeName 매장명 * @param storeName 매장명
* @param storeImage 매장 이미지 * @param businessType 업종
* @param address 주소 * @param address 주소
* @param phoneNumber 전화번호 * @param phoneNumber 전화번호
* @param instaAccount 인스타그램 계정 * @param businessHours 영업시간
* @param naverBlogAccount 네이버 블로그 계정
* @param openTime 오픈 시간
* @param closeTime 마감 시간
* @param closedDays 휴무일 * @param closedDays 휴무일
* @param seatCount 좌석 * @param seatCount 좌석
* @param snsAccounts SNS 계정 정보
* @param description 설명
*/ */
public void updateStoreInfo(String storeName, String storeImage, String address, String phoneNumber, public void updateStore(String storeName, String businessType, String address,
String instaAccount, String naverBlogAccount, String openTime, String closeTime, String phoneNumber, String businessHours, String closedDays,
String closedDays, Integer seatCount) { Integer seatCount, String snsAccounts, String description) {
this.storeName = storeName; if (storeName != null && !storeName.trim().isEmpty()) {
this.storeImage = storeImage; this.storeName = storeName;
this.address = address; }
if (businessType != null && !businessType.trim().isEmpty()) {
this.businessType = businessType;
}
if (address != null && !address.trim().isEmpty()) {
this.address = address;
}
this.phoneNumber = phoneNumber; this.phoneNumber = phoneNumber;
this.instaAccount = instaAccount; this.businessHours = businessHours;
this.naverBlogAccount = naverBlogAccount;
this.openTime = openTime;
this.closeTime = closeTime;
this.closedDays = closedDays; this.closedDays = closedDays;
this.seatCount = seatCount; this.seatCount = seatCount;
this.snsAccounts = snsAccounts;
this.description = description;
} }
} }

View File

@ -14,10 +14,29 @@ import java.util.Optional;
public interface StoreRepository extends JpaRepository<Store, Long> { public interface StoreRepository extends JpaRepository<Store, Long> {
/** /**
* 사용자 ID로 매장 조회 * 회원 ID로 매장 조회
* *
* @param userId 사용자 ID * @param memberId 회원 ID
* @return 매장 정보 * @return 매장 정보 (Optional)
*/ */
Optional<Store> findByUserId(String userId); Optional<Store> findByMemberId(Long memberId);
/**
* 회원의 매장 존재 여부 확인
*
* @param memberId 회원 ID
* @return 존재 여부
*/
boolean existsByMemberId(Long memberId);
/**
* 매장명으로 매장 조회
*
* @param storeName 매장명
* @return 매장 목록
*/
Optional<Store> findByStoreName(String storeName);
} }

View File

@ -7,13 +7,13 @@ import com.won.smarketing.store.dto.MenuUpdateRequest;
import java.util.List; import java.util.List;
/** /**
* 메뉴 관리 서비스 인터페이스 * 메뉴 서비스 인터페이스
* 메뉴 등록, 조회, 수정, 삭제 기능 정의 * 메뉴 관리 관련 비즈니스 로직 정의
*/ */
public interface MenuService { public interface MenuService {
/** /**
* 메뉴 정보 등록 * 메뉴 등록
* *
* @param request 메뉴 등록 요청 정보 * @param request 메뉴 등록 요청 정보
* @return 등록된 메뉴 정보 * @return 등록된 메뉴 정보
@ -31,7 +31,7 @@ public interface MenuService {
/** /**
* 메뉴 정보 수정 * 메뉴 정보 수정
* *
* @param menuId 수정할 메뉴 ID * @param menuId 메뉴 ID
* @param request 메뉴 수정 요청 정보 * @param request 메뉴 수정 요청 정보
* @return 수정된 메뉴 정보 * @return 수정된 메뉴 정보
*/ */
@ -40,7 +40,7 @@ public interface MenuService {
/** /**
* 메뉴 삭제 * 메뉴 삭제
* *
* @param menuId 삭제할 메뉴 ID * @param menuId 메뉴 ID
*/ */
void deleteMenu(Long menuId); void deleteMenu(Long menuId);
} }

View File

@ -3,15 +3,15 @@ package com.won.smarketing.store.service;
import com.won.smarketing.store.dto.SalesResponse; import com.won.smarketing.store.dto.SalesResponse;
/** /**
* 매출 관리 서비스 인터페이스 * 매출 서비스 인터페이스
* 매출 조회 기능 정의 * 매출 조회 관련 비즈니스 로직 정의
*/ */
public interface SalesService { public interface SalesService {
/** /**
* 매출 정보 조회 * 매출 정보 조회
* *
* @return 매출 정보 (오늘, 월간, 전일 대비) * @return 매출 정보
*/ */
SalesResponse getSales(); SalesResponse getSales();
} }

View File

@ -1,42 +1,60 @@
package com.won.smarketing.store.service; package com.won.smarketing.store.service;
import com.won.smarketing.store.dto.SalesResponse; import com.won.smarketing.store.dto.SalesResponse;
import com.won.smarketing.store.repository.SalesRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode;
/** /**
* 매출 관리 서비스 구현체 * 매출 서비스 구현체
* 매출 조회 기능 * 매출 조회 기능 구현 (재는 Mock 데이터)
*/ */
@Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Transactional(readOnly = true) @Transactional(readOnly = true)
public class SalesServiceImpl implements SalesService { public class SalesServiceImpl implements SalesService {
private final SalesRepository salesRepository;
/** /**
* 매출 정보 조회 * 매출 정보 조회
* 현재는 Mock 데이터를 반환 (실제로는 매출 데이터 조회 로직 필요)
* *
* @return 매출 정보 (오늘, 월간, 전일 대비) * @return 매출 정보
*/ */
@Override @Override
public SalesResponse getSales() { public SalesResponse getSales() {
// TODO: 현재는 더미 데이터 반환, 실제로는 현재 로그인한 사용자의 매장 ID를 사용해야 log.info("매출 정보 조회");
Long storeId = 1L; // 임시로 설정
BigDecimal todaySales = salesRepository.findTodaySalesByStoreId(storeId); // Mock 데이터 (실제로는 데이터베이스에서 조회)
BigDecimal monthSales = salesRepository.findMonthSalesByStoreId(storeId); BigDecimal todaySales = new BigDecimal("150000");
BigDecimal previousDayComparison = salesRepository.findPreviousDayComparisonByStoreId(storeId); BigDecimal monthSales = new BigDecimal("4500000");
BigDecimal yesterdaySales = new BigDecimal("125000");
BigDecimal targetSales = new BigDecimal("176000");
// 전일 대비 변화 계산
BigDecimal previousDayComparison = todaySales.subtract(yesterdaySales);
BigDecimal previousDayChangeRate = yesterdaySales.compareTo(BigDecimal.ZERO) > 0
? previousDayComparison.divide(yesterdaySales, 4, RoundingMode.HALF_UP)
.multiply(new BigDecimal("100"))
: BigDecimal.ZERO;
// 목표 대비 달성률 계산
BigDecimal goalAchievementRate = targetSales.compareTo(BigDecimal.ZERO) > 0
? todaySales.divide(targetSales, 4, RoundingMode.HALF_UP)
.multiply(new BigDecimal("100"))
: BigDecimal.ZERO;
return SalesResponse.builder() return SalesResponse.builder()
.todaySales(todaySales != null ? todaySales : BigDecimal.ZERO) .todaySales(todaySales)
.monthSales(monthSales != null ? monthSales : BigDecimal.ZERO) .monthSales(monthSales)
.previousDayComparison(previousDayComparison != null ? previousDayComparison : BigDecimal.ZERO) .previousDayComparison(previousDayComparison)
.previousDayChangeRate(previousDayChangeRate)
.goalAchievementRate(goalAchievementRate)
.build(); .build();
} }
} }

View File

@ -5,13 +5,13 @@ import com.won.smarketing.store.dto.StoreResponse;
import com.won.smarketing.store.dto.StoreUpdateRequest; import com.won.smarketing.store.dto.StoreUpdateRequest;
/** /**
* 매장 관리 서비스 인터페이스 * 매장 서비스 인터페이스
* 매장 등록, 조회, 수정 기능 정의 * 매장 관리 관련 비즈니스 로직 정의
*/ */
public interface StoreService { public interface StoreService {
/** /**
* 매장 정보 등록 * 매장 등록
* *
* @param request 매장 등록 요청 정보 * @param request 매장 등록 요청 정보
* @return 등록된 매장 정보 * @return 등록된 매장 정보
@ -19,9 +19,16 @@ public interface StoreService {
StoreResponse register(StoreCreateRequest request); StoreResponse register(StoreCreateRequest request);
/** /**
* 매장 정보 조회 * 매장 정보 조회 (현재 로그인 사용자)
* *
* @param storeId 조회할 매장 ID * @return 매장 정보
*/
StoreResponse getMyStore();
/**
* 매장 정보 조회 (매장 ID)
*
* @param storeId 매장 ID
* @return 매장 정보 * @return 매장 정보
*/ */
StoreResponse getStore(String storeId); StoreResponse getStore(String storeId);
@ -29,7 +36,7 @@ public interface StoreService {
/** /**
* 매장 정보 수정 * 매장 정보 수정
* *
* @param storeId 수정할 매장 ID * @param storeId 매장 ID
* @param request 매장 수정 요청 정보 * @param request 매장 수정 요청 정보
* @return 수정된 매장 정보 * @return 수정된 매장 정보
*/ */

View File

@ -8,13 +8,16 @@ import com.won.smarketing.store.dto.StoreUpdateRequest;
import com.won.smarketing.store.entity.Store; import com.won.smarketing.store.entity.Store;
import com.won.smarketing.store.repository.StoreRepository; import com.won.smarketing.store.repository.StoreRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
/** /**
* 매장 관리 서비스 구현체 * 매장 서비스 구현체
* 매장 등록, 조회, 수정 기능 구현 * 매장 등록, 조회, 수정 기능 구현
*/ */
@Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Transactional(readOnly = true) @Transactional(readOnly = true)
@ -23,7 +26,7 @@ public class StoreServiceImpl implements StoreService {
private final StoreRepository storeRepository; private final StoreRepository storeRepository;
/** /**
* 매장 정보 등록 * 매장 등록
* *
* @param request 매장 등록 요청 정보 * @param request 매장 등록 요청 정보
* @return 등록된 매장 정보 * @return 등록된 매장 정보
@ -31,50 +34,75 @@ public class StoreServiceImpl implements StoreService {
@Override @Override
@Transactional @Transactional
public StoreResponse register(StoreCreateRequest request) { public StoreResponse register(StoreCreateRequest request) {
// 사용자별 매장 중복 등록 확인 String currentUserId = getCurrentUserId();
if (storeRepository.findByUserId(request.getUserId()).isPresent()) { Long memberId = Long.valueOf(currentUserId); // 실제로는 Member ID 조회 필요
log.info("매장 등록 시작: {} (회원: {})", request.getStoreName(), memberId);
// 회원당 하나의 매장만 등록 가능
if (storeRepository.existsByMemberId(memberId)) {
throw new BusinessException(ErrorCode.STORE_ALREADY_EXISTS); throw new BusinessException(ErrorCode.STORE_ALREADY_EXISTS);
} }
// 매장 엔티티 생성 저장 // 매장 엔티티 생성 저장
Store store = Store.builder() Store store = Store.builder()
.userId(request.getUserId()) .memberId(memberId)
.storeName(request.getStoreName()) .storeName(request.getStoreName())
.storeImage(request.getStoreImage())
.businessType(request.getBusinessType()) .businessType(request.getBusinessType())
.address(request.getAddress()) .address(request.getAddress())
.phoneNumber(request.getPhoneNumber()) .phoneNumber(request.getPhoneNumber())
.businessNumber(request.getBusinessNumber()) .businessHours(request.getBusinessHours())
.instaAccount(request.getInstaAccount())
.naverBlogAccount(request.getNaverBlogAccount())
.openTime(request.getOpenTime())
.closeTime(request.getCloseTime())
.closedDays(request.getClosedDays()) .closedDays(request.getClosedDays())
.seatCount(request.getSeatCount()) .seatCount(request.getSeatCount())
.snsAccounts(request.getSnsAccounts())
.description(request.getDescription())
.build(); .build();
Store savedStore = storeRepository.save(store); Store savedStore = storeRepository.save(store);
log.info("매장 등록 완료: {} (ID: {})", savedStore.getStoreName(), savedStore.getId());
return toStoreResponse(savedStore); return toStoreResponse(savedStore);
} }
/** /**
* 매장 정보 조회 * 매장 정보 조회 (현재 로그인 사용자)
* *
* @param storeId 조회할 매장 ID
* @return 매장 정보 * @return 매장 정보
*/ */
@Override @Override
public StoreResponse getStore(String storeId) { public StoreResponse getMyStore() {
Store store = storeRepository.findByUserId(storeId) String currentUserId = getCurrentUserId();
Long memberId = Long.valueOf(currentUserId);
Store store = storeRepository.findByMemberId(memberId)
.orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND));
return toStoreResponse(store); return toStoreResponse(store);
} }
/**
* 매장 정보 조회 (매장 ID)
*
* @param storeId 매장 ID
* @return 매장 정보
*/
@Override
public StoreResponse getStore(String storeId) {
try {
Long id = Long.valueOf(storeId);
Store store = storeRepository.findById(id)
.orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND));
return toStoreResponse(store);
} catch (NumberFormatException e) {
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE);
}
}
/** /**
* 매장 정보 수정 * 매장 정보 수정
* *
* @param storeId 수정할 매장 ID * @param storeId 매장 ID
* @param request 매장 수정 요청 정보 * @param request 매장 수정 요청 정보
* @return 수정된 매장 정보 * @return 수정된 매장 정보
*/ */
@ -85,20 +113,21 @@ public class StoreServiceImpl implements StoreService {
.orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND));
// 매장 정보 업데이트 // 매장 정보 업데이트
store.updateStoreInfo( store.updateStore(
request.getStoreName(), request.getStoreName(),
request.getStoreImage(), request.getBusinessType(),
request.getAddress(), request.getAddress(),
request.getPhoneNumber(), request.getPhoneNumber(),
request.getInstaAccount(), request.getBusinessHours(),
request.getNaverBlogAccount(),
request.getOpenTime(),
request.getCloseTime(),
request.getClosedDays(), request.getClosedDays(),
request.getSeatCount() request.getSeatCount(),
request.getSnsAccounts(),
request.getDescription()
); );
Store updatedStore = storeRepository.save(store); Store updatedStore = storeRepository.save(store);
log.info("매장 정보 수정 완료: {} (ID: {})", updatedStore.getStoreName(), updatedStore.getId());
return toStoreResponse(updatedStore); return toStoreResponse(updatedStore);
} }
@ -112,19 +141,25 @@ public class StoreServiceImpl implements StoreService {
return StoreResponse.builder() return StoreResponse.builder()
.storeId(store.getId()) .storeId(store.getId())
.storeName(store.getStoreName()) .storeName(store.getStoreName())
.storeImage(store.getStoreImage())
.businessType(store.getBusinessType()) .businessType(store.getBusinessType())
.address(store.getAddress()) .address(store.getAddress())
.phoneNumber(store.getPhoneNumber()) .phoneNumber(store.getPhoneNumber())
.businessNumber(store.getBusinessNumber()) .businessHours(store.getBusinessHours())
.instaAccount(store.getInstaAccount())
.naverBlogAccount(store.getNaverBlogAccount())
.openTime(store.getOpenTime())
.closeTime(store.getCloseTime())
.closedDays(store.getClosedDays()) .closedDays(store.getClosedDays())
.seatCount(store.getSeatCount()) .seatCount(store.getSeatCount())
.snsAccounts(store.getSnsAccounts())
.description(store.getDescription())
.createdAt(store.getCreatedAt()) .createdAt(store.getCreatedAt())
.updatedAt(store.getUpdatedAt()) .updatedAt(store.getUpdatedAt())
.build(); .build();
} }
/**
* 현재 로그인된 사용자 ID 조회
*
* @return 사용자 ID
*/
private String getCurrentUserId() {
return SecurityContextHolder.getContext().getAuthentication().getName();
}
} }