user-service api 초안 개발

This commit is contained in:
wonho 2025-10-24 15:19:04 +09:00
parent 50cf1dbcf1
commit 34df9c3b8f
28 changed files with 1953 additions and 15 deletions

View File

@ -18,7 +18,7 @@ primary:
enabled: true enabled: true
storageClass: "managed-premium" storageClass: "managed-premium"
size: 10Gi size: 10Gi
resources: resources:
limits: limits:
memory: "4Gi" memory: "4Gi"
@ -26,12 +26,14 @@ primary:
requests: requests:
memory: "2Gi" memory: "2Gi"
cpu: "0.5" cpu: "0.5"
# 성능 최적화 설정 # 성능 최적화 설정
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
value: "3GB" value: "3GB"
- name: POSTGRESQL_MAX_CONNECTIONS - name: POSTGRESQL_MAX_CONNECTIONS
value: "200" value: "200"

View File

@ -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

View 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>

View File

@ -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'
} }

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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("/users/register", "/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();
}
}

View File

@ -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");
}
}

View File

@ -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("/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();
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}
}
}

View 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
}
}

View File

@ -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();
}
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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.");
}
}
}

View File

@ -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.");
}
}
}

View 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}