diff --git a/backing-service/install/postgres/values-user.yaml b/backing-service/install/postgres/values-user.yaml index 665a2fa..af3a323 100644 --- a/backing-service/install/postgres/values-user.yaml +++ b/backing-service/install/postgres/values-user.yaml @@ -18,7 +18,7 @@ primary: enabled: true storageClass: "managed-premium" size: 10Gi - + resources: limits: memory: "4Gi" @@ -26,12 +26,14 @@ primary: requests: memory: "2Gi" cpu: "0.5" - - # 성능 최적화 설정 + + # 성능 최적화 설정 extraEnvVars: + - name: POSTGRESQL_READ_ONLY_MODE + value: "no" - name: POSTGRESQL_SHARED_BUFFERS value: "1GB" - - name: POSTGRESQL_EFFECTIVE_CACHE_SIZE + - name: POSTGRESQL_EFFECTIVE_CACHE_SIZE value: "3GB" - name: POSTGRESQL_MAX_CONNECTIONS value: "200" diff --git a/design/backend/api/user-service-api.yaml b/design/backend/api/user-service-api.yaml index e1c486f..20112a3 100644 --- a/design/backend/api/user-service-api.yaml +++ b/design/backend/api/user-service-api.yaml @@ -51,7 +51,7 @@ paths: - JWT 토큰 자동 발급 **처리 흐름:** - 1. 중복 사용자 확인 (전화번호 기반) + 1. 중복 사용자 확인 (이메일/전화번호 기반) 2. 비밀번호 해싱 (bcrypt) 3. User/Store 데이터베이스 트랜잭션 처리 4. JWT 토큰 생성 및 세션 저장 (Redis) @@ -114,7 +114,7 @@ paths: summary: 중복 사용자 value: code: USER_001 - message: 이미 가입된 전화번호입니다 + message: 이미 가입된 이메일입니다 timestamp: 2025-10-22T10:30:00Z validationError: summary: 입력 검증 오류 @@ -140,7 +140,7 @@ paths: **유저스토리:** UFR-USER-020 **주요 기능:** - - 전화번호/비밀번호 인증 + - 이메일/비밀번호 인증 - JWT 토큰 발급 - Redis 세션 저장 - 최종 로그인 시각 업데이트 (비동기) @@ -162,7 +162,7 @@ paths: default: summary: 로그인 요청 예시 value: - phoneNumber: "01012345678" + email: hong@example.com password: "Password123!" responses: '200': @@ -191,7 +191,7 @@ paths: summary: 인증 실패 value: code: AUTH_001 - message: 전화번호 또는 비밀번호를 확인해주세요 + message: 이메일 또는 비밀번호를 확인해주세요 timestamp: 2025-10-22T10:30:00Z /users/logout: @@ -679,14 +679,15 @@ components: LoginRequest: type: object required: - - phoneNumber + - email - password properties: - phoneNumber: + email: type: string - pattern: '^010\d{8}$' - description: 휴대폰 번호 - example: "01012345678" + format: email + maxLength: 100 + description: 이메일 주소 + example: hong@example.com password: type: string minLength: 8 @@ -977,7 +978,7 @@ components: message: type: string description: 에러 메시지 - example: 이미 가입된 전화번호입니다 + example: 이미 가입된 이메일입니다 timestamp: type: string format: date-time diff --git a/user-service/.run/user-service.run.xml b/user-service/.run/user-service.run.xml new file mode 100644 index 0000000..07dfd36 --- /dev/null +++ b/user-service/.run/user-service.run.xml @@ -0,0 +1,87 @@ + + + + + + + + true + true + + + + + false + false + + + diff --git a/user-service/build.gradle b/user-service/build.gradle index 63a1c78..ad1b873 100644 --- a/user-service/build.gradle +++ b/user-service/build.gradle @@ -7,4 +7,10 @@ dependencies { // OpenFeign for external API calls (사업자번호 검증) implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + + // H2 Database for development + runtimeOnly 'com.h2database:h2' + + // PostgreSQL Database for production + runtimeOnly 'org.postgresql:postgresql' } diff --git a/user-service/src/main/java/com/kt/event/user/UserServiceApplication.java b/user-service/src/main/java/com/kt/event/user/UserServiceApplication.java new file mode 100644 index 0000000..007a47d --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/UserServiceApplication.java @@ -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); + } +} diff --git a/user-service/src/main/java/com/kt/event/user/config/AsyncConfig.java b/user-service/src/main/java/com/kt/event/user/config/AsyncConfig.java new file mode 100644 index 0000000..782145c --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/config/AsyncConfig.java @@ -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; + } +} diff --git a/user-service/src/main/java/com/kt/event/user/config/RedisConfig.java b/user-service/src/main/java/com/kt/event/user/config/RedisConfig.java new file mode 100644 index 0000000..c4c48d6 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/config/RedisConfig.java @@ -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 redisTemplate() { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(redisConnectionFactory()); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new StringRedisSerializer()); + return template; + } +} diff --git a/user-service/src/main/java/com/kt/event/user/config/SecurityConfig.java b/user-service/src/main/java/com/kt/event/user/config/SecurityConfig.java new file mode 100644 index 0000000..7f592a6 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/config/SecurityConfig.java @@ -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(); + } +} diff --git a/user-service/src/main/java/com/kt/event/user/config/SwaggerConfig.java b/user-service/src/main/java/com/kt/event/user/config/SwaggerConfig.java new file mode 100644 index 0000000..60ab414 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/config/SwaggerConfig.java @@ -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"); + } +} diff --git a/user-service/src/main/java/com/kt/event/user/controller/UserController.java b/user-service/src/main/java/com/kt/event/user/controller/UserController.java new file mode 100644 index 0000000..e130914 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/controller/UserController.java @@ -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 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 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 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 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 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 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(); + } +} diff --git a/user-service/src/main/java/com/kt/event/user/dto/request/ChangePasswordRequest.java b/user-service/src/main/java/com/kt/event/user/dto/request/ChangePasswordRequest.java new file mode 100644 index 0000000..b141321 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/dto/request/ChangePasswordRequest.java @@ -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; +} diff --git a/user-service/src/main/java/com/kt/event/user/dto/request/LoginRequest.java b/user-service/src/main/java/com/kt/event/user/dto/request/LoginRequest.java new file mode 100644 index 0000000..b743595 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/dto/request/LoginRequest.java @@ -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; +} diff --git a/user-service/src/main/java/com/kt/event/user/dto/request/RegisterRequest.java b/user-service/src/main/java/com/kt/event/user/dto/request/RegisterRequest.java new file mode 100644 index 0000000..95db436 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/dto/request/RegisterRequest.java @@ -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; +} diff --git a/user-service/src/main/java/com/kt/event/user/dto/request/UpdateProfileRequest.java b/user-service/src/main/java/com/kt/event/user/dto/request/UpdateProfileRequest.java new file mode 100644 index 0000000..6ca8ea9 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/dto/request/UpdateProfileRequest.java @@ -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; +} diff --git a/user-service/src/main/java/com/kt/event/user/dto/response/LoginResponse.java b/user-service/src/main/java/com/kt/event/user/dto/response/LoginResponse.java new file mode 100644 index 0000000..9fc930b --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/dto/response/LoginResponse.java @@ -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; +} diff --git a/user-service/src/main/java/com/kt/event/user/dto/response/LogoutResponse.java b/user-service/src/main/java/com/kt/event/user/dto/response/LogoutResponse.java new file mode 100644 index 0000000..cebfb57 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/dto/response/LogoutResponse.java @@ -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; +} diff --git a/user-service/src/main/java/com/kt/event/user/dto/response/ProfileResponse.java b/user-service/src/main/java/com/kt/event/user/dto/response/ProfileResponse.java new file mode 100644 index 0000000..334e2cb --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/dto/response/ProfileResponse.java @@ -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; +} diff --git a/user-service/src/main/java/com/kt/event/user/dto/response/RegisterResponse.java b/user-service/src/main/java/com/kt/event/user/dto/response/RegisterResponse.java new file mode 100644 index 0000000..6f01cdd --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/dto/response/RegisterResponse.java @@ -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; +} diff --git a/user-service/src/main/java/com/kt/event/user/entity/Store.java b/user-service/src/main/java/com/kt/event/user/entity/Store.java new file mode 100644 index 0000000..75917db --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/entity/Store.java @@ -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; + } + } +} diff --git a/user-service/src/main/java/com/kt/event/user/entity/User.java b/user-service/src/main/java/com/kt/event/user/entity/User.java new file mode 100644 index 0000000..89ec86e --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/entity/User.java @@ -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 + } +} diff --git a/user-service/src/main/java/com/kt/event/user/exception/UserErrorCode.java b/user-service/src/main/java/com/kt/event/user/exception/UserErrorCode.java new file mode 100644 index 0000000..3f82060 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/exception/UserErrorCode.java @@ -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(); + } +} diff --git a/user-service/src/main/java/com/kt/event/user/repository/StoreRepository.java b/user-service/src/main/java/com/kt/event/user/repository/StoreRepository.java new file mode 100644 index 0000000..dfab0ef --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/repository/StoreRepository.java @@ -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 { + + /** + * 사용자 ID로 매장 조회 + * + * @param userId 사용자 ID + * @return 매장 Optional + */ + Optional findByUserId(Long userId); +} diff --git a/user-service/src/main/java/com/kt/event/user/repository/UserRepository.java b/user-service/src/main/java/com/kt/event/user/repository/UserRepository.java new file mode 100644 index 0000000..91b6606 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/repository/UserRepository.java @@ -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 { + + /** + * 이메일로 사용자 조회 + * + * @param email 이메일 + * @return 사용자 Optional + */ + Optional findByEmail(String email); + + /** + * 전화번호로 사용자 조회 + * + * @param phoneNumber 전화번호 + * @return 사용자 Optional + */ + Optional 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); +} diff --git a/user-service/src/main/java/com/kt/event/user/service/AuthenticationService.java b/user-service/src/main/java/com/kt/event/user/service/AuthenticationService.java new file mode 100644 index 0000000..f014196 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/service/AuthenticationService.java @@ -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); +} diff --git a/user-service/src/main/java/com/kt/event/user/service/UserService.java b/user-service/src/main/java/com/kt/event/user/service/UserService.java new file mode 100644 index 0000000..da171a5 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/service/UserService.java @@ -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); +} diff --git a/user-service/src/main/java/com/kt/event/user/service/impl/AuthenticationServiceImpl.java b/user-service/src/main/java/com/kt/event/user/service/impl/AuthenticationServiceImpl.java new file mode 100644 index 0000000..8ccd04b --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/service/impl/AuthenticationServiceImpl.java @@ -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 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."); + } + } +} diff --git a/user-service/src/main/java/com/kt/event/user/service/impl/UserServiceImpl.java b/user-service/src/main/java/com/kt/event/user/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..15ef003 --- /dev/null +++ b/user-service/src/main/java/com/kt/event/user/service/impl/UserServiceImpl.java @@ -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 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."); + } + } +} diff --git a/user-service/src/main/resources/application.yml b/user-service/src/main/resources/application.yml new file mode 100644 index 0000000..4637dd2 --- /dev/null +++ b/user-service/src/main/resources/application.yml @@ -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}