mirror of
https://github.com/ktds-dg0501/kt-event-marketing.git
synced 2025-12-06 08:06:25 +00:00
commit
446e4c613d
@ -29,6 +29,8 @@ primary:
|
|||||||
|
|
||||||
# 성능 최적화 설정
|
# 성능 최적화 설정
|
||||||
extraEnvVars:
|
extraEnvVars:
|
||||||
|
- name: POSTGRESQL_READ_ONLY_MODE
|
||||||
|
value: "no"
|
||||||
- name: POSTGRESQL_SHARED_BUFFERS
|
- name: POSTGRESQL_SHARED_BUFFERS
|
||||||
value: "1GB"
|
value: "1GB"
|
||||||
- name: POSTGRESQL_EFFECTIVE_CACHE_SIZE
|
- name: POSTGRESQL_EFFECTIVE_CACHE_SIZE
|
||||||
|
|||||||
@ -51,7 +51,7 @@ paths:
|
|||||||
- JWT 토큰 자동 발급
|
- JWT 토큰 자동 발급
|
||||||
|
|
||||||
**처리 흐름:**
|
**처리 흐름:**
|
||||||
1. 중복 사용자 확인 (전화번호 기반)
|
1. 중복 사용자 확인 (이메일/전화번호 기반)
|
||||||
2. 비밀번호 해싱 (bcrypt)
|
2. 비밀번호 해싱 (bcrypt)
|
||||||
3. User/Store 데이터베이스 트랜잭션 처리
|
3. User/Store 데이터베이스 트랜잭션 처리
|
||||||
4. JWT 토큰 생성 및 세션 저장 (Redis)
|
4. JWT 토큰 생성 및 세션 저장 (Redis)
|
||||||
@ -114,7 +114,7 @@ paths:
|
|||||||
summary: 중복 사용자
|
summary: 중복 사용자
|
||||||
value:
|
value:
|
||||||
code: USER_001
|
code: USER_001
|
||||||
message: 이미 가입된 전화번호입니다
|
message: 이미 가입된 이메일입니다
|
||||||
timestamp: 2025-10-22T10:30:00Z
|
timestamp: 2025-10-22T10:30:00Z
|
||||||
validationError:
|
validationError:
|
||||||
summary: 입력 검증 오류
|
summary: 입력 검증 오류
|
||||||
@ -140,7 +140,7 @@ paths:
|
|||||||
**유저스토리:** UFR-USER-020
|
**유저스토리:** UFR-USER-020
|
||||||
|
|
||||||
**주요 기능:**
|
**주요 기능:**
|
||||||
- 전화번호/비밀번호 인증
|
- 이메일/비밀번호 인증
|
||||||
- JWT 토큰 발급
|
- JWT 토큰 발급
|
||||||
- Redis 세션 저장
|
- Redis 세션 저장
|
||||||
- 최종 로그인 시각 업데이트 (비동기)
|
- 최종 로그인 시각 업데이트 (비동기)
|
||||||
@ -162,7 +162,7 @@ paths:
|
|||||||
default:
|
default:
|
||||||
summary: 로그인 요청 예시
|
summary: 로그인 요청 예시
|
||||||
value:
|
value:
|
||||||
phoneNumber: "01012345678"
|
email: hong@example.com
|
||||||
password: "Password123!"
|
password: "Password123!"
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
@ -191,7 +191,7 @@ paths:
|
|||||||
summary: 인증 실패
|
summary: 인증 실패
|
||||||
value:
|
value:
|
||||||
code: AUTH_001
|
code: AUTH_001
|
||||||
message: 전화번호 또는 비밀번호를 확인해주세요
|
message: 이메일 또는 비밀번호를 확인해주세요
|
||||||
timestamp: 2025-10-22T10:30:00Z
|
timestamp: 2025-10-22T10:30:00Z
|
||||||
|
|
||||||
/users/logout:
|
/users/logout:
|
||||||
@ -679,14 +679,15 @@ components:
|
|||||||
LoginRequest:
|
LoginRequest:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- phoneNumber
|
- email
|
||||||
- password
|
- password
|
||||||
properties:
|
properties:
|
||||||
phoneNumber:
|
email:
|
||||||
type: string
|
type: string
|
||||||
pattern: '^010\d{8}$'
|
format: email
|
||||||
description: 휴대폰 번호
|
maxLength: 100
|
||||||
example: "01012345678"
|
description: 이메일 주소
|
||||||
|
example: hong@example.com
|
||||||
password:
|
password:
|
||||||
type: string
|
type: string
|
||||||
minLength: 8
|
minLength: 8
|
||||||
@ -977,7 +978,7 @@ components:
|
|||||||
message:
|
message:
|
||||||
type: string
|
type: string
|
||||||
description: 에러 메시지
|
description: 에러 메시지
|
||||||
example: 이미 가입된 전화번호입니다
|
example: 이미 가입된 이메일입니다
|
||||||
timestamp:
|
timestamp:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
|||||||
87
user-service/.run/user-service.run.xml
Normal file
87
user-service/.run/user-service.run.xml
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="user-service" type="GradleRunConfiguration" factoryName="Gradle">
|
||||||
|
<ExternalSystemSettings>
|
||||||
|
<option name="env">
|
||||||
|
<map>
|
||||||
|
<!-- Server Configuration -->
|
||||||
|
<entry key="SERVER_PORT" value="8081" />
|
||||||
|
|
||||||
|
<!-- Database Configuration -->
|
||||||
|
<entry key="DB_URL" value="jdbc:postgresql://20.249.125.115:5432/userdb" />
|
||||||
|
<entry key="DB_DRIVER" value="org.postgresql.Driver" />
|
||||||
|
<entry key="DB_HOST" value="20.249.125.115" />
|
||||||
|
<entry key="DB_PORT" value="5432" />
|
||||||
|
<entry key="DB_NAME" value="userdb" />
|
||||||
|
<entry key="DB_USERNAME" value="eventuser" />
|
||||||
|
<entry key="DB_PASSWORD" value="Hi5Jessica!" />
|
||||||
|
<entry key="DB_KIND" value="postgresql" />
|
||||||
|
|
||||||
|
<!-- JPA Configuration -->
|
||||||
|
<entry key="DDL_AUTO" value="update" />
|
||||||
|
<entry key="SHOW_SQL" value="true" />
|
||||||
|
<entry key="JPA_DIALECT" value="org.hibernate.dialect.PostgreSQLDialect" />
|
||||||
|
|
||||||
|
<!-- H2 Console (disabled for production DB) -->
|
||||||
|
<entry key="H2_CONSOLE_ENABLED" value="false" />
|
||||||
|
|
||||||
|
<!-- Redis Configuration -->
|
||||||
|
<entry key="REDIS_ENABLED" value="true" />
|
||||||
|
<entry key="REDIS_HOST" value="20.214.210.71" />
|
||||||
|
<entry key="REDIS_PORT" value="6379" />
|
||||||
|
<entry key="REDIS_PASSWORD" value="Hi5Jessica!" />
|
||||||
|
<entry key="REDIS_DATABASE" value="0" />
|
||||||
|
<entry key="EXCLUDE_REDIS" value="" />
|
||||||
|
|
||||||
|
<!-- Kafka Configuration -->
|
||||||
|
<entry key="KAFKA_BOOTSTRAP_SERVERS" value="4.230.50.63:9092" />
|
||||||
|
<entry key="KAFKA_CONSUMER_GROUP" value="user-service-consumers" />
|
||||||
|
<entry key="EXCLUDE_KAFKA" value="" />
|
||||||
|
|
||||||
|
<!-- JWT Configuration -->
|
||||||
|
<entry key="JWT_SECRET" value="kt-event-marketing-secret-key-for-development-only-please-change-in-production" />
|
||||||
|
<entry key="JWT_ACCESS_TOKEN_VALIDITY" value="604800000" />
|
||||||
|
|
||||||
|
<!-- CORS Configuration -->
|
||||||
|
<entry key="CORS_ALLOWED_ORIGINS" value="http://localhost:*" />
|
||||||
|
|
||||||
|
<!-- Logging Configuration -->
|
||||||
|
<entry key="LOG_LEVEL_APP" value="DEBUG" />
|
||||||
|
<entry key="LOG_LEVEL_WEB" value="INFO" />
|
||||||
|
<entry key="LOG_LEVEL_SQL" value="DEBUG" />
|
||||||
|
<entry key="LOG_LEVEL_SQL_TYPE" value="TRACE" />
|
||||||
|
<entry key="LOG_FILE_PATH" value="logs/user-service.log" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
<option name="executionName" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="externalSystemIdString" value="GRADLE" />
|
||||||
|
<option name="scriptParameters" value="" />
|
||||||
|
<option name="taskDescriptions">
|
||||||
|
<list />
|
||||||
|
</option>
|
||||||
|
<option name="taskNames">
|
||||||
|
<list>
|
||||||
|
<option value="user-service:bootRun" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="vmOptions" />
|
||||||
|
</ExternalSystemSettings>
|
||||||
|
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
|
||||||
|
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
|
||||||
|
<EXTENSION ID="com.intellij.execution.ExternalSystemRunConfigurationJavaExtension">
|
||||||
|
<extension name="net.ashald.envfile">
|
||||||
|
<option name="IS_ENABLED" value="false" />
|
||||||
|
<option name="IS_SUBST" value="false" />
|
||||||
|
<option name="IS_PATH_MACRO_SUPPORTED" value="false" />
|
||||||
|
<option name="IS_IGNORE_MISSING_FILES" value="false" />
|
||||||
|
<option name="IS_ENABLE_EXPERIMENTAL_INTEGRATIONS" value="false" />
|
||||||
|
<ENTRIES>
|
||||||
|
<ENTRY IS_ENABLED="true" PARSER="runconfig" IS_EXECUTABLE="false" />
|
||||||
|
</ENTRIES>
|
||||||
|
</extension>
|
||||||
|
</EXTENSION>
|
||||||
|
<DebugAllEnabled>false</DebugAllEnabled>
|
||||||
|
<RunAsTest>false</RunAsTest>
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@ -7,4 +7,10 @@ dependencies {
|
|||||||
|
|
||||||
// OpenFeign for external API calls (사업자번호 검증)
|
// OpenFeign for external API calls (사업자번호 검증)
|
||||||
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
|
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
|
||||||
|
|
||||||
|
// H2 Database for development
|
||||||
|
runtimeOnly 'com.h2database:h2'
|
||||||
|
|
||||||
|
// PostgreSQL Database for production
|
||||||
|
runtimeOnly 'org.postgresql:postgresql'
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
package com.kt.event.user;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
||||||
|
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User Service Application
|
||||||
|
*
|
||||||
|
* KT AI 기반 소상공인 이벤트 자동 생성 서비스 - User Service
|
||||||
|
*
|
||||||
|
* @author Backend Developer
|
||||||
|
* @since 1.0
|
||||||
|
*/
|
||||||
|
@SpringBootApplication(scanBasePackages = {
|
||||||
|
"com.kt.event.user",
|
||||||
|
"com.kt.event.common"
|
||||||
|
})
|
||||||
|
@EntityScan(basePackages = {
|
||||||
|
"com.kt.event.user.entity",
|
||||||
|
"com.kt.event.common.entity"
|
||||||
|
})
|
||||||
|
@EnableJpaAuditing
|
||||||
|
public class UserServiceApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(UserServiceApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
package com.kt.event.user.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비동기 처리 설정
|
||||||
|
*
|
||||||
|
* @Async 어노테이션 활성화 및 스레드 풀 설정
|
||||||
|
*
|
||||||
|
* @author Backend Developer
|
||||||
|
* @since 1.0
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableAsync
|
||||||
|
public class AsyncConfig {
|
||||||
|
|
||||||
|
@Bean(name = "taskExecutor")
|
||||||
|
public Executor taskExecutor() {
|
||||||
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
|
executor.setCorePoolSize(2);
|
||||||
|
executor.setMaxPoolSize(10);
|
||||||
|
executor.setQueueCapacity(500);
|
||||||
|
executor.setThreadNamePrefix("async-");
|
||||||
|
executor.initialize();
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
package com.kt.event.user.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
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.RedisStandaloneConfiguration;
|
||||||
|
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 설정
|
||||||
|
*
|
||||||
|
* Redis 연결 및 템플릿 설정
|
||||||
|
*
|
||||||
|
* @author Backend Developer
|
||||||
|
* @since 1.0
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@ConditionalOnProperty(name = "spring.data.redis.enabled", havingValue = "true", matchIfMissing = false)
|
||||||
|
public class RedisConfig {
|
||||||
|
|
||||||
|
@Value("${spring.data.redis.host}")
|
||||||
|
private String host;
|
||||||
|
|
||||||
|
@Value("${spring.data.redis.port}")
|
||||||
|
private int port;
|
||||||
|
|
||||||
|
@Value("${spring.data.redis.password:}")
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@Value("${spring.data.redis.database:0}")
|
||||||
|
private int database;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RedisConnectionFactory redisConnectionFactory() {
|
||||||
|
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
|
||||||
|
config.setHostName(host);
|
||||||
|
config.setPort(port);
|
||||||
|
config.setDatabase(database);
|
||||||
|
if (password != null && !password.isEmpty()) {
|
||||||
|
config.setPassword(password);
|
||||||
|
}
|
||||||
|
return new LettuceConnectionFactory(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RedisTemplate<String, Object> redisTemplate() {
|
||||||
|
RedisTemplate<String, Object> template = new RedisTemplate<>();
|
||||||
|
template.setConnectionFactory(redisConnectionFactory());
|
||||||
|
template.setKeySerializer(new StringRedisSerializer());
|
||||||
|
template.setValueSerializer(new StringRedisSerializer());
|
||||||
|
template.setHashKeySerializer(new StringRedisSerializer());
|
||||||
|
template.setHashValueSerializer(new StringRedisSerializer());
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
package com.kt.event.user.config;
|
||||||
|
|
||||||
|
import com.kt.event.common.security.JwtAuthenticationFilter;
|
||||||
|
import com.kt.event.common.security.JwtTokenProvider;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Security 설정
|
||||||
|
*
|
||||||
|
* JWT 기반 인증 및 API 보안 설정
|
||||||
|
*
|
||||||
|
* @author Backend Developer
|
||||||
|
* @since 1.0
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
private final JwtTokenProvider jwtTokenProvider;
|
||||||
|
|
||||||
|
@Value("${cors.allowed-origins:http://localhost:*}")
|
||||||
|
private String allowedOrigins;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
|
return http
|
||||||
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||||
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
// Public endpoints
|
||||||
|
.requestMatchers("/api/v1/users/register", "/api/v1/users/login").permitAll()
|
||||||
|
// Actuator endpoints
|
||||||
|
.requestMatchers("/actuator/**").permitAll()
|
||||||
|
// Swagger UI endpoints
|
||||||
|
.requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**").permitAll()
|
||||||
|
// Health check
|
||||||
|
.requestMatchers("/health").permitAll()
|
||||||
|
// All other requests require authentication
|
||||||
|
.anyRequest().authenticated()
|
||||||
|
)
|
||||||
|
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
|
||||||
|
UsernamePasswordAuthenticationFilter.class)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
|
|
||||||
|
// 환경변수에서 허용할 Origin 패턴 설정
|
||||||
|
String[] origins = allowedOrigins.split(",");
|
||||||
|
configuration.setAllowedOriginPatterns(Arrays.asList(origins));
|
||||||
|
|
||||||
|
// 허용할 HTTP 메소드
|
||||||
|
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
|
||||||
|
|
||||||
|
// 허용할 헤더
|
||||||
|
configuration.setAllowedHeaders(Arrays.asList(
|
||||||
|
"Authorization", "Content-Type", "X-Requested-With", "Accept",
|
||||||
|
"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"
|
||||||
|
));
|
||||||
|
|
||||||
|
// 자격 증명 허용
|
||||||
|
configuration.setAllowCredentials(true);
|
||||||
|
|
||||||
|
// Pre-flight 요청 캐시 시간
|
||||||
|
configuration.setMaxAge(3600L);
|
||||||
|
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
source.registerCorsConfiguration("/**", configuration);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public PasswordEncoder passwordEncoder() {
|
||||||
|
return new BCryptPasswordEncoder();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
package com.kt.event.user.config;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.models.Components;
|
||||||
|
import io.swagger.v3.oas.models.OpenAPI;
|
||||||
|
import io.swagger.v3.oas.models.info.Contact;
|
||||||
|
import io.swagger.v3.oas.models.info.Info;
|
||||||
|
import io.swagger.v3.oas.models.security.SecurityRequirement;
|
||||||
|
import io.swagger.v3.oas.models.security.SecurityScheme;
|
||||||
|
import io.swagger.v3.oas.models.servers.Server;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swagger/OpenAPI 설정
|
||||||
|
*
|
||||||
|
* User Service API 문서화를 위한 설정
|
||||||
|
*
|
||||||
|
* @author Backend Developer
|
||||||
|
* @since 1.0
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class SwaggerConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public OpenAPI openAPI() {
|
||||||
|
return new OpenAPI()
|
||||||
|
.info(apiInfo())
|
||||||
|
.addServersItem(new Server()
|
||||||
|
.url("http://localhost:8081")
|
||||||
|
.description("Local Development"))
|
||||||
|
.addServersItem(new Server()
|
||||||
|
.url("{protocol}://{host}:{port}")
|
||||||
|
.description("Custom Server")
|
||||||
|
.variables(new io.swagger.v3.oas.models.servers.ServerVariables()
|
||||||
|
.addServerVariable("protocol", new io.swagger.v3.oas.models.servers.ServerVariable()
|
||||||
|
._default("http")
|
||||||
|
.description("Protocol (http or https)")
|
||||||
|
.addEnumItem("http")
|
||||||
|
.addEnumItem("https"))
|
||||||
|
.addServerVariable("host", new io.swagger.v3.oas.models.servers.ServerVariable()
|
||||||
|
._default("localhost")
|
||||||
|
.description("Server host"))
|
||||||
|
.addServerVariable("port", new io.swagger.v3.oas.models.servers.ServerVariable()
|
||||||
|
._default("8081")
|
||||||
|
.description("Server port"))))
|
||||||
|
.addSecurityItem(new SecurityRequirement().addList("Bearer Authentication"))
|
||||||
|
.components(new Components()
|
||||||
|
.addSecuritySchemes("Bearer Authentication", createAPIKeyScheme()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Info apiInfo() {
|
||||||
|
return new Info()
|
||||||
|
.title("User Service API")
|
||||||
|
.description("KT AI 기반 소상공인 이벤트 자동 생성 서비스 - User Service API")
|
||||||
|
.version("1.0.0")
|
||||||
|
.contact(new Contact()
|
||||||
|
.name("Digital Garage Team")
|
||||||
|
.email("support@kt-event-marketing.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private SecurityScheme createAPIKeyScheme() {
|
||||||
|
return new SecurityScheme()
|
||||||
|
.type(SecurityScheme.Type.HTTP)
|
||||||
|
.bearerFormat("JWT")
|
||||||
|
.scheme("bearer");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,132 @@
|
|||||||
|
package com.kt.event.user.controller;
|
||||||
|
|
||||||
|
import com.kt.event.common.security.UserPrincipal;
|
||||||
|
import com.kt.event.user.dto.request.ChangePasswordRequest;
|
||||||
|
import com.kt.event.user.dto.request.LoginRequest;
|
||||||
|
import com.kt.event.user.dto.request.RegisterRequest;
|
||||||
|
import com.kt.event.user.dto.request.UpdateProfileRequest;
|
||||||
|
import com.kt.event.user.dto.response.LoginResponse;
|
||||||
|
import com.kt.event.user.dto.response.LogoutResponse;
|
||||||
|
import com.kt.event.user.dto.response.ProfileResponse;
|
||||||
|
import com.kt.event.user.dto.response.RegisterResponse;
|
||||||
|
import com.kt.event.user.service.AuthenticationService;
|
||||||
|
import com.kt.event.user.service.UserService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User Controller
|
||||||
|
*
|
||||||
|
* 사용자 인증 및 프로필 관리 API
|
||||||
|
*
|
||||||
|
* @author Backend Developer
|
||||||
|
* @since 1.0
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/users")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "User", description = "사용자 인증 및 프로필 관리 API")
|
||||||
|
public class UserController {
|
||||||
|
|
||||||
|
private final UserService userService;
|
||||||
|
private final AuthenticationService authenticationService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원가입
|
||||||
|
*
|
||||||
|
* UFR-USER-010: 회원가입
|
||||||
|
*/
|
||||||
|
@PostMapping("/register")
|
||||||
|
@Operation(summary = "회원가입", description = "소상공인 회원가입 API")
|
||||||
|
public ResponseEntity<RegisterResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||||
|
log.info("회원가입 요청: phoneNumber={}, email={}", request.getPhoneNumber(), request.getEmail());
|
||||||
|
RegisterResponse response = userService.register(request);
|
||||||
|
log.info("회원가입 성공: userId={}", response.getUserId());
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인
|
||||||
|
*
|
||||||
|
* UFR-USER-020: 로그인
|
||||||
|
*/
|
||||||
|
@PostMapping("/login")
|
||||||
|
@Operation(summary = "로그인", description = "소상공인 로그인 API")
|
||||||
|
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
|
||||||
|
log.info("로그인 요청: email={}", request.getEmail());
|
||||||
|
LoginResponse response = authenticationService.login(request);
|
||||||
|
log.info("로그인 성공: userId={}", response.getUserId());
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그아웃
|
||||||
|
*
|
||||||
|
* UFR-USER-040: 로그아웃
|
||||||
|
*/
|
||||||
|
@PostMapping("/logout")
|
||||||
|
@Operation(summary = "로그아웃", description = "로그아웃 API")
|
||||||
|
public ResponseEntity<LogoutResponse> logout(@RequestHeader("Authorization") String authHeader) {
|
||||||
|
String token = authHeader.substring(7); // "Bearer " 제거
|
||||||
|
log.info("로그아웃 요청");
|
||||||
|
LogoutResponse response = authenticationService.logout(token);
|
||||||
|
log.info("로그아웃 성공");
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로필 조회
|
||||||
|
*
|
||||||
|
* UFR-USER-030: 프로필 관리
|
||||||
|
*/
|
||||||
|
@GetMapping("/profile")
|
||||||
|
@Operation(summary = "프로필 조회", description = "사용자 프로필 조회 API")
|
||||||
|
public ResponseEntity<ProfileResponse> getProfile(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
|
Long userId = principal.getUserId();
|
||||||
|
log.info("프로필 조회 요청: userId={}", userId);
|
||||||
|
ProfileResponse response = userService.getProfile(userId);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로필 수정
|
||||||
|
*
|
||||||
|
* UFR-USER-030: 프로필 관리
|
||||||
|
*/
|
||||||
|
@PutMapping("/profile")
|
||||||
|
@Operation(summary = "프로필 수정", description = "사용자 프로필 수정 API")
|
||||||
|
public ResponseEntity<ProfileResponse> updateProfile(
|
||||||
|
@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@Valid @RequestBody UpdateProfileRequest request) {
|
||||||
|
Long userId = principal.getUserId();
|
||||||
|
log.info("프로필 수정 요청: userId={}", userId);
|
||||||
|
ProfileResponse response = userService.updateProfile(userId, request);
|
||||||
|
log.info("프로필 수정 성공: userId={}", userId);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 변경
|
||||||
|
*
|
||||||
|
* UFR-USER-030: 프로필 관리
|
||||||
|
*/
|
||||||
|
@PutMapping("/password")
|
||||||
|
@Operation(summary = "비밀번호 변경", description = "비밀번호 변경 API")
|
||||||
|
public ResponseEntity<Void> changePassword(
|
||||||
|
@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@Valid @RequestBody ChangePasswordRequest request) {
|
||||||
|
Long userId = principal.getUserId();
|
||||||
|
log.info("비밀번호 변경 요청: userId={}", userId);
|
||||||
|
userService.changePassword(userId, request);
|
||||||
|
log.info("비밀번호 변경 성공: userId={}", userId);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
package com.kt.event.user.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 변경 요청 DTO
|
||||||
|
*
|
||||||
|
* UFR-USER-030: 프로필 관리
|
||||||
|
*
|
||||||
|
* @author Backend Developer
|
||||||
|
* @since 1.0
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class ChangePasswordRequest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 비밀번호
|
||||||
|
*/
|
||||||
|
@NotBlank(message = "현재 비밀번호는 필수입니다")
|
||||||
|
private String currentPassword;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새 비밀번호 (8자 이상)
|
||||||
|
*/
|
||||||
|
@NotBlank(message = "새 비밀번호는 필수입니다")
|
||||||
|
@Size(min = 8, max = 100, message = "새 비밀번호는 8자 이상 100자 이하여야 합니다")
|
||||||
|
private String newPassword;
|
||||||
|
}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
package com.kt.event.user.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인 요청 DTO
|
||||||
|
*
|
||||||
|
* UFR-USER-020: 로그인
|
||||||
|
*
|
||||||
|
* @author Backend Developer
|
||||||
|
* @since 1.0
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class LoginRequest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이메일 주소
|
||||||
|
*/
|
||||||
|
@NotBlank(message = "이메일은 필수입니다")
|
||||||
|
@Email(message = "이메일 형식이 올바르지 않습니다")
|
||||||
|
@Size(max = 100, message = "이메일은 100자를 초과할 수 없습니다")
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호
|
||||||
|
*/
|
||||||
|
@NotBlank(message = "비밀번호는 필수입니다")
|
||||||
|
private String password;
|
||||||
|
}
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
package com.kt.event.user.dto.request;
|
||||||
|
|
||||||
|
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.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원가입 요청 DTO
|
||||||
|
*
|
||||||
|
* UFR-USER-010: 회원가입
|
||||||
|
*
|
||||||
|
* @author Backend Developer
|
||||||
|
* @since 1.0
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class RegisterRequest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 이름 (2자 이상)
|
||||||
|
*/
|
||||||
|
@NotBlank(message = "이름은 필수입니다")
|
||||||
|
@Size(min = 2, max = 50, message = "이름은 2자 이상 50자 이하여야 합니다")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전화번호 (010XXXXXXXX 형식)
|
||||||
|
*/
|
||||||
|
@NotBlank(message = "전화번호는 필수입니다")
|
||||||
|
@Pattern(regexp = "^010\\d{8}$", message = "전화번호는 010XXXXXXXX 형식이어야 합니다")
|
||||||
|
private String phoneNumber;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이메일 주소
|
||||||
|
*/
|
||||||
|
@NotBlank(message = "이메일은 필수입니다")
|
||||||
|
@Email(message = "이메일 형식이 올바르지 않습니다")
|
||||||
|
@Size(max = 100, message = "이메일은 100자 이하여야 합니다")
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 (8자 이상)
|
||||||
|
*/
|
||||||
|
@NotBlank(message = "비밀번호는 필수입니다")
|
||||||
|
@Size(min = 8, max = 100, message = "비밀번호는 8자 이상 100자 이하여야 합니다")
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매장명
|
||||||
|
*/
|
||||||
|
@NotBlank(message = "매장명은 필수입니다")
|
||||||
|
@Size(max = 100, message = "매장명은 100자 이하여야 합니다")
|
||||||
|
private String storeName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업종
|
||||||
|
*/
|
||||||
|
@Size(max = 50, message = "업종은 50자 이하여야 합니다")
|
||||||
|
private String industry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주소
|
||||||
|
*/
|
||||||
|
@NotBlank(message = "주소는 필수입니다")
|
||||||
|
@Size(max = 255, message = "주소는 255자 이하여야 합니다")
|
||||||
|
private String address;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 영업시간
|
||||||
|
*/
|
||||||
|
@Size(max = 255, message = "영업시간은 255자 이하여야 합니다")
|
||||||
|
private String businessHours;
|
||||||
|
}
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
package com.kt.event.user.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로필 수정 요청 DTO
|
||||||
|
*
|
||||||
|
* UFR-USER-030: 프로필 관리
|
||||||
|
*
|
||||||
|
* @author Backend Developer
|
||||||
|
* @since 1.0
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class UpdateProfileRequest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 이름
|
||||||
|
*/
|
||||||
|
@Size(min = 2, max = 50, message = "이름은 2자 이상 50자 이하여야 합니다")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전화번호 (010XXXXXXXX 형식)
|
||||||
|
*/
|
||||||
|
@Pattern(regexp = "^010\\d{8}$", message = "전화번호는 010XXXXXXXX 형식이어야 합니다")
|
||||||
|
private String phoneNumber;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이메일 주소
|
||||||
|
*/
|
||||||
|
@Email(message = "이메일 형식이 올바르지 않습니다")
|
||||||
|
@Size(max = 100, message = "이메일은 100자 이하여야 합니다")
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매장명
|
||||||
|
*/
|
||||||
|
@Size(max = 100, message = "매장명은 100자 이하여야 합니다")
|
||||||
|
private String storeName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업종
|
||||||
|
*/
|
||||||
|
@Size(max = 50, message = "업종은 50자 이하여야 합니다")
|
||||||
|
private String industry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주소
|
||||||
|
*/
|
||||||
|
@Size(max = 255, message = "주소는 255자 이하여야 합니다")
|
||||||
|
private String address;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 영업시간
|
||||||
|
*/
|
||||||
|
@Size(max = 255, message = "영업시간은 255자 이하여야 합니다")
|
||||||
|
private String businessHours;
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
package com.kt.event.user.dto.response;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인 응답 DTO
|
||||||
|
*
|
||||||
|
* UFR-USER-020: 로그인
|
||||||
|
*
|
||||||
|
* @author Backend Developer
|
||||||
|
* @since 1.0
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class LoginResponse {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT 토큰
|
||||||
|
*/
|
||||||
|
private String token;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 ID
|
||||||
|
*/
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 이름
|
||||||
|
*/
|
||||||
|
private String userName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 역할
|
||||||
|
*/
|
||||||
|
private String role;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이메일
|
||||||
|
*/
|
||||||
|
private String email;
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package com.kt.event.user.dto.response;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그아웃 응답 DTO
|
||||||
|
*
|
||||||
|
* UFR-USER-040: 로그아웃
|
||||||
|
*
|
||||||
|
* @author Backend Developer
|
||||||
|
* @since 1.0
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class LogoutResponse {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 성공 여부
|
||||||
|
*/
|
||||||
|
private boolean success;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메시지
|
||||||
|
*/
|
||||||
|
private String message;
|
||||||
|
}
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
package com.kt.event.user.dto.response;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로필 응답 DTO
|
||||||
|
*
|
||||||
|
* UFR-USER-030: 프로필 관리
|
||||||
|
*
|
||||||
|
* @author Backend Developer
|
||||||
|
* @since 1.0
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class ProfileResponse {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 ID
|
||||||
|
*/
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 이름
|
||||||
|
*/
|
||||||
|
private String userName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전화번호
|
||||||
|
*/
|
||||||
|
private String phoneNumber;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이메일
|
||||||
|
*/
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 역할
|
||||||
|
*/
|
||||||
|
private String role;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매장 ID
|
||||||
|
*/
|
||||||
|
private Long storeId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매장명
|
||||||
|
*/
|
||||||
|
private String storeName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업종
|
||||||
|
*/
|
||||||
|
private String industry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주소
|
||||||
|
*/
|
||||||
|
private String address;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 영업시간
|
||||||
|
*/
|
||||||
|
private String businessHours;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생성일시
|
||||||
|
*/
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최종 로그인 일시
|
||||||
|
*/
|
||||||
|
private LocalDateTime lastLoginAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
package com.kt.event.user.dto.response;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원가입 응답 DTO
|
||||||
|
*
|
||||||
|
* UFR-USER-010: 회원가입
|
||||||
|
*
|
||||||
|
* @author Backend Developer
|
||||||
|
* @since 1.0
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class RegisterResponse {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT 토큰
|
||||||
|
*/
|
||||||
|
private String token;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 ID
|
||||||
|
*/
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 이름
|
||||||
|
*/
|
||||||
|
private String userName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매장 ID
|
||||||
|
*/
|
||||||
|
private Long storeId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매장명
|
||||||
|
*/
|
||||||
|
private String storeName;
|
||||||
|
}
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
package com.kt.event.user.entity;
|
||||||
|
|
||||||
|
import com.kt.event.common.entity.BaseTimeEntity;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매장 엔티티
|
||||||
|
*
|
||||||
|
* 소상공인 매장 정보를 저장하는 엔티티
|
||||||
|
*
|
||||||
|
* @author Backend Developer
|
||||||
|
* @since 1.0
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "stores")
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class Store extends BaseTimeEntity {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매장 ID (Primary Key)
|
||||||
|
*/
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "store_id")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매장명
|
||||||
|
*/
|
||||||
|
@Column(name = "name", nullable = false, length = 100)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업종
|
||||||
|
*/
|
||||||
|
@Column(name = "industry", length = 50)
|
||||||
|
private String industry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주소
|
||||||
|
*/
|
||||||
|
@Column(name = "address", nullable = false, length = 255)
|
||||||
|
private String address;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 영업시간
|
||||||
|
*/
|
||||||
|
@Column(name = "business_hours", length = 255)
|
||||||
|
private String businessHours;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 정보 (One-to-One)
|
||||||
|
*/
|
||||||
|
@OneToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "user_id", nullable = false)
|
||||||
|
private User user;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 연결 (내부용)
|
||||||
|
*
|
||||||
|
* @param user 사용자
|
||||||
|
*/
|
||||||
|
void setUser(User user) {
|
||||||
|
this.user = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매장 정보 수정
|
||||||
|
*
|
||||||
|
* @param name 매장명
|
||||||
|
* @param industry 업종
|
||||||
|
* @param address 주소
|
||||||
|
* @param businessHours 영업시간
|
||||||
|
*/
|
||||||
|
public void updateInfo(String name, String industry, String address, String businessHours) {
|
||||||
|
if (name != null) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
if (industry != null) {
|
||||||
|
this.industry = industry;
|
||||||
|
}
|
||||||
|
if (address != null) {
|
||||||
|
this.address = address;
|
||||||
|
}
|
||||||
|
if (businessHours != null) {
|
||||||
|
this.businessHours = businessHours;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
174
user-service/src/main/java/com/kt/event/user/entity/User.java
Normal file
174
user-service/src/main/java/com/kt/event/user/entity/User.java
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
package com.kt.event.user.entity;
|
||||||
|
|
||||||
|
import com.kt.event.common.entity.BaseTimeEntity;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 엔티티
|
||||||
|
*
|
||||||
|
* 소상공인 사용자 정보를 저장하는 엔티티
|
||||||
|
*
|
||||||
|
* @author Backend Developer
|
||||||
|
* @since 1.0
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "users", indexes = {
|
||||||
|
@Index(name = "idx_user_phone", columnList = "phone_number", unique = true),
|
||||||
|
@Index(name = "idx_user_email", columnList = "email", unique = true)
|
||||||
|
})
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class User extends BaseTimeEntity {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 ID (Primary Key)
|
||||||
|
*/
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "user_id")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 이름
|
||||||
|
*/
|
||||||
|
@Column(name = "name", nullable = false, length = 50)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전화번호 (로그인 ID로도 사용)
|
||||||
|
*/
|
||||||
|
@Column(name = "phone_number", nullable = false, unique = true, length = 20)
|
||||||
|
private String phoneNumber;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이메일 주소
|
||||||
|
*/
|
||||||
|
@Column(name = "email", nullable = false, unique = true, length = 100)
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 (bcrypt 해시)
|
||||||
|
*/
|
||||||
|
@Column(name = "password_hash", nullable = false, length = 255)
|
||||||
|
private String passwordHash;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 역할 (기본값: OWNER)
|
||||||
|
*/
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "role", nullable = false, length = 20)
|
||||||
|
@Builder.Default
|
||||||
|
private UserRole role = UserRole.OWNER;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계정 상태 (기본값: ACTIVE)
|
||||||
|
*/
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "status", nullable = false, length = 20)
|
||||||
|
@Builder.Default
|
||||||
|
private UserStatus status = UserStatus.ACTIVE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최종 로그인 일시
|
||||||
|
*/
|
||||||
|
@Column(name = "last_login_at")
|
||||||
|
private LocalDateTime lastLoginAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매장 정보 (One-to-One)
|
||||||
|
*/
|
||||||
|
@OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||||
|
private Store store;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최종 로그인 시각 업데이트
|
||||||
|
*/
|
||||||
|
public void updateLastLoginAt() {
|
||||||
|
this.lastLoginAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 변경
|
||||||
|
*
|
||||||
|
* @param newPasswordHash 새 비밀번호 해시
|
||||||
|
*/
|
||||||
|
public void changePassword(String newPasswordHash) {
|
||||||
|
this.passwordHash = newPasswordHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로필 정보 수정
|
||||||
|
*
|
||||||
|
* @param name 이름
|
||||||
|
* @param email 이메일
|
||||||
|
* @param phoneNumber 전화번호
|
||||||
|
*/
|
||||||
|
public void updateProfile(String name, String email, String phoneNumber) {
|
||||||
|
if (name != null) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
if (email != null) {
|
||||||
|
this.email = email;
|
||||||
|
}
|
||||||
|
if (phoneNumber != null) {
|
||||||
|
this.phoneNumber = phoneNumber;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매장 정보 연결
|
||||||
|
*
|
||||||
|
* @param store 매장 정보
|
||||||
|
*/
|
||||||
|
public void setStore(Store store) {
|
||||||
|
this.store = store;
|
||||||
|
if (store != null) {
|
||||||
|
store.setUser(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 역할 Enum
|
||||||
|
*/
|
||||||
|
public enum UserRole {
|
||||||
|
/**
|
||||||
|
* 매장 소유주
|
||||||
|
*/
|
||||||
|
OWNER,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시스템 관리자
|
||||||
|
*/
|
||||||
|
ADMIN
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 계정 상태 Enum
|
||||||
|
*/
|
||||||
|
public enum UserStatus {
|
||||||
|
/**
|
||||||
|
* 활성 상태
|
||||||
|
*/
|
||||||
|
ACTIVE,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비활성 상태
|
||||||
|
*/
|
||||||
|
INACTIVE,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 잠금 상태 (보안상 이유)
|
||||||
|
*/
|
||||||
|
LOCKED,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 탈퇴 상태
|
||||||
|
*/
|
||||||
|
WITHDRAWN
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
package com.kt.event.user.exception;
|
||||||
|
|
||||||
|
import com.kt.event.common.exception.ErrorCode;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User Service 에러 코드
|
||||||
|
*
|
||||||
|
* Common 모듈의 ErrorCode enum을 사용
|
||||||
|
* User Service에서 사용하는 에러 코드만 열거
|
||||||
|
*
|
||||||
|
* @author Backend Developer
|
||||||
|
* @since 1.0
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public enum UserErrorCode {
|
||||||
|
|
||||||
|
// User 관련 에러 - Common ErrorCode 사용
|
||||||
|
USER_DUPLICATE_EMAIL(ErrorCode.USER_001),
|
||||||
|
USER_DUPLICATE_PHONE(ErrorCode.USER_001), // 중복 사용자로 처리
|
||||||
|
USER_NOT_FOUND(ErrorCode.USER_003),
|
||||||
|
|
||||||
|
// Authentication 관련 에러 - Common ErrorCode 사용
|
||||||
|
AUTH_FAILED(ErrorCode.AUTH_001),
|
||||||
|
AUTH_INVALID_TOKEN(ErrorCode.AUTH_002),
|
||||||
|
AUTH_TOKEN_EXPIRED(ErrorCode.AUTH_003),
|
||||||
|
AUTH_UNAUTHORIZED(ErrorCode.AUTH_001),
|
||||||
|
|
||||||
|
// Password 관련 에러 - Common ErrorCode 사용
|
||||||
|
PWD_INVALID_CURRENT(ErrorCode.USER_004),
|
||||||
|
PWD_SAME_AS_CURRENT(ErrorCode.USER_004);
|
||||||
|
|
||||||
|
private final ErrorCode errorCode;
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return errorCode.getCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMessage() {
|
||||||
|
return errorCode.getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
package com.kt.event.user.repository;
|
||||||
|
|
||||||
|
import com.kt.event.user.entity.Store;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매장 Repository
|
||||||
|
*
|
||||||
|
* 매장 데이터 액세스 인터페이스
|
||||||
|
*
|
||||||
|
* @author Backend Developer
|
||||||
|
* @since 1.0
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface StoreRepository extends JpaRepository<Store, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 ID로 매장 조회
|
||||||
|
*
|
||||||
|
* @param userId 사용자 ID
|
||||||
|
* @return 매장 Optional
|
||||||
|
*/
|
||||||
|
Optional<Store> findByUserId(Long userId);
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
package com.kt.event.user.repository;
|
||||||
|
|
||||||
|
import com.kt.event.user.entity.User;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 Repository
|
||||||
|
*
|
||||||
|
* 사용자 데이터 액세스 인터페이스
|
||||||
|
*
|
||||||
|
* @author Backend Developer
|
||||||
|
* @since 1.0
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface UserRepository extends JpaRepository<User, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이메일로 사용자 조회
|
||||||
|
*
|
||||||
|
* @param email 이메일
|
||||||
|
* @return 사용자 Optional
|
||||||
|
*/
|
||||||
|
Optional<User> findByEmail(String email);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전화번호로 사용자 조회
|
||||||
|
*
|
||||||
|
* @param phoneNumber 전화번호
|
||||||
|
* @return 사용자 Optional
|
||||||
|
*/
|
||||||
|
Optional<User> findByPhoneNumber(String phoneNumber);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이메일 존재 여부 확인
|
||||||
|
*
|
||||||
|
* @param email 이메일
|
||||||
|
* @return 존재 여부
|
||||||
|
*/
|
||||||
|
boolean existsByEmail(String email);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전화번호 존재 여부 확인
|
||||||
|
*
|
||||||
|
* @param phoneNumber 전화번호
|
||||||
|
* @return 존재 여부
|
||||||
|
*/
|
||||||
|
boolean existsByPhoneNumber(String phoneNumber);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최종 로그인 시각 업데이트
|
||||||
|
*
|
||||||
|
* @param userId 사용자 ID
|
||||||
|
* @param lastLoginAt 최종 로그인 시각
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE User u SET u.lastLoginAt = :lastLoginAt WHERE u.id = :userId")
|
||||||
|
void updateLastLoginAt(@Param("userId") Long userId, @Param("lastLoginAt") LocalDateTime lastLoginAt);
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
package com.kt.event.user.service;
|
||||||
|
|
||||||
|
import com.kt.event.user.dto.request.LoginRequest;
|
||||||
|
import com.kt.event.user.dto.response.LoginResponse;
|
||||||
|
import com.kt.event.user.dto.response.LogoutResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication Service Interface
|
||||||
|
*
|
||||||
|
* 인증 관련 비즈니스 로직 인터페이스
|
||||||
|
*
|
||||||
|
* @author Backend Developer
|
||||||
|
* @since 1.0
|
||||||
|
*/
|
||||||
|
public interface AuthenticationService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인
|
||||||
|
*
|
||||||
|
* @param request 로그인 요청
|
||||||
|
* @return 로그인 응답
|
||||||
|
*/
|
||||||
|
LoginResponse login(LoginRequest request);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그아웃
|
||||||
|
*
|
||||||
|
* @param token JWT 토큰
|
||||||
|
* @return 로그아웃 응답
|
||||||
|
*/
|
||||||
|
LogoutResponse logout(String token);
|
||||||
|
}
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
package com.kt.event.user.service;
|
||||||
|
|
||||||
|
import com.kt.event.user.dto.request.ChangePasswordRequest;
|
||||||
|
import com.kt.event.user.dto.request.UpdateProfileRequest;
|
||||||
|
import com.kt.event.user.dto.request.RegisterRequest;
|
||||||
|
import com.kt.event.user.dto.response.ProfileResponse;
|
||||||
|
import com.kt.event.user.dto.response.RegisterResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User Service Interface
|
||||||
|
*
|
||||||
|
* 사용자 관리 비즈니스 로직 인터페이스
|
||||||
|
*
|
||||||
|
* @author Backend Developer
|
||||||
|
* @since 1.0
|
||||||
|
*/
|
||||||
|
public interface UserService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원가입
|
||||||
|
*
|
||||||
|
* @param request 회원가입 요청
|
||||||
|
* @return 회원가입 응답
|
||||||
|
*/
|
||||||
|
RegisterResponse register(RegisterRequest request);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로필 조회
|
||||||
|
*
|
||||||
|
* @param userId 사용자 ID
|
||||||
|
* @return 프로필 응답
|
||||||
|
*/
|
||||||
|
ProfileResponse getProfile(Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로필 수정
|
||||||
|
*
|
||||||
|
* @param userId 사용자 ID
|
||||||
|
* @param request 프로필 수정 요청
|
||||||
|
* @return 프로필 응답
|
||||||
|
*/
|
||||||
|
ProfileResponse updateProfile(Long userId, UpdateProfileRequest request);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 변경
|
||||||
|
*
|
||||||
|
* @param userId 사용자 ID
|
||||||
|
* @param request 비밀번호 변경 요청
|
||||||
|
*/
|
||||||
|
void changePassword(Long userId, ChangePasswordRequest request);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최종 로그인 시각 업데이트 (비동기)
|
||||||
|
*
|
||||||
|
* @param userId 사용자 ID
|
||||||
|
*/
|
||||||
|
void updateLastLoginAt(Long userId);
|
||||||
|
}
|
||||||
@ -0,0 +1,147 @@
|
|||||||
|
package com.kt.event.user.service.impl;
|
||||||
|
|
||||||
|
import com.kt.event.common.exception.BusinessException;
|
||||||
|
import com.kt.event.common.security.JwtTokenProvider;
|
||||||
|
import com.kt.event.user.dto.request.LoginRequest;
|
||||||
|
import com.kt.event.user.dto.response.LoginResponse;
|
||||||
|
import com.kt.event.user.dto.response.LogoutResponse;
|
||||||
|
import com.kt.event.user.entity.User;
|
||||||
|
import com.kt.event.user.exception.UserErrorCode;
|
||||||
|
import com.kt.event.user.repository.UserRepository;
|
||||||
|
import com.kt.event.user.service.AuthenticationService;
|
||||||
|
import com.kt.event.user.service.UserService;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication Service 구현체
|
||||||
|
*
|
||||||
|
* 인증 관련 비즈니스 로직 구현
|
||||||
|
*
|
||||||
|
* @author Backend Developer
|
||||||
|
* @since 1.0
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class AuthenticationServiceImpl implements AuthenticationService {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final JwtTokenProvider jwtTokenProvider;
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
private RedisTemplate<String, Object> redisTemplate;
|
||||||
|
|
||||||
|
public AuthenticationServiceImpl(UserRepository userRepository,
|
||||||
|
PasswordEncoder passwordEncoder,
|
||||||
|
JwtTokenProvider jwtTokenProvider,
|
||||||
|
UserService userService) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.jwtTokenProvider = jwtTokenProvider;
|
||||||
|
this.userService = userService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인
|
||||||
|
*
|
||||||
|
* UFR-USER-020: 로그인
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = false)
|
||||||
|
public LoginResponse login(LoginRequest request) {
|
||||||
|
// 1. 사용자 조회 (이메일 기반)
|
||||||
|
User user = userRepository.findByEmail(request.getEmail())
|
||||||
|
.orElseThrow(() -> new BusinessException(UserErrorCode.AUTH_FAILED.getErrorCode()));
|
||||||
|
|
||||||
|
// 2. 비밀번호 검증
|
||||||
|
if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
|
||||||
|
throw new BusinessException(UserErrorCode.AUTH_FAILED.getErrorCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. JWT 토큰 생성
|
||||||
|
String token = jwtTokenProvider.createAccessToken(
|
||||||
|
user.getId(),
|
||||||
|
user.getEmail(),
|
||||||
|
user.getName(),
|
||||||
|
List.of(user.getRole().name())
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Redis 세션 저장 (TTL 7일)
|
||||||
|
saveSession(token, user.getId(), user.getRole().name());
|
||||||
|
|
||||||
|
// 5. 최종 로그인 시각 업데이트 (비동기)
|
||||||
|
userService.updateLastLoginAt(user.getId());
|
||||||
|
|
||||||
|
// 6. 응답 반환
|
||||||
|
return LoginResponse.builder()
|
||||||
|
.token(token)
|
||||||
|
.userId(user.getId())
|
||||||
|
.userName(user.getName())
|
||||||
|
.role(user.getRole().name())
|
||||||
|
.email(user.getEmail())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그아웃
|
||||||
|
*
|
||||||
|
* UFR-USER-040: 로그아웃
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public LogoutResponse logout(String token) {
|
||||||
|
// 1. JWT 토큰 검증
|
||||||
|
if (!jwtTokenProvider.validateToken(token)) {
|
||||||
|
throw new BusinessException(UserErrorCode.AUTH_INVALID_TOKEN.getErrorCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Redis 세션 삭제 (Redis가 활성화된 경우에만)
|
||||||
|
if (redisTemplate != null) {
|
||||||
|
String sessionKey = "user:session:" + token;
|
||||||
|
redisTemplate.delete(sessionKey);
|
||||||
|
|
||||||
|
// 3. JWT Blacklist 추가 (남은 만료 시간만큼 TTL 설정)
|
||||||
|
String blacklistKey = "jwt:blacklist:" + token;
|
||||||
|
long remainingTime = jwtTokenProvider.getExpirationFromToken(token).getTime() - System.currentTimeMillis();
|
||||||
|
if (remainingTime > 0) {
|
||||||
|
redisTemplate.opsForValue().set(blacklistKey, "true", remainingTime, TimeUnit.MILLISECONDS);
|
||||||
|
}
|
||||||
|
log.debug("Redis session and blacklist updated for logout");
|
||||||
|
} else {
|
||||||
|
log.warn("Redis is disabled. Session not cleared from Redis.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 응답 반환
|
||||||
|
return LogoutResponse.builder()
|
||||||
|
.success(true)
|
||||||
|
.message("안전하게 로그아웃되었습니다")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 세션 저장 (Redis가 활성화된 경우에만)
|
||||||
|
*
|
||||||
|
* @param token JWT 토큰
|
||||||
|
* @param userId 사용자 ID
|
||||||
|
* @param role 역할
|
||||||
|
*/
|
||||||
|
private void saveSession(String token, Long userId, String role) {
|
||||||
|
if (redisTemplate != null) {
|
||||||
|
String key = "user:session:" + token;
|
||||||
|
String value = userId + ":" + role;
|
||||||
|
redisTemplate.opsForValue().set(key, value, 7, TimeUnit.DAYS);
|
||||||
|
log.debug("Redis session saved: userId={}", userId);
|
||||||
|
} else {
|
||||||
|
log.warn("Redis is disabled. Session not saved to Redis.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,236 @@
|
|||||||
|
package com.kt.event.user.service.impl;
|
||||||
|
|
||||||
|
import com.kt.event.common.exception.BusinessException;
|
||||||
|
import com.kt.event.common.security.JwtTokenProvider;
|
||||||
|
import com.kt.event.user.dto.request.ChangePasswordRequest;
|
||||||
|
import com.kt.event.user.dto.request.RegisterRequest;
|
||||||
|
import com.kt.event.user.dto.request.UpdateProfileRequest;
|
||||||
|
import com.kt.event.user.dto.response.ProfileResponse;
|
||||||
|
import com.kt.event.user.dto.response.RegisterResponse;
|
||||||
|
import com.kt.event.user.entity.Store;
|
||||||
|
import com.kt.event.user.entity.User;
|
||||||
|
import com.kt.event.user.exception.UserErrorCode;
|
||||||
|
import com.kt.event.user.repository.StoreRepository;
|
||||||
|
import com.kt.event.user.repository.UserRepository;
|
||||||
|
import com.kt.event.user.service.UserService;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User Service 구현체
|
||||||
|
*
|
||||||
|
* 사용자 관리 비즈니스 로직 구현
|
||||||
|
*
|
||||||
|
* @author Backend Developer
|
||||||
|
* @since 1.0
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class UserServiceImpl implements UserService {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final StoreRepository storeRepository;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final JwtTokenProvider jwtTokenProvider;
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
private RedisTemplate<String, Object> redisTemplate;
|
||||||
|
|
||||||
|
public UserServiceImpl(UserRepository userRepository,
|
||||||
|
StoreRepository storeRepository,
|
||||||
|
PasswordEncoder passwordEncoder,
|
||||||
|
JwtTokenProvider jwtTokenProvider) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.storeRepository = storeRepository;
|
||||||
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.jwtTokenProvider = jwtTokenProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원가입
|
||||||
|
*
|
||||||
|
* UFR-USER-010: 회원가입
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public RegisterResponse register(RegisterRequest request) {
|
||||||
|
// 1. 이메일 중복 확인
|
||||||
|
if (userRepository.existsByEmail(request.getEmail())) {
|
||||||
|
throw new BusinessException(UserErrorCode.USER_DUPLICATE_EMAIL.getErrorCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 전화번호 중복 확인
|
||||||
|
if (userRepository.existsByPhoneNumber(request.getPhoneNumber())) {
|
||||||
|
throw new BusinessException(UserErrorCode.USER_DUPLICATE_PHONE.getErrorCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 비밀번호 해싱
|
||||||
|
String passwordHash = passwordEncoder.encode(request.getPassword());
|
||||||
|
|
||||||
|
// 4. User 엔티티 생성 및 저장
|
||||||
|
User user = User.builder()
|
||||||
|
.name(request.getName())
|
||||||
|
.phoneNumber(request.getPhoneNumber())
|
||||||
|
.email(request.getEmail())
|
||||||
|
.passwordHash(passwordHash)
|
||||||
|
.role(User.UserRole.OWNER)
|
||||||
|
.status(User.UserStatus.ACTIVE)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
User savedUser = userRepository.save(user);
|
||||||
|
|
||||||
|
// 5. Store 엔티티 생성 및 저장
|
||||||
|
Store store = Store.builder()
|
||||||
|
.name(request.getStoreName())
|
||||||
|
.industry(request.getIndustry())
|
||||||
|
.address(request.getAddress())
|
||||||
|
.businessHours(request.getBusinessHours())
|
||||||
|
.user(savedUser)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Store savedStore = storeRepository.save(store);
|
||||||
|
|
||||||
|
// 6. JWT 토큰 생성
|
||||||
|
String token = jwtTokenProvider.createAccessToken(
|
||||||
|
savedUser.getId(),
|
||||||
|
savedUser.getEmail(),
|
||||||
|
savedUser.getName(),
|
||||||
|
List.of(savedUser.getRole().name())
|
||||||
|
);
|
||||||
|
|
||||||
|
// 7. Redis 세션 저장 (TTL 7일)
|
||||||
|
saveSession(token, savedUser.getId(), savedUser.getRole().name());
|
||||||
|
|
||||||
|
// 8. 응답 반환
|
||||||
|
return RegisterResponse.builder()
|
||||||
|
.token(token)
|
||||||
|
.userId(savedUser.getId())
|
||||||
|
.userName(savedUser.getName())
|
||||||
|
.storeId(savedStore.getId())
|
||||||
|
.storeName(savedStore.getName())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로필 조회
|
||||||
|
*
|
||||||
|
* UFR-USER-030: 프로필 관리
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public ProfileResponse getProfile(Long userId) {
|
||||||
|
User user = userRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND.getErrorCode()));
|
||||||
|
|
||||||
|
Store store = storeRepository.findByUserId(userId)
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
return ProfileResponse.builder()
|
||||||
|
.userId(user.getId())
|
||||||
|
.userName(user.getName())
|
||||||
|
.phoneNumber(user.getPhoneNumber())
|
||||||
|
.email(user.getEmail())
|
||||||
|
.role(user.getRole().name())
|
||||||
|
.storeId(store != null ? store.getId() : null)
|
||||||
|
.storeName(store != null ? store.getName() : null)
|
||||||
|
.industry(store != null ? store.getIndustry() : null)
|
||||||
|
.address(store != null ? store.getAddress() : null)
|
||||||
|
.businessHours(store != null ? store.getBusinessHours() : null)
|
||||||
|
.createdAt(user.getCreatedAt())
|
||||||
|
.lastLoginAt(user.getLastLoginAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로필 수정
|
||||||
|
*
|
||||||
|
* UFR-USER-030: 프로필 관리
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public ProfileResponse updateProfile(Long userId, UpdateProfileRequest request) {
|
||||||
|
User user = userRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND.getErrorCode()));
|
||||||
|
|
||||||
|
// User 정보 수정
|
||||||
|
user.updateProfile(request.getName(), request.getEmail(), request.getPhoneNumber());
|
||||||
|
|
||||||
|
// Store 정보 수정
|
||||||
|
Store store = storeRepository.findByUserId(userId).orElse(null);
|
||||||
|
if (store != null) {
|
||||||
|
store.updateInfo(
|
||||||
|
request.getStoreName(),
|
||||||
|
request.getIndustry(),
|
||||||
|
request.getAddress(),
|
||||||
|
request.getBusinessHours()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getProfile(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 변경
|
||||||
|
*
|
||||||
|
* UFR-USER-030: 프로필 관리
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void changePassword(Long userId, ChangePasswordRequest request) {
|
||||||
|
User user = userRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND.getErrorCode()));
|
||||||
|
|
||||||
|
// 현재 비밀번호 검증
|
||||||
|
if (!passwordEncoder.matches(request.getCurrentPassword(), user.getPasswordHash())) {
|
||||||
|
throw new BusinessException(UserErrorCode.PWD_INVALID_CURRENT.getErrorCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새 비밀번호가 현재 비밀번호와 동일한지 확인
|
||||||
|
if (passwordEncoder.matches(request.getNewPassword(), user.getPasswordHash())) {
|
||||||
|
throw new BusinessException(UserErrorCode.PWD_SAME_AS_CURRENT.getErrorCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새 비밀번호 해싱 및 저장
|
||||||
|
String newPasswordHash = passwordEncoder.encode(request.getNewPassword());
|
||||||
|
user.changePassword(newPasswordHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최종 로그인 시각 업데이트 (비동기)
|
||||||
|
*
|
||||||
|
* UFR-USER-020: 로그인
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Async
|
||||||
|
@Transactional
|
||||||
|
public void updateLastLoginAt(Long userId) {
|
||||||
|
userRepository.updateLastLoginAt(userId, LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 세션 저장 (Redis가 활성화된 경우에만)
|
||||||
|
*
|
||||||
|
* @param token JWT 토큰
|
||||||
|
* @param userId 사용자 ID
|
||||||
|
* @param role 역할
|
||||||
|
*/
|
||||||
|
private void saveSession(String token, Long userId, String role) {
|
||||||
|
if (redisTemplate != null) {
|
||||||
|
String key = "user:session:" + token;
|
||||||
|
String value = userId + ":" + role;
|
||||||
|
redisTemplate.opsForValue().set(key, value, 7, TimeUnit.DAYS);
|
||||||
|
log.debug("Redis session saved: userId={}", userId);
|
||||||
|
} else {
|
||||||
|
log.warn("Redis is disabled. Session not saved to Redis.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
123
user-service/src/main/resources/application.yml
Normal file
123
user-service/src/main/resources/application.yml
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: user-service
|
||||||
|
|
||||||
|
# Database Configuration (PostgreSQL)
|
||||||
|
datasource:
|
||||||
|
url: ${DB_URL:jdbc:postgresql://20.249.125.115:5432/userdb}
|
||||||
|
username: ${DB_USERNAME:eventuser}
|
||||||
|
password: ${DB_PASSWORD:Hi5Jessica!}
|
||||||
|
driver-class-name: ${DB_DRIVER:org.postgresql.Driver}
|
||||||
|
hikari:
|
||||||
|
maximum-pool-size: ${DB_POOL_MAX:20}
|
||||||
|
minimum-idle: ${DB_POOL_MIN:5}
|
||||||
|
connection-timeout: ${DB_CONN_TIMEOUT:30000}
|
||||||
|
idle-timeout: ${DB_IDLE_TIMEOUT:600000}
|
||||||
|
max-lifetime: ${DB_MAX_LIFETIME:1800000}
|
||||||
|
leak-detection-threshold: ${DB_LEAK_THRESHOLD:60000}
|
||||||
|
|
||||||
|
# H2 Console (개발용 - PostgreSQL 사용 시 비활성화)
|
||||||
|
h2:
|
||||||
|
console:
|
||||||
|
enabled: ${H2_CONSOLE_ENABLED:false}
|
||||||
|
path: /h2-console
|
||||||
|
|
||||||
|
# JPA Configuration
|
||||||
|
jpa:
|
||||||
|
show-sql: ${SHOW_SQL:true}
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
format_sql: true
|
||||||
|
use_sql_comments: true
|
||||||
|
dialect: ${JPA_DIALECT:org.hibernate.dialect.PostgreSQLDialect}
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: ${DDL_AUTO:update}
|
||||||
|
|
||||||
|
# Auto-configuration exclusions for development without external services
|
||||||
|
autoconfigure:
|
||||||
|
exclude:
|
||||||
|
- ${EXCLUDE_KAFKA:}
|
||||||
|
- ${EXCLUDE_REDIS:}
|
||||||
|
|
||||||
|
# Redis Configuration
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
enabled: ${REDIS_ENABLED:true}
|
||||||
|
host: ${REDIS_HOST:20.214.210.71}
|
||||||
|
port: ${REDIS_PORT:6379}
|
||||||
|
password: ${REDIS_PASSWORD:Hi5Jessica!}
|
||||||
|
timeout: ${REDIS_TIMEOUT:2000ms}
|
||||||
|
lettuce:
|
||||||
|
pool:
|
||||||
|
max-active: ${REDIS_POOL_MAX:8}
|
||||||
|
max-idle: ${REDIS_POOL_IDLE:8}
|
||||||
|
min-idle: ${REDIS_POOL_MIN:0}
|
||||||
|
max-wait: ${REDIS_POOL_WAIT:-1ms}
|
||||||
|
database: ${REDIS_DATABASE:0}
|
||||||
|
|
||||||
|
# Kafka Configuration
|
||||||
|
kafka:
|
||||||
|
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:4.230.50.63:9092}
|
||||||
|
consumer:
|
||||||
|
group-id: ${KAFKA_CONSUMER_GROUP:user-service-consumers}
|
||||||
|
auto-offset-reset: earliest
|
||||||
|
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
|
||||||
|
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
|
||||||
|
properties:
|
||||||
|
spring.json.trusted.packages: "*"
|
||||||
|
producer:
|
||||||
|
key-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||||
|
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
jwt:
|
||||||
|
secret: ${JWT_SECRET:kt-event-marketing-secret-key-for-development-only-please-change-in-production}
|
||||||
|
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:604800000} # 7 days in milliseconds
|
||||||
|
|
||||||
|
# CORS Configuration
|
||||||
|
cors:
|
||||||
|
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:*}
|
||||||
|
|
||||||
|
# Actuator
|
||||||
|
management:
|
||||||
|
endpoints:
|
||||||
|
web:
|
||||||
|
exposure:
|
||||||
|
include: health,info,metrics,prometheus
|
||||||
|
base-path: /actuator
|
||||||
|
endpoint:
|
||||||
|
health:
|
||||||
|
show-details: always
|
||||||
|
show-components: always
|
||||||
|
health:
|
||||||
|
livenessState:
|
||||||
|
enabled: true
|
||||||
|
readinessState:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# OpenAPI Documentation
|
||||||
|
springdoc:
|
||||||
|
api-docs:
|
||||||
|
path: /v3/api-docs
|
||||||
|
swagger-ui:
|
||||||
|
path: /swagger-ui.html
|
||||||
|
tags-sorter: alpha
|
||||||
|
operations-sorter: alpha
|
||||||
|
show-actuator: false
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
com.kt.event.user: ${LOG_LEVEL_APP:DEBUG}
|
||||||
|
org.springframework.web: ${LOG_LEVEL_WEB:INFO}
|
||||||
|
org.hibernate.SQL: ${LOG_LEVEL_SQL:DEBUG}
|
||||||
|
org.hibernate.type: ${LOG_LEVEL_SQL_TYPE:TRACE}
|
||||||
|
pattern:
|
||||||
|
console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
|
||||||
|
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
|
||||||
|
file:
|
||||||
|
name: ${LOG_FILE_PATH:logs/user-service.log}
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
server:
|
||||||
|
port: ${SERVER_PORT:8081}
|
||||||
Loading…
x
Reference in New Issue
Block a user