From 8c341d0a7d42b923fb2f0fac00b6f91ce7c5473a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Sat, 19 Jul 2025 15:28:46 +0900 Subject: [PATCH 001/135] Update cd.yml --- .github/workflows/cd.yml | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 8864fc2..b46e7be 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -26,18 +26,25 @@ jobs: - name: Locate and Prepare JAR artifact id: get_jar_path run: | + # --- 디버깅용 코드 추가 시작 --- + echo "Current working directory: $(pwd)" + echo "Listing contents of build/libs/ before finding JAR:" + ls -al build/libs/ + # --- 디버깅용 코드 추가 끝 --- + JAR_FILE=$(find build/libs -name "*.jar" | head -n 1) if [ -z "$JAR_FILE" ]; then echo "Error: No JAR file found!" exit 1 fi - echo "JAR_FILE=$JAR_FILE" >> $GITHUB_OUTPUT + echo "Found JAR: $JAR_FILE" + echo "jar_path=$JAR_FILE" >> $GITHUB_OUTPUT - - name: Add EC2 Host Key to known_hosts + - name: Verify JAR path before transfer run: | - mkdir -p ~/.ssh - echo "${{ secrets.SSH_HOST_KEYS }}" > ~/.ssh/known_hosts - chmod 600 ~/.ssh/known_hosts + echo "Verifying JAR_FILE path: ${{ steps.get_jar_path.outputs.jar_path }}" + test -f "${{ steps.get_jar_path.outputs.jar_path }}" || { echo "Error: JAR file not found at expected path!"; exit 1; } + ls -lh "${{ steps.get_jar_path.outputs.jar_path }}" - name: Transfer JAR to EC2 Server uses: appleboy/scp-action@master @@ -72,4 +79,4 @@ jobs: - name: Clean up local SSH key file if: always() - run: rm -f private_key.pem \ No newline at end of file + run: rm -f private_key.pem From 532f0809c4a33f2ab30e7e2be2a3fb49f291f1f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Sat, 19 Jul 2025 21:36:23 +0900 Subject: [PATCH 002/135] Update cd.yml --- .github/workflows/cd.yml | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index b46e7be..63c560f 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -26,25 +26,18 @@ jobs: - name: Locate and Prepare JAR artifact id: get_jar_path run: | - # --- 디버깅용 코드 추가 시작 --- - echo "Current working directory: $(pwd)" - echo "Listing contents of build/libs/ before finding JAR:" - ls -al build/libs/ - # --- 디버깅용 코드 추가 끝 --- - JAR_FILE=$(find build/libs -name "*.jar" | head -n 1) if [ -z "$JAR_FILE" ]; then echo "Error: No JAR file found!" exit 1 fi - echo "Found JAR: $JAR_FILE" echo "jar_path=$JAR_FILE" >> $GITHUB_OUTPUT - - name: Verify JAR path before transfer + - name: Add EC2 Host Key to known_hosts run: | - echo "Verifying JAR_FILE path: ${{ steps.get_jar_path.outputs.jar_path }}" - test -f "${{ steps.get_jar_path.outputs.jar_path }}" || { echo "Error: JAR file not found at expected path!"; exit 1; } - ls -lh "${{ steps.get_jar_path.outputs.jar_path }}" + mkdir -p ~/.ssh + echo "${{ secrets.SSH_HOST_KEYS }}" > ~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts - name: Transfer JAR to EC2 Server uses: appleboy/scp-action@master From e78918af56f315ff35d30cd73ece3c738492cc66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Sun, 20 Jul 2025 15:24:22 +0900 Subject: [PATCH 003/135] Update cd.yml --- .github/workflows/cd.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 63c560f..1252c76 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -26,11 +26,16 @@ jobs: - name: Locate and Prepare JAR artifact id: get_jar_path run: | + echo "Current working directory: $(pwd)" # 디버깅용 + echo "Listing contents of build/libs/ before finding JAR:" # 디버깅용 + ls -al build/libs/ # 디버깅용 + JAR_FILE=$(find build/libs -name "*.jar" | head -n 1) if [ -z "$JAR_FILE" ]; then echo "Error: No JAR file found!" exit 1 fi + echo "Found JAR: $JAR_FILE" # 디버깅용 echo "jar_path=$JAR_FILE" >> $GITHUB_OUTPUT - name: Add EC2 Host Key to known_hosts From f798f9a41f009b5196d5c1138101a47befe063ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Sun, 20 Jul 2025 15:27:53 +0900 Subject: [PATCH 004/135] Update cd.yml --- .github/workflows/cd.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 1252c76..63c560f 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -26,16 +26,11 @@ jobs: - name: Locate and Prepare JAR artifact id: get_jar_path run: | - echo "Current working directory: $(pwd)" # 디버깅용 - echo "Listing contents of build/libs/ before finding JAR:" # 디버깅용 - ls -al build/libs/ # 디버깅용 - JAR_FILE=$(find build/libs -name "*.jar" | head -n 1) if [ -z "$JAR_FILE" ]; then echo "Error: No JAR file found!" exit 1 fi - echo "Found JAR: $JAR_FILE" # 디버깅용 echo "jar_path=$JAR_FILE" >> $GITHUB_OUTPUT - name: Add EC2 Host Key to known_hosts From 6a2a7af604ca2155e4e3d29d22cfef5742f48372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Sun, 20 Jul 2025 15:54:56 +0900 Subject: [PATCH 005/135] Update cd.yml --- .github/workflows/cd.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 63c560f..dd90c10 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -26,9 +26,13 @@ jobs: - name: Locate and Prepare JAR artifact id: get_jar_path run: | - JAR_FILE=$(find build/libs -name "*.jar" | head -n 1) + # build.gradle에서 jar { enabled = false } 설정 후, + # 기본적으로 생성되는 실행 가능한 JAR 파일명을 정확히 찾음 (예: artifactId-version.jar) + # JoyCrew의 경우 'backend-0.0.1-SNAPSHOT.jar' 형태일 가능성이 높음. + JAR_FILE=$(find build/libs -name "*.jar" | head -n 1) + if [ -z "$JAR_FILE" ]; then - echo "Error: No JAR file found!" + echo "Error: No executable JAR file found in build/libs directory!" exit 1 fi echo "jar_path=$JAR_FILE" >> $GITHUB_OUTPUT From e65868d0ff17d0e2576444e8f37cfb9729c61000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Sun, 20 Jul 2025 17:30:38 +0900 Subject: [PATCH 006/135] Update build.gradle --- build.gradle | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/build.gradle b/build.gradle index b26d9cb..d23461e 100644 --- a/build.gradle +++ b/build.gradle @@ -41,3 +41,7 @@ dependencies { tasks.named('test') { useJUnitPlatform() } + +jar { + enabled = false +} From 58626552832f726b2fb8b591dfd352b45bf7b8be Mon Sep 17 00:00:00 2001 From: yeoEun Date: Mon, 21 Jul 2025 16:58:55 +0900 Subject: [PATCH 007/135] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8+?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EC=A1=B0=ED=9A=8C+=EC=9E=94?= =?UTF-8?q?=EC=95=A1=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 5 ++ .../backend/config/SecurityConfig.java | 55 +++++++++++++++++ .../joycrew/backend/config/SwaggerConfig.java | 31 ++++++++++ .../backend/controller/AuthController.java | 44 +++++++++++++ .../backend/controller/UserController.java | 50 +++++++++++++++ .../backend/controller/WalletController.java | 51 ++++++++++++++++ .../com/joycrew/backend/dto/LoginRequest.java | 22 +++++++ .../joycrew/backend/dto/LoginResponse.java | 14 +++++ .../backend/dto/UserProfileResponse.java | 20 ++++++ .../joycrew/backend/dto/WalletResponse.java | 14 +++++ .../com/joycrew/backend/entity/Company.java | 45 ++++++++++++++ .../backend/entity/CompanyAdminAccess.java | 49 +++++++++++++++ .../joycrew/backend/entity/Department.java | 46 ++++++++++++++ .../com/joycrew/backend/entity/Employee.java | 61 +++++++++++++++++++ .../entity/RewardPointTransaction.java | 41 +++++++++++++ .../com/joycrew/backend/entity/Wallet.java | 38 ++++++++++++ .../repository/EmployeeRepository.java | 10 +++ .../backend/repository/WalletRepository.java | 9 +++ .../security/JwtAuthenticationFilter.java | 49 +++++++++++++++ .../com/joycrew/backend/security/JwtUtil.java | 39 ++++++++++++ .../joycrew/backend/service/AuthService.java | 34 +++++++++++ .../backend/service/EmployeeService.java | 32 ++++++++++ .../backend/util/PasswordHashPrinter.java | 20 ++++++ 23 files changed, 779 insertions(+) create mode 100644 src/main/java/com/joycrew/backend/config/SecurityConfig.java create mode 100644 src/main/java/com/joycrew/backend/config/SwaggerConfig.java create mode 100644 src/main/java/com/joycrew/backend/controller/AuthController.java create mode 100644 src/main/java/com/joycrew/backend/controller/UserController.java create mode 100644 src/main/java/com/joycrew/backend/controller/WalletController.java create mode 100644 src/main/java/com/joycrew/backend/dto/LoginRequest.java create mode 100644 src/main/java/com/joycrew/backend/dto/LoginResponse.java create mode 100644 src/main/java/com/joycrew/backend/dto/UserProfileResponse.java create mode 100644 src/main/java/com/joycrew/backend/dto/WalletResponse.java create mode 100644 src/main/java/com/joycrew/backend/entity/Company.java create mode 100644 src/main/java/com/joycrew/backend/entity/CompanyAdminAccess.java create mode 100644 src/main/java/com/joycrew/backend/entity/Department.java create mode 100644 src/main/java/com/joycrew/backend/entity/Employee.java create mode 100644 src/main/java/com/joycrew/backend/entity/RewardPointTransaction.java create mode 100644 src/main/java/com/joycrew/backend/entity/Wallet.java create mode 100644 src/main/java/com/joycrew/backend/repository/EmployeeRepository.java create mode 100644 src/main/java/com/joycrew/backend/repository/WalletRepository.java create mode 100644 src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/joycrew/backend/security/JwtUtil.java create mode 100644 src/main/java/com/joycrew/backend/service/AuthService.java create mode 100644 src/main/java/com/joycrew/backend/service/EmployeeService.java create mode 100644 src/main/java/com/joycrew/backend/util/PasswordHashPrinter.java diff --git a/build.gradle b/build.gradle index d23461e..d9a3cec 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,11 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' + implementation 'org.springframework.security:spring-security-crypto' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.mysql:mysql-connector-j' diff --git a/src/main/java/com/joycrew/backend/config/SecurityConfig.java b/src/main/java/com/joycrew/backend/config/SecurityConfig.java new file mode 100644 index 0000000..09dbb81 --- /dev/null +++ b/src/main/java/com/joycrew/backend/config/SecurityConfig.java @@ -0,0 +1,55 @@ +package com.joycrew.backend.config; + +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.security.JwtAuthenticationFilter; +import com.joycrew.backend.security.JwtUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtUtil jwtUtil; + private final EmployeeRepository employeeRepository; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.ignoringRequestMatchers( + "/h2-console/**", + "/api/auth/login", + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html" + )) + .headers(headers -> headers.disable()) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/h2-console/**", + "/api/auth/login", + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html" + ).permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, employeeRepository), + org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class) + .formLogin(form -> form.disable()) + .httpBasic(Customizer.withDefaults()); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/joycrew/backend/config/SwaggerConfig.java b/src/main/java/com/joycrew/backend/config/SwaggerConfig.java new file mode 100644 index 0000000..49150b0 --- /dev/null +++ b/src/main/java/com/joycrew/backend/config/SwaggerConfig.java @@ -0,0 +1,31 @@ +package com.joycrew.backend.config; + +import io.swagger.v3.oas.models.OpenAPI; +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.Components; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + final String securitySchemeName = "Authorization"; + + return new OpenAPI() + .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) + .components(new Components().addSecuritySchemes(securitySchemeName, + new SecurityScheme() + .name(securitySchemeName) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))) + .info(new Info() + .title("JoyCrew API") + .version("v1.0.0") + .description("JoyCrew 백엔드 API 명세서입니다.")); + } +} diff --git a/src/main/java/com/joycrew/backend/controller/AuthController.java b/src/main/java/com/joycrew/backend/controller/AuthController.java new file mode 100644 index 0000000..79487d3 --- /dev/null +++ b/src/main/java/com/joycrew/backend/controller/AuthController.java @@ -0,0 +1,44 @@ +package com.joycrew.backend.controller; + +import com.joycrew.backend.dto.LoginRequest; +import com.joycrew.backend.dto.LoginResponse; +import com.joycrew.backend.service.AuthService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "인증", description = "로그인 관련 API") +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @Operation(summary = "로그인", description = "이메일과 비밀번호를 이용해 JWT 토큰을 반환합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그인 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = LoginResponse.class))), + @ApiResponse(responseCode = "401", description = "이메일 또는 비밀번호 오류", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = LoginResponse.class))) + }) + @PostMapping("/login") + public ResponseEntity login(@RequestBody @Valid LoginRequest request) { + try { + return ResponseEntity.ok(authService.login(request)); + } catch (RuntimeException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new LoginResponse("이메일 또는 비밀번호가 올바르지 않습니다.")); + } + } +} diff --git a/src/main/java/com/joycrew/backend/controller/UserController.java b/src/main/java/com/joycrew/backend/controller/UserController.java new file mode 100644 index 0000000..73dd4e3 --- /dev/null +++ b/src/main/java/com/joycrew/backend/controller/UserController.java @@ -0,0 +1,50 @@ +package com.joycrew.backend.controller; + +import com.joycrew.backend.dto.UserProfileResponse; +import com.joycrew.backend.entity.Employee; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@Tag(name = "사용자", description = "사용자 정보 관련 API") +@RestController +@RequestMapping("/api/user") +@RequiredArgsConstructor +public class UserController { + + @Operation(summary = "사용자 프로필 조회", description = "JWT 토큰으로 인증된 사용자의 프로필 정보를 반환합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = UserProfileResponse.class))), + @ApiResponse(responseCode = "401", description = "토큰 없음 또는 유효하지 않음", + content = @Content(mediaType = "application/json", + schema = @Schema(example = "{\"message\": \"유효하지 않은 토큰입니다.\"}"))) + }) + @GetMapping("/profile") + public ResponseEntity getProfile(Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("message", "유효하지 않은 토큰입니다.")); + } + + Employee employee = (Employee) authentication.getPrincipal(); + + UserProfileResponse response = new UserProfileResponse( + employee.getEmployeeId(), + employee.getEmployeeName(), + employee.getEmail() + ); + + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/joycrew/backend/controller/WalletController.java b/src/main/java/com/joycrew/backend/controller/WalletController.java new file mode 100644 index 0000000..d298892 --- /dev/null +++ b/src/main/java/com/joycrew/backend/controller/WalletController.java @@ -0,0 +1,51 @@ +package com.joycrew.backend.controller; + +import com.joycrew.backend.dto.WalletResponse; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.repository.WalletRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@Tag(name = "지갑", description = "포인트 관련 API") +@RestController +@RequestMapping("/api/wallet") +@RequiredArgsConstructor +public class WalletController { + + private final WalletRepository walletRepository; + + @Operation(summary = "포인트 잔액 조회", description = "현재 로그인된 사용자의 포인트 잔액을 반환합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = WalletResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 필요", + content = @Content(mediaType = "application/json", + schema = @Schema(example = "{\"message\": \"로그인이 필요합니다.\"}"))) + }) + @GetMapping("/point") + public ResponseEntity getWalletPoint(Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("message", "로그인이 필요합니다.")); + } + + Employee employee = (Employee) authentication.getPrincipal(); + Wallet wallet = walletRepository.findByEmployee(employee); + + int balance = wallet != null ? wallet.getBalance() : 0; + return ResponseEntity.ok(new WalletResponse(balance)); + } +} diff --git a/src/main/java/com/joycrew/backend/dto/LoginRequest.java b/src/main/java/com/joycrew/backend/dto/LoginRequest.java new file mode 100644 index 0000000..114286d --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/LoginRequest.java @@ -0,0 +1,22 @@ +package com.joycrew.backend.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Schema(description = "로그인 요청 DTO") +public class LoginRequest { + + @Schema(description = "이메일 주소", example = "user@example.com", required = true) + @Email + @NotBlank + private String email; + + @Schema(description = "비밀번호", example = "password123!", required = true) + @NotBlank + private String password; +} diff --git a/src/main/java/com/joycrew/backend/dto/LoginResponse.java b/src/main/java/com/joycrew/backend/dto/LoginResponse.java new file mode 100644 index 0000000..4466f52 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/LoginResponse.java @@ -0,0 +1,14 @@ +package com.joycrew.backend.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Schema(description = "로그인 응답 DTO", example = "{ \"accessToken\": \"eyJhbGciOiJIUzI1NiJ9...\" }") +public class LoginResponse { + + @Schema(description = "JWT 토큰 또는 에러 메시지", example = "eyJhbGciOiJIUzI1NiJ9...") + private String accessToken; +} diff --git a/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java b/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java new file mode 100644 index 0000000..0471280 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java @@ -0,0 +1,20 @@ +package com.joycrew.backend.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Schema(description = "사용자 프로필 응답 DTO") +public class UserProfileResponse { + + @Schema(description = "사용자 ID", example = "1") + private Long id; + + @Schema(description = "사용자 이름", example = "홍길동") + private String name; + + @Schema(description = "이메일 주소", example = "user@example.com") + private String email; +} diff --git a/src/main/java/com/joycrew/backend/dto/WalletResponse.java b/src/main/java/com/joycrew/backend/dto/WalletResponse.java new file mode 100644 index 0000000..29c1905 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/WalletResponse.java @@ -0,0 +1,14 @@ +package com.joycrew.backend.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Schema(description = "지갑 잔액 응답 DTO") +public class WalletResponse { + + @Schema(description = "현재 잔액", example = "12000") + private Integer balance; +} diff --git a/src/main/java/com/joycrew/backend/entity/Company.java b/src/main/java/com/joycrew/backend/entity/Company.java new file mode 100644 index 0000000..59b03e5 --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/Company.java @@ -0,0 +1,45 @@ +package com.joycrew.backend.entity; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; +import java.util.List; + +@Entity +@Table(name = "company") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Company { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long companyId; + + private String companyName; + private String status; + private LocalDateTime startAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + @OneToMany(mappedBy = "company") + private List employees; + + @OneToMany(mappedBy = "company") + private List departments; + + @OneToMany(mappedBy = "company") + private List adminAccessList; + + @PrePersist + protected void onCreate() { + this.createdAt = this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/joycrew/backend/entity/CompanyAdminAccess.java b/src/main/java/com/joycrew/backend/entity/CompanyAdminAccess.java new file mode 100644 index 0000000..654115d --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/CompanyAdminAccess.java @@ -0,0 +1,49 @@ +package com.joycrew.backend.entity; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "company_admin_access") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CompanyAdminAccess { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long accessId; + + @ManyToOne + @JoinColumn(name = "employee_id") + private Employee employee; + + @ManyToOne + @JoinColumn(name = "company_id") + private Company company; + + private String adminLevel; + + @ManyToOne + @JoinColumn(name = "assigned_by") + private Employee assignedBy; + + private LocalDateTime assignedAt; + private String status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + this.createdAt = this.updatedAt = LocalDateTime.now(); + this.assignedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/joycrew/backend/entity/Department.java b/src/main/java/com/joycrew/backend/entity/Department.java new file mode 100644 index 0000000..bfdec5a --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/Department.java @@ -0,0 +1,46 @@ +package com.joycrew.backend.entity; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; +import java.util.List; + +@Entity +@Table(name = "department") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Department { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long departmentId; + + private String name; + + @ManyToOne + @JoinColumn(name = "company_id") + private Company company; + + @OneToOne + @JoinColumn(name = "department_head_id") + private Employee departmentHead; + + @OneToMany(mappedBy = "department") + private List employees; + + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + this.createdAt = this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/joycrew/backend/entity/Employee.java b/src/main/java/com/joycrew/backend/entity/Employee.java new file mode 100644 index 0000000..5a4d14a --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/Employee.java @@ -0,0 +1,61 @@ +package com.joycrew.backend.entity; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; +import java.util.List; + +@Entity +@Table(name = "employee") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Employee { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long employeeId; + + @ManyToOne + @JoinColumn(name = "company_id") + private Company company; + + @ManyToOne + @JoinColumn(name = "department_id") + private Department department; + + private String passwordHash; + private String employeeName; + private String email; + private String position; + private String status; + private String role; + + private LocalDateTime lastLoginAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + @OneToMany(mappedBy = "employee") + private List wallets; + + @OneToMany(mappedBy = "sender") + private List sentTransactions; + + @OneToMany(mappedBy = "receiver") + private List receivedTransactions; + + @OneToMany(mappedBy = "employee") + private List adminAccesses; + + @PrePersist + protected void onCreate() { + this.createdAt = this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/joycrew/backend/entity/RewardPointTransaction.java b/src/main/java/com/joycrew/backend/entity/RewardPointTransaction.java new file mode 100644 index 0000000..7d951f0 --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/RewardPointTransaction.java @@ -0,0 +1,41 @@ +package com.joycrew.backend.entity; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "reward_point_transaction") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class RewardPointTransaction { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long transactionId; + + @ManyToOne + @JoinColumn(name = "sender_id") + private Employee sender; + + @ManyToOne + @JoinColumn(name = "receiver_id") + private Employee receiver; + + private Integer pointAmount; + + @Lob + private String message; + + private String type; + private LocalDateTime transactionDate; + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + this.createdAt = this.transactionDate = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/joycrew/backend/entity/Wallet.java b/src/main/java/com/joycrew/backend/entity/Wallet.java new file mode 100644 index 0000000..67292e6 --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/Wallet.java @@ -0,0 +1,38 @@ +package com.joycrew.backend.entity; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "wallet") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Wallet { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long walletId; + + @ManyToOne + @JoinColumn(name = "employee_id") + private Employee employee; + + private Integer balance; + private Integer giftablePoint; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + this.createdAt = this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java b/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java new file mode 100644 index 0000000..88947fd --- /dev/null +++ b/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java @@ -0,0 +1,10 @@ +package com.joycrew.backend.repository; + +import com.joycrew.backend.entity.Employee; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface EmployeeRepository extends JpaRepository { + Optional findByEmail(String email); +} diff --git a/src/main/java/com/joycrew/backend/repository/WalletRepository.java b/src/main/java/com/joycrew/backend/repository/WalletRepository.java new file mode 100644 index 0000000..a149af2 --- /dev/null +++ b/src/main/java/com/joycrew/backend/repository/WalletRepository.java @@ -0,0 +1,9 @@ +package com.joycrew.backend.repository; + +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface WalletRepository extends JpaRepository { + Wallet findByEmployee(Employee employee); +} diff --git a/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java b/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..c92cc9f --- /dev/null +++ b/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java @@ -0,0 +1,49 @@ +package com.joycrew.backend.security; + +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.repository.EmployeeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final EmployeeRepository employeeRepository; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + + String authHeader = request.getHeader("Authorization"); + + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); // Remove "Bearer " + String email = jwtUtil.getEmailFromToken(token); + + Employee employee = employeeRepository.findByEmail(email) + .orElse(null); + + if (employee != null) { + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + employee, null, null // 권한은 아직 처리 안함 + ); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/joycrew/backend/security/JwtUtil.java b/src/main/java/com/joycrew/backend/security/JwtUtil.java new file mode 100644 index 0000000..8a487ef --- /dev/null +++ b/src/main/java/com/joycrew/backend/security/JwtUtil.java @@ -0,0 +1,39 @@ +package com.joycrew.backend.security; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.SignatureAlgorithm; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; + +@Component +public class JwtUtil { + + private final String SECRET_KEY = "your-secret-key-should-be-at-least-256-bits-long!"; + private final long EXPIRATION_TIME = 86400000L; // 1 day + + private SecretKey getSigningKey() { + byte[] keyBytes = SECRET_KEY.getBytes(); + return Keys.hmacShaKeyFor(keyBytes); + } + + public String generateToken(String email) { + return Jwts.builder() + .setSubject(email) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .compact(); + } + + public String getEmailFromToken(String token) { + return Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody() + .getSubject(); + } +} diff --git a/src/main/java/com/joycrew/backend/service/AuthService.java b/src/main/java/com/joycrew/backend/service/AuthService.java new file mode 100644 index 0000000..879a7c5 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/AuthService.java @@ -0,0 +1,34 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.LoginRequest; +import com.joycrew.backend.dto.LoginResponse; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.security.JwtUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final EmployeeRepository employeeRepository; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + + public LoginResponse login(LoginRequest request) { + Employee employee = employeeRepository.findByEmail(request.getEmail()) + .orElseThrow(() -> new RuntimeException("이메일 또는 비밀번호가 올바르지 않습니다.")); + + System.out.println("입력된 비밀번호: " + request.getPassword()); + System.out.println("DB 해시: " + employee.getPasswordHash()); + System.out.println("일치 여부: " + passwordEncoder.matches(request.getPassword(), employee.getPasswordHash())); + + if (!passwordEncoder.matches(request.getPassword(), employee.getPasswordHash())) { + throw new RuntimeException("이메일 또는 비밀번호가 올바르지 않습니다."); + } + + return new LoginResponse(jwtUtil.generateToken(employee.getEmail())); + } +} diff --git a/src/main/java/com/joycrew/backend/service/EmployeeService.java b/src/main/java/com/joycrew/backend/service/EmployeeService.java new file mode 100644 index 0000000..164651f --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/EmployeeService.java @@ -0,0 +1,32 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.repository.EmployeeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class EmployeeService { + + private final EmployeeRepository employeeRepository; + private final PasswordEncoder passwordEncoder; + + public void registerEmployee(String email, String rawPassword, String name) { + String encodedPassword = passwordEncoder.encode(rawPassword); + + Employee newEmployee = Employee.builder() + .email(email) + .passwordHash(encodedPassword) + .employeeName(name) + .status("ACTIVE") + .role("USER") + .createdAt(LocalDateTime.now()) + .build(); + + employeeRepository.save(newEmployee); + } +} diff --git a/src/main/java/com/joycrew/backend/util/PasswordHashPrinter.java b/src/main/java/com/joycrew/backend/util/PasswordHashPrinter.java new file mode 100644 index 0000000..7a437ac --- /dev/null +++ b/src/main/java/com/joycrew/backend/util/PasswordHashPrinter.java @@ -0,0 +1,20 @@ +package com.joycrew.backend.util; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PasswordHashPrinter { + + private final PasswordEncoder passwordEncoder; + + @PostConstruct + public void print() { + String raw = "1234"; + String hash = passwordEncoder.encode(raw); + System.out.println("📌 실제 해시: " + hash); + } +} From ef7d1ddb903f261547af66336db3937039235555 Mon Sep 17 00:00:00 2001 From: yeoEun Date: Mon, 21 Jul 2025 17:59:33 +0900 Subject: [PATCH 008/135] =?UTF-8?q?feat:=20CORS=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/config/SecurityConfig.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/main/java/com/joycrew/backend/config/SecurityConfig.java b/src/main/java/com/joycrew/backend/config/SecurityConfig.java index 09dbb81..1011633 100644 --- a/src/main/java/com/joycrew/backend/config/SecurityConfig.java +++ b/src/main/java/com/joycrew/backend/config/SecurityConfig.java @@ -11,6 +11,9 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; @Configuration @RequiredArgsConstructor @@ -22,6 +25,7 @@ public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http + .cors(Customizer.withDefaults()) .csrf(csrf -> csrf.ignoringRequestMatchers( "/h2-console/**", "/api/auth/login", @@ -29,6 +33,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/swagger-ui/**", "/swagger-ui.html" )) + //.csrf(csrf -> csrf.disable()) // CSRF 비활성화 .headers(headers -> headers.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers( @@ -48,8 +53,23 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti return http.build(); } + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + + @Bean + public CorsFilter corsFilter() { + CorsConfiguration config = new CorsConfiguration(); + config.addAllowedOriginPattern("http://localhost:3000"); // 프론트 도메인만 허용하려면 "http://localhost:3000" + config.addAllowedOriginPattern("https://joycrew.co.kr"); + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + config.setAllowCredentials(true); // 쿠키 인증 등 허용 시 true + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return new CorsFilter(source); + } } From 9dae51b6f6d607bf0d9bd9017fa2e1270aaa75ad Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Thu, 24 Jul 2025 20:42:19 +0900 Subject: [PATCH 009/135] =?UTF-8?q?fix:=20CD=20=ED=8C=8C=EC=9D=B4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EC=95=88=EC=A0=95=EC=84=B1=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B3=B4=EC=95=88=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cd.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index dd90c10..18c7b10 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -26,9 +26,6 @@ jobs: - name: Locate and Prepare JAR artifact id: get_jar_path run: | - # build.gradle에서 jar { enabled = false } 설정 후, - # 기본적으로 생성되는 실행 가능한 JAR 파일명을 정확히 찾음 (예: artifactId-version.jar) - # JoyCrew의 경우 'backend-0.0.1-SNAPSHOT.jar' 형태일 가능성이 높음. JAR_FILE=$(find build/libs -name "*.jar" | head -n 1) if [ -z "$JAR_FILE" ]; then @@ -62,7 +59,9 @@ jobs: script: | set -eux - + + export JWT_SECRET_KEY="${{ secrets.JWT_SECRET_KEY }}" + sudo systemctl stop joycrew-backend || true DEPLOY_DIR="/home/${{ secrets.SSH_USER }}/app/build/libs" From 3d7200aa9f6656074acb4ba3dc0d92548644c29e Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Thu, 24 Jul 2025 20:44:02 +0900 Subject: [PATCH 010/135] =?UTF-8?q?refactor:=20=EC=97=94=ED=8B=B0=ED=8B=B0?= =?UTF-8?q?=20=EA=B4=80=EA=B3=84=20=EB=B0=8F=20=ED=95=84=EC=88=98=20?= =?UTF-8?q?=EC=86=8D=EC=84=B1=20=EB=B3=B4=EA=B0=95=20=EB=B0=8F=20=EA=B4=80?= =?UTF-8?q?=EA=B3=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 44 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/build.gradle b/build.gradle index d9a3cec..7a0323a 100644 --- a/build.gradle +++ b/build.gradle @@ -23,6 +23,25 @@ repositories { mavenCentral() } +sourceSets { + main { + java { + srcDirs = ['src/main/java'] + } + resources { + srcDirs = ['src/main/resources'] + } + } + test { + java { + srcDirs = ['src/test/java'] + } + resources { + srcDirs = ['src/test/resources'] + } + } +} + dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' @@ -33,20 +52,33 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' implementation 'org.springframework.security:spring-security-crypto' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' + implementation 'org.springframework.boot:spring-boot-starter-test' + implementation 'org.springframework.security:spring-security-test' + compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.mysql:mysql-connector-j' runtimeOnly 'com.h2database:h2' + annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } +jar { + enabled = false +} + tasks.named('test') { useJUnitPlatform() -} -jar { - enabled = false -} + filter { + includeTestsMatching "com.joycrew.backend.service.*Test" + includeTestsMatching "com.joycrew.backend.repository.*Test" + includeTestsMatching "com.joycrew.backend.controller.*Test" + } + + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + showStandardStreams = true + } +} \ No newline at end of file From 5b56c339f200b4ead64bda55d8b89effdd56c0ba Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Thu, 24 Jul 2025 20:44:55 +0900 Subject: [PATCH 011/135] =?UTF-8?q?refactor:=20=EC=97=94=ED=8B=B0=ED=8B=B0?= =?UTF-8?q?=20=EA=B4=80=EA=B3=84=20=EB=B0=8F=20=ED=95=84=EC=88=98=20?= =?UTF-8?q?=EC=86=8D=EC=84=B1=20=EB=B3=B4=EA=B0=95=20=EB=B0=8F=20=EA=B4=80?= =?UTF-8?q?=EA=B3=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/config/SecurityConfig.java | 68 ++++++++++++---- .../backend/controller/AuthController.java | 38 +++++++-- .../backend/controller/UserController.java | 38 +++++++-- .../backend/controller/WalletController.java | 26 ++++-- .../joycrew/backend/dto/LoginResponse.java | 24 +++++- ...esponse.java => PointBalanceResponse.java} | 9 ++- .../backend/dto/UserProfileResponse.java | 24 +++++- .../com/joycrew/backend/entity/Company.java | 19 +++-- .../backend/entity/CompanyAdminAccess.java | 36 ++++++--- .../joycrew/backend/entity/Department.java | 15 ++-- .../com/joycrew/backend/entity/Employee.java | 79 ++++++++++++++++--- .../entity/RewardPointTransaction.java | 19 +++-- .../com/joycrew/backend/entity/Wallet.java | 16 +++- .../backend/entity/enums/AccessStatus.java | 7 ++ .../backend/entity/enums/AdminLevel.java | 8 ++ .../backend/entity/enums/TransactionType.java | 10 +++ .../backend/entity/enums/UserRole.java | 8 ++ .../backend/repository/CompanyRepository.java | 7 ++ .../repository/EmployeeRepository.java | 2 +- .../backend/repository/WalletRepository.java | 7 +- .../security/EmployeeDetailsService.java | 24 ++++++ .../security/JwtAuthenticationFilter.java | 19 ++--- .../com/joycrew/backend/security/JwtUtil.java | 12 ++- .../joycrew/backend/service/AuthService.java | 55 +++++++++++-- .../backend/service/EmployeeService.java | 31 +++++++- .../backend/util/PasswordHashPrinter.java | 20 ----- src/main/resources/application-dev.yml | 16 ++-- src/main/resources/application-prod.yml | 15 +++- src/main/resources/application.yml | 12 ++- 29 files changed, 516 insertions(+), 148 deletions(-) rename src/main/java/com/joycrew/backend/dto/{WalletResponse.java => PointBalanceResponse.java} (60%) create mode 100644 src/main/java/com/joycrew/backend/entity/enums/AccessStatus.java create mode 100644 src/main/java/com/joycrew/backend/entity/enums/AdminLevel.java create mode 100644 src/main/java/com/joycrew/backend/entity/enums/TransactionType.java create mode 100644 src/main/java/com/joycrew/backend/entity/enums/UserRole.java create mode 100644 src/main/java/com/joycrew/backend/repository/CompanyRepository.java create mode 100644 src/main/java/com/joycrew/backend/security/EmployeeDetailsService.java delete mode 100644 src/main/java/com/joycrew/backend/util/PasswordHashPrinter.java diff --git a/src/main/java/com/joycrew/backend/config/SecurityConfig.java b/src/main/java/com/joycrew/backend/config/SecurityConfig.java index 1011633..cacc67e 100644 --- a/src/main/java/com/joycrew/backend/config/SecurityConfig.java +++ b/src/main/java/com/joycrew/backend/config/SecurityConfig.java @@ -1,40 +1,49 @@ package com.joycrew.backend.config; +import com.fasterxml.jackson.databind.ObjectMapper; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.security.JwtAuthenticationFilter; import com.joycrew.backend.security.JwtUtil; +import com.joycrew.backend.security.EmployeeDetailsService; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.CorsFilter; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.core.userdetails.UserDetailsService; + +import java.util.Map; + @Configuration +@EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { private final JwtUtil jwtUtil; private final EmployeeRepository employeeRepository; + private final ObjectMapper objectMapper; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .cors(Customizer.withDefaults()) - .csrf(csrf -> csrf.ignoringRequestMatchers( - "/h2-console/**", - "/api/auth/login", - "/v3/api-docs/**", - "/swagger-ui/**", - "/swagger-ui.html" - )) - //.csrf(csrf -> csrf.disable()) // CSRF 비활성화 - .headers(headers -> headers.disable()) + .csrf(csrf -> csrf.disable()) + .headers(headers -> headers + .frameOptions(frameOptions -> frameOptions.disable())) .authorizeHttpRequests(auth -> auth .requestMatchers( "/h2-console/**", @@ -45,15 +54,29 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti ).permitAll() .anyRequest().authenticated() ) - .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, employeeRepository), - org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class) + .exceptionHandling(exceptions -> exceptions + .authenticationEntryPoint((request, response, authException) -> { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType("application/json;charset=UTF-8"); + + // JSON 형식으로 에러 메시지 생성 + String jsonResponse = objectMapper.writeValueAsString( + Map.of("message", "로그인이 필요합니다.") + ); + + response.getWriter().write(jsonResponse); + }) + ) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, userDetailsService()), + UsernamePasswordAuthenticationFilter.class) .formLogin(form -> form.disable()) - .httpBasic(Customizer.withDefaults()); + .httpBasic(httpBasic -> httpBasic.disable()); return http.build(); } - @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); @@ -62,14 +85,27 @@ public PasswordEncoder passwordEncoder() { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); - config.addAllowedOriginPattern("http://localhost:3000"); // 프론트 도메인만 허용하려면 "http://localhost:3000" + config.addAllowedOriginPattern("http://localhost:3000"); config.addAllowedOriginPattern("https://joycrew.co.kr"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); - config.setAllowCredentials(true); // 쿠키 인증 등 허용 시 true + config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } -} + + @Bean + public UserDetailsService userDetailsService() { + return new EmployeeDetailsService(employeeRepository); + } + + @Bean + public AuthenticationManager authenticationManager(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) { + DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider(); + authenticationProvider.setUserDetailsService(userDetailsService); + authenticationProvider.setPasswordEncoder(passwordEncoder); + return new ProviderManager(authenticationProvider); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/controller/AuthController.java b/src/main/java/com/joycrew/backend/controller/AuthController.java index 79487d3..6350d98 100644 --- a/src/main/java/com/joycrew/backend/controller/AuthController.java +++ b/src/main/java/com/joycrew/backend/controller/AuthController.java @@ -9,12 +9,17 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.web.bind.annotation.*; +import java.util.Map; + @Tag(name = "인증", description = "로그인 관련 API") @RestController @RequestMapping("/api/auth") @@ -23,22 +28,43 @@ public class AuthController { private final AuthService authService; - @Operation(summary = "로그인", description = "이메일과 비밀번호를 이용해 JWT 토큰을 반환합니다.") + @Operation(summary = "로그인", description = "이메일과 비밀번호를 이용해 JWT 토큰과 사용자 정보를 반환합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "로그인 성공", content = @Content(mediaType = "application/json", schema = @Schema(implementation = LoginResponse.class))), - @ApiResponse(responseCode = "401", description = "이메일 또는 비밀번호 오류", + @ApiResponse(responseCode = "401", description = "인증 실패 (이메일 또는 비밀번호 오류)", content = @Content(mediaType = "application/json", - schema = @Schema(implementation = LoginResponse.class))) + schema = @Schema(example = "{\"accessToken\": \"\", \"message\": \"이메일 또는 비밀번호가 올바르지 않습니다.\"}"))) }) @PostMapping("/login") public ResponseEntity login(@RequestBody @Valid LoginRequest request) { try { return ResponseEntity.ok(authService.login(request)); - } catch (RuntimeException e) { + } catch (UsernameNotFoundException | BadCredentialsException e) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(new LoginResponse("이메일 또는 비밀번호가 올바르지 않습니다.")); + .body(LoginResponse.builder() + .accessToken("") + .message(e.getMessage()) + .build()); + } catch (RuntimeException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(LoginResponse.builder() + .accessToken("") + .message("서버 오류가 발생했습니다.") + .build()); } } -} + + @Operation(summary = "로그아웃", description = "사용자의 로그아웃 요청을 처리합니다. 클라이언트는 이 응답을 받은 후 토큰을 삭제해야 합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그아웃 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(example = "{\"message\": \"로그아웃 되었습니다.\"}"))) + }) + @PostMapping("/logout") + public ResponseEntity> logout(HttpServletRequest request) { + authService.logout(request); + return ResponseEntity.ok(Map.of("message", "로그아웃 되었습니다.")); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/controller/UserController.java b/src/main/java/com/joycrew/backend/controller/UserController.java index 73dd4e3..c87e3ec 100644 --- a/src/main/java/com/joycrew/backend/controller/UserController.java +++ b/src/main/java/com/joycrew/backend/controller/UserController.java @@ -2,6 +2,9 @@ import com.joycrew.backend.dto.UserProfileResponse; import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.WalletRepository; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; @@ -15,6 +18,7 @@ import org.springframework.web.bind.annotation.*; import java.util.Map; +import java.util.Optional; @Tag(name = "사용자", description = "사용자 정보 관련 API") @RestController @@ -22,6 +26,9 @@ @RequiredArgsConstructor public class UserController { + private final EmployeeRepository employeeRepository; + private final WalletRepository walletRepository; + @Operation(summary = "사용자 프로필 조회", description = "JWT 토큰으로 인증된 사용자의 프로필 정보를 반환합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", @@ -37,14 +44,31 @@ public ResponseEntity getProfile(Authentication authentication) { .body(Map.of("message", "유효하지 않은 토큰입니다.")); } - Employee employee = (Employee) authentication.getPrincipal(); + String userEmail = authentication.getName(); + + Employee employee = employeeRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("인증된 사용자를 찾을 수 없습니다.")); + + Optional walletOptional = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()); + int totalBalance = 0; + int giftableBalance = 0; + if (walletOptional.isPresent()) { + Wallet wallet = walletOptional.get(); + totalBalance = wallet.getBalance(); + giftableBalance = wallet.getGiftablePoint(); + } - UserProfileResponse response = new UserProfileResponse( - employee.getEmployeeId(), - employee.getEmployeeName(), - employee.getEmail() - ); + UserProfileResponse response = UserProfileResponse.builder() + .employeeId(employee.getEmployeeId()) + .name(employee.getEmployeeName()) + .email(employee.getEmail()) + .role(employee.getRole()) + .department(employee.getDepartment() != null ? employee.getDepartment().getName() : null) // 부서 이름 추가 + .position(employee.getPosition()) + .totalBalance(totalBalance) + .giftableBalance(giftableBalance) + .build(); return ResponseEntity.ok(response); } -} +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/controller/WalletController.java b/src/main/java/com/joycrew/backend/controller/WalletController.java index d298892..0a6a5b4 100644 --- a/src/main/java/com/joycrew/backend/controller/WalletController.java +++ b/src/main/java/com/joycrew/backend/controller/WalletController.java @@ -1,6 +1,6 @@ package com.joycrew.backend.controller; -import com.joycrew.backend.dto.WalletResponse; +import com.joycrew.backend.dto.PointBalanceResponse; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.Wallet; import com.joycrew.backend.repository.WalletRepository; @@ -17,6 +17,7 @@ import org.springframework.web.bind.annotation.*; import java.util.Map; +import java.util.Optional; @Tag(name = "지갑", description = "포인트 관련 API") @RestController @@ -30,7 +31,7 @@ public class WalletController { @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(mediaType = "application/json", - schema = @Schema(implementation = WalletResponse.class))), + schema = @Schema(implementation = PointBalanceResponse.class))), @ApiResponse(responseCode = "401", description = "인증 필요", content = @Content(mediaType = "application/json", schema = @Schema(example = "{\"message\": \"로그인이 필요합니다.\"}"))) @@ -42,10 +43,21 @@ public ResponseEntity getWalletPoint(Authentication authentication) { .body(Map.of("message", "로그인이 필요합니다.")); } - Employee employee = (Employee) authentication.getPrincipal(); - Wallet wallet = walletRepository.findByEmployee(employee); + String userEmail = authentication.getName(); - int balance = wallet != null ? wallet.getBalance() : 0; - return ResponseEntity.ok(new WalletResponse(balance)); + Long employeeId = ((Employee) authentication.getPrincipal()).getEmployeeId(); + + Optional walletOptional = walletRepository.findByEmployee_EmployeeId(employeeId); + + int totalBalance = 0; + int giftableBalance = 0; + + if (walletOptional.isPresent()) { + Wallet wallet = walletOptional.get(); + totalBalance = wallet.getBalance(); + giftableBalance = wallet.getGiftablePoint(); + } + + return ResponseEntity.ok(new PointBalanceResponse(totalBalance, giftableBalance)); } -} +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/LoginResponse.java b/src/main/java/com/joycrew/backend/dto/LoginResponse.java index 4466f52..97c4622 100644 --- a/src/main/java/com/joycrew/backend/dto/LoginResponse.java +++ b/src/main/java/com/joycrew/backend/dto/LoginResponse.java @@ -1,14 +1,32 @@ package com.joycrew.backend.dto; +import com.joycrew.backend.entity.enums.UserRole; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; +import lombok.Builder; // @Builder 어노테이션 추가 import lombok.Getter; @Getter @AllArgsConstructor -@Schema(description = "로그인 응답 DTO", example = "{ \"accessToken\": \"eyJhbGciOiJIUzI1NiJ9...\" }") +@Builder +@Schema(description = "로그인 응답 DTO") public class LoginResponse { - @Schema(description = "JWT 토큰 또는 에러 메시지", example = "eyJhbGciOiJIUzI1NiJ9...") + @Schema(description = "JWT 토큰", example = "eyJhbGciOiJIUzI1NiJ9...") private String accessToken; -} + + @Schema(description = "응답 메시지 (성공/실패)", example = "로그인 성공" ) + private String message; + + @Schema(description = "사용자 고유 ID", example = "1") + private Long userId; + + @Schema(description = "사용자 이름", example = "홍길동") + private String name; + + @Schema(description = "사용자 이메일", example = "user@example.com") + private String email; + + @Schema(description = "사용자 역할", example = "EMPLOYEE", allowableValues = {"EMPLOYEE", "MANAGER", "HR_ADMIN", "SUPER_ADMIN"}) + private UserRole role; // ENUM 타입 유지 +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/WalletResponse.java b/src/main/java/com/joycrew/backend/dto/PointBalanceResponse.java similarity index 60% rename from src/main/java/com/joycrew/backend/dto/WalletResponse.java rename to src/main/java/com/joycrew/backend/dto/PointBalanceResponse.java index 29c1905..2a0c9ad 100644 --- a/src/main/java/com/joycrew/backend/dto/WalletResponse.java +++ b/src/main/java/com/joycrew/backend/dto/PointBalanceResponse.java @@ -7,8 +7,11 @@ @Getter @AllArgsConstructor @Schema(description = "지갑 잔액 응답 DTO") -public class WalletResponse { +public class PointBalanceResponse { @Schema(description = "현재 잔액", example = "12000") - private Integer balance; -} + private Integer totalBalance; + + @Schema(description = "선물 가능한 포인트", example = "500") + private Integer giftableBalance; +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java b/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java index 0471280..785e540 100644 --- a/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java +++ b/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java @@ -1,20 +1,38 @@ package com.joycrew.backend.dto; +import com.joycrew.backend.entity.enums.UserRole; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; @Getter @AllArgsConstructor +@Builder @Schema(description = "사용자 프로필 응답 DTO") public class UserProfileResponse { - @Schema(description = "사용자 ID", example = "1") - private Long id; + @Schema(description = "사용자 고유 ID", example = "1") + private Long employeeId; @Schema(description = "사용자 이름", example = "홍길동") private String name; @Schema(description = "이메일 주소", example = "user@example.com") private String email; -} + + @Schema(description = "현재 총 포인트 잔액", example = "1200") + private Integer totalBalance; + + @Schema(description = "현재 선물 가능한 포인트 잔액", example = "50") + private Integer giftableBalance; + + @Schema(description = "사용자 역할", example = "EMPLOYEE", allowableValues = {"EMPLOYEE", "MANAGER", "HR_ADMIN", "SUPER_ADMIN"}) + private UserRole role; + + @Schema(description = "소속 부서", example = "개발팀", nullable = true) + private String department; + + @Schema(description = "직책", example = "대리", nullable = true) + private String position; +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/Company.java b/src/main/java/com/joycrew/backend/entity/Company.java index 59b03e5..0750571 100644 --- a/src/main/java/com/joycrew/backend/entity/Company.java +++ b/src/main/java/com/joycrew/backend/entity/Company.java @@ -21,25 +21,32 @@ public class Company { private String companyName; private String status; private LocalDateTime startAt; - private LocalDateTime createdAt; - private LocalDateTime updatedAt; - @OneToMany(mappedBy = "company") + @Column(nullable = false) + private Double totalCompanyBalance; + + @OneToMany(mappedBy = "company", cascade = CascadeType.ALL, orphanRemoval = true) private List employees; - @OneToMany(mappedBy = "company") + @OneToMany(mappedBy = "company", cascade = CascadeType.ALL, orphanRemoval = true) private List departments; - @OneToMany(mappedBy = "company") + @OneToMany(mappedBy = "company", cascade = CascadeType.ALL, orphanRemoval = true) private List adminAccessList; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + @PrePersist protected void onCreate() { this.createdAt = this.updatedAt = LocalDateTime.now(); + if (this.totalCompanyBalance == null) { + this.totalCompanyBalance = 0.0; + } } @PreUpdate protected void onUpdate() { this.updatedAt = LocalDateTime.now(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/CompanyAdminAccess.java b/src/main/java/com/joycrew/backend/entity/CompanyAdminAccess.java index 654115d..c7d9a00 100644 --- a/src/main/java/com/joycrew/backend/entity/CompanyAdminAccess.java +++ b/src/main/java/com/joycrew/backend/entity/CompanyAdminAccess.java @@ -1,5 +1,7 @@ package com.joycrew.backend.entity; +import com.joycrew.backend.entity.enums.AccessStatus; +import com.joycrew.backend.entity.enums.AdminLevel; import jakarta.persistence.*; import lombok.*; import java.time.LocalDateTime; @@ -17,33 +19,47 @@ public class CompanyAdminAccess { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long accessId; - @ManyToOne - @JoinColumn(name = "employee_id") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "employee_id", nullable = false) private Employee employee; - @ManyToOne - @JoinColumn(name = "company_id") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "company_id", nullable = false) private Company company; - private String adminLevel; + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private AdminLevel adminLevel; - @ManyToOne - @JoinColumn(name = "assigned_by") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "assigned_by", nullable = true) private Employee assignedBy; + @Column(nullable = false) private LocalDateTime assignedAt; - private String status; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private AccessStatus status; + + @Column(nullable = false) private LocalDateTime createdAt; + @Column(nullable = false) private LocalDateTime updatedAt; @PrePersist protected void onCreate() { this.createdAt = this.updatedAt = LocalDateTime.now(); - this.assignedAt = LocalDateTime.now(); + if (this.assignedAt == null) { + this.assignedAt = LocalDateTime.now(); + } + if (this.status == null) { + this.status = AccessStatus.ACTIVE; + } } @PreUpdate protected void onUpdate() { this.updatedAt = LocalDateTime.now(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/Department.java b/src/main/java/com/joycrew/backend/entity/Department.java index bfdec5a..25e6426 100644 --- a/src/main/java/com/joycrew/backend/entity/Department.java +++ b/src/main/java/com/joycrew/backend/entity/Department.java @@ -18,20 +18,23 @@ public class Department { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long departmentId; + @Column(nullable = false) private String name; - @ManyToOne - @JoinColumn(name = "company_id") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "company_id", nullable = false) private Company company; - @OneToOne - @JoinColumn(name = "department_head_id") + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "department_head_id", nullable = true) private Employee departmentHead; - @OneToMany(mappedBy = "department") + @OneToMany(mappedBy = "department", cascade = CascadeType.ALL, orphanRemoval = true) private List employees; + @Column(nullable = false) private LocalDateTime createdAt; + @Column(nullable = false) private LocalDateTime updatedAt; @PrePersist @@ -43,4 +46,4 @@ protected void onCreate() { protected void onUpdate() { this.updatedAt = LocalDateTime.now(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/Employee.java b/src/main/java/com/joycrew/backend/entity/Employee.java index 5a4d14a..16faf18 100644 --- a/src/main/java/com/joycrew/backend/entity/Employee.java +++ b/src/main/java/com/joycrew/backend/entity/Employee.java @@ -1,10 +1,17 @@ package com.joycrew.backend.entity; +import com.joycrew.backend.entity.enums.UserRole; import jakarta.persistence.*; import lombok.*; import java.time.LocalDateTime; import java.util.List; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import java.util.Collection; +import java.util.Collections; + @Entity @Table(name = "employee") @Getter @@ -12,50 +19,98 @@ @NoArgsConstructor @AllArgsConstructor @Builder -public class Employee { +public class Employee implements UserDetails { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long employeeId; - @ManyToOne - @JoinColumn(name = "company_id") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "company_id", nullable = false) private Company company; - @ManyToOne - @JoinColumn(name = "department_id") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "department_id", nullable = true) private Department department; + @Column(nullable = false) private String passwordHash; + @Column(nullable = false) private String employeeName; + @Column(nullable = false, unique = true) private String email; private String position; + @Column(nullable = false) private String status; - private String role; + @Column(nullable = false) + private UserRole role; private LocalDateTime lastLoginAt; + @Column(nullable = false) private LocalDateTime createdAt; + @Column(nullable = false) private LocalDateTime updatedAt; - @OneToMany(mappedBy = "employee") - private List wallets; + @OneToOne(mappedBy = "employee", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private Wallet wallet; - @OneToMany(mappedBy = "sender") + @OneToMany(mappedBy = "sender", cascade = CascadeType.ALL, orphanRemoval = true) private List sentTransactions; - @OneToMany(mappedBy = "receiver") + @OneToMany(mappedBy = "receiver", cascade = CascadeType.ALL, orphanRemoval = true) private List receivedTransactions; - @OneToMany(mappedBy = "employee") + @OneToMany(mappedBy = "employee", cascade = CascadeType.ALL, orphanRemoval = true) private List adminAccesses; @PrePersist protected void onCreate() { this.createdAt = this.updatedAt = LocalDateTime.now(); + if (this.status == null) { + this.status = "ACTIVE"; + } + if (this.role == null) { + this.role = UserRole.EMPLOYEE; + } } @PreUpdate protected void onUpdate() { this.updatedAt = LocalDateTime.now(); } -} + + @Override + public Collection getAuthorities() { + return Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + this.role)); + } + + @Override + public String getPassword() { + return this.passwordHash; + } + + @Override + public String getUsername() { + return this.email; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return "ACTIVE".equals(this.status); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/RewardPointTransaction.java b/src/main/java/com/joycrew/backend/entity/RewardPointTransaction.java index 7d951f0..75e3c5a 100644 --- a/src/main/java/com/joycrew/backend/entity/RewardPointTransaction.java +++ b/src/main/java/com/joycrew/backend/entity/RewardPointTransaction.java @@ -1,5 +1,6 @@ package com.joycrew.backend.entity; +import com.joycrew.backend.entity.enums.TransactionType; import jakarta.persistence.*; import lombok.*; import java.time.LocalDateTime; @@ -17,25 +18,31 @@ public class RewardPointTransaction { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long transactionId; - @ManyToOne - @JoinColumn(name = "sender_id") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sender_id", nullable = true) private Employee sender; - @ManyToOne - @JoinColumn(name = "receiver_id") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "receiver_id", nullable = false) private Employee receiver; + @Column(nullable = false) private Integer pointAmount; @Lob private String message; - private String type; + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private TransactionType type; + + @Column(nullable = false) private LocalDateTime transactionDate; + @Column(nullable = false) private LocalDateTime createdAt; @PrePersist protected void onCreate() { this.createdAt = this.transactionDate = LocalDateTime.now(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/Wallet.java b/src/main/java/com/joycrew/backend/entity/Wallet.java index 67292e6..277de88 100644 --- a/src/main/java/com/joycrew/backend/entity/Wallet.java +++ b/src/main/java/com/joycrew/backend/entity/Wallet.java @@ -17,22 +17,32 @@ public class Wallet { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long walletId; - @ManyToOne - @JoinColumn(name = "employee_id") + @OneToOne + @JoinColumn(name = "employee_id", nullable = false, unique = true) private Employee employee; + @Column(nullable = false) private Integer balance; + @Column(nullable = false) private Integer giftablePoint; + @Column(nullable = false) private LocalDateTime createdAt; + @Column(nullable = false) private LocalDateTime updatedAt; @PrePersist protected void onCreate() { this.createdAt = this.updatedAt = LocalDateTime.now(); + if (this.balance == null) { + this.balance = 0; + } + if (this.giftablePoint == null) { + this.giftablePoint = 0; + } } @PreUpdate protected void onUpdate() { this.updatedAt = LocalDateTime.now(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/enums/AccessStatus.java b/src/main/java/com/joycrew/backend/entity/enums/AccessStatus.java new file mode 100644 index 0000000..abd5085 --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/enums/AccessStatus.java @@ -0,0 +1,7 @@ +package com.joycrew.backend.entity.enums; + +public enum AccessStatus { + ACTIVE, + INACTIVE, + REVOKED +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/enums/AdminLevel.java b/src/main/java/com/joycrew/backend/entity/enums/AdminLevel.java new file mode 100644 index 0000000..1faf9f7 --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/enums/AdminLevel.java @@ -0,0 +1,8 @@ +package com.joycrew.backend.entity.enums; + +public enum AdminLevel { + SUPER_ADMIN, + HR_ADMIN, + MANAGER, + EMPLOYEE +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/enums/TransactionType.java b/src/main/java/com/joycrew/backend/entity/enums/TransactionType.java new file mode 100644 index 0000000..58900fb --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/enums/TransactionType.java @@ -0,0 +1,10 @@ +package com.joycrew.backend.entity.enums; + +public enum TransactionType { + AWARD_P2P, + AWARD_MANAGER_SPOT, + AWARD_AUTOMATED, + REDEEM_ITEM, + ADMIN_ADJUSTMENT, + EXPIRE_POINTS +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/enums/UserRole.java b/src/main/java/com/joycrew/backend/entity/enums/UserRole.java new file mode 100644 index 0000000..538f934 --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/enums/UserRole.java @@ -0,0 +1,8 @@ +package com.joycrew.backend.entity.enums; + +public enum UserRole { + EMPLOYEE, + MANAGER, + HR_ADMIN, + SUPER_ADMIN +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/repository/CompanyRepository.java b/src/main/java/com/joycrew/backend/repository/CompanyRepository.java new file mode 100644 index 0000000..0a8799a --- /dev/null +++ b/src/main/java/com/joycrew/backend/repository/CompanyRepository.java @@ -0,0 +1,7 @@ +package com.joycrew.backend.repository; + +import com.joycrew.backend.entity.Company; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CompanyRepository extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java b/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java index 88947fd..7d06509 100644 --- a/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java +++ b/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java @@ -7,4 +7,4 @@ public interface EmployeeRepository extends JpaRepository { Optional findByEmail(String email); -} +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/repository/WalletRepository.java b/src/main/java/com/joycrew/backend/repository/WalletRepository.java index a149af2..8400560 100644 --- a/src/main/java/com/joycrew/backend/repository/WalletRepository.java +++ b/src/main/java/com/joycrew/backend/repository/WalletRepository.java @@ -1,9 +1,10 @@ package com.joycrew.backend.repository; -import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.Wallet; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface WalletRepository extends JpaRepository { - Wallet findByEmployee(Employee employee); -} + Optional findByEmployee_EmployeeId(Long employeeId); +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/security/EmployeeDetailsService.java b/src/main/java/com/joycrew/backend/security/EmployeeDetailsService.java new file mode 100644 index 0000000..86f1233 --- /dev/null +++ b/src/main/java/com/joycrew/backend/security/EmployeeDetailsService.java @@ -0,0 +1,24 @@ +package com.joycrew.backend.security; + +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.repository.EmployeeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class EmployeeDetailsService implements UserDetailsService { + + private final EmployeeRepository employeeRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + Employee employee = employeeRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("User not found with email: " + email)); + + return employee; + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java b/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java index c92cc9f..4ab39a9 100644 --- a/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java @@ -1,10 +1,10 @@ package com.joycrew.backend.security; -import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.repository.EmployeeRepository; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.web.filter.OncePerRequestFilter; import jakarta.servlet.FilterChain; @@ -17,7 +17,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; - private final EmployeeRepository employeeRepository; + private final UserDetailsService userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, @@ -28,16 +28,17 @@ protected void doFilterInternal(HttpServletRequest request, String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { - String token = authHeader.substring(7); // Remove "Bearer " + String token = authHeader.substring(7); String email = jwtUtil.getEmailFromToken(token); - Employee employee = employeeRepository.findByEmail(email) - .orElse(null); + UserDetails userDetails = userDetailsService.loadUserByUsername(email); - if (employee != null) { + if (userDetails != null) { UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( - employee, null, null // 권한은 아직 처리 안함 + userDetails, + null, + userDetails.getAuthorities() ); SecurityContextHolder.getContext().setAuthentication(authentication); @@ -46,4 +47,4 @@ protected void doFilterInternal(HttpServletRequest request, filterChain.doFilter(request, response); } -} +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/security/JwtUtil.java b/src/main/java/com/joycrew/backend/security/JwtUtil.java index 8a487ef..10d37ef 100644 --- a/src/main/java/com/joycrew/backend/security/JwtUtil.java +++ b/src/main/java/com/joycrew/backend/security/JwtUtil.java @@ -3,19 +3,23 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.SignatureAlgorithm; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import java.util.Date; +import java.nio.charset.StandardCharsets; @Component public class JwtUtil { - private final String SECRET_KEY = "your-secret-key-should-be-at-least-256-bits-long!"; - private final long EXPIRATION_TIME = 86400000L; // 1 day + @Value("${jwt.secret}") + private String secretKey; + + private final long EXPIRATION_TIME = 86400000L; private SecretKey getSigningKey() { - byte[] keyBytes = SECRET_KEY.getBytes(); + byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8); return Keys.hmacShaKeyFor(keyBytes); } @@ -36,4 +40,4 @@ public String getEmailFromToken(String token) { .getBody() .getSubject(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/AuthService.java b/src/main/java/com/joycrew/backend/service/AuthService.java index 879a7c5..be08034 100644 --- a/src/main/java/com/joycrew/backend/service/AuthService.java +++ b/src/main/java/com/joycrew/backend/service/AuthService.java @@ -5,7 +5,15 @@ import com.joycrew.backend.entity.Employee; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.security.JwtUtil; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -13,22 +21,53 @@ @RequiredArgsConstructor public class AuthService { + private static final Logger log = LoggerFactory.getLogger(AuthService.class); + private final EmployeeRepository employeeRepository; private final PasswordEncoder passwordEncoder; private final JwtUtil jwtUtil; + private final AuthenticationManager authenticationManager; public LoginResponse login(LoginRequest request) { - Employee employee = employeeRepository.findByEmail(request.getEmail()) - .orElseThrow(() -> new RuntimeException("이메일 또는 비밀번호가 올바르지 않습니다.")); + log.info("Attempting login for email: {}", request.getEmail()); + + try { + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword()) + ); + + Employee employee = employeeRepository.findByEmail(request.getEmail()) + .orElseThrow(() -> new UsernameNotFoundException("사용자 정보를 찾을 수 없습니다.")); + + String accessToken = jwtUtil.generateToken(employee.getEmail()); + + return LoginResponse.builder() + .accessToken(accessToken) + .message("로그인 성공") + .userId(employee.getEmployeeId()) + .name(employee.getEmployeeName()) + .email(employee.getEmail()) + .role(employee.getRole()) + .build(); + } catch (UsernameNotFoundException | BadCredentialsException e) { + log.warn("Login failed for email {}: {}", request.getEmail(), e.getMessage()); + throw e; + } catch (Exception e) { + log.error("An unexpected error occurred during login for email {}: {}", request.getEmail(), e.getMessage(), e); + throw new RuntimeException("로그인 중 서버 오류가 발생했습니다."); + } + } - System.out.println("입력된 비밀번호: " + request.getPassword()); - System.out.println("DB 해시: " + employee.getPasswordHash()); - System.out.println("일치 여부: " + passwordEncoder.matches(request.getPassword(), employee.getPasswordHash())); + public void logout(HttpServletRequest request) { + final String authHeader = request.getHeader("Authorization"); + final String jwt; - if (!passwordEncoder.matches(request.getPassword(), employee.getPasswordHash())) { - throw new RuntimeException("이메일 또는 비밀번호가 올바르지 않습니다."); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + log.warn("Logout request received without a valid Bearer token."); + return; } - return new LoginResponse(jwtUtil.generateToken(employee.getEmail())); + jwt = authHeader.substring(7); + log.info("Logout request received for a token. In a real application, this token should be blacklisted."); } } diff --git a/src/main/java/com/joycrew/backend/service/EmployeeService.java b/src/main/java/com/joycrew/backend/service/EmployeeService.java index 164651f..8a1829b 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeService.java @@ -1,10 +1,15 @@ package com.joycrew.backend.service; +import com.joycrew.backend.entity.Company; import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.UserRole; import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.WalletRepository; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; @@ -14,8 +19,14 @@ public class EmployeeService { private final EmployeeRepository employeeRepository; private final PasswordEncoder passwordEncoder; + private final WalletRepository walletRepository; + + @Transactional + public void registerEmployee(String email, String rawPassword, String name, Company company) { + if (employeeRepository.findByEmail(email).isPresent()) { + throw new RuntimeException("이미 존재하는 이메일입니다."); + } - public void registerEmployee(String email, String rawPassword, String name) { String encodedPassword = passwordEncoder.encode(rawPassword); Employee newEmployee = Employee.builder() @@ -23,10 +34,22 @@ public void registerEmployee(String email, String rawPassword, String name) { .passwordHash(encodedPassword) .employeeName(name) .status("ACTIVE") - .role("USER") + .role(UserRole.EMPLOYEE) .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .company(company) + .build(); + + Employee savedEmployee = employeeRepository.save(newEmployee); + + Wallet newWallet = Wallet.builder() + .employee(savedEmployee) + .balance(0) + .giftablePoint(0) .build(); + walletRepository.save(newWallet); - employeeRepository.save(newEmployee); + savedEmployee.setWallet(newWallet); + employeeRepository.save(savedEmployee); } -} +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/util/PasswordHashPrinter.java b/src/main/java/com/joycrew/backend/util/PasswordHashPrinter.java deleted file mode 100644 index 7a437ac..0000000 --- a/src/main/java/com/joycrew/backend/util/PasswordHashPrinter.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.joycrew.backend.util; - -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class PasswordHashPrinter { - - private final PasswordEncoder passwordEncoder; - - @PostConstruct - public void print() { - String raw = "1234"; - String hash = passwordEncoder.encode(raw); - System.out.println("📌 실제 해시: " + hash); - } -} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 7a04d84..ecd01ed 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -9,8 +9,14 @@ spring: enabled: true jpa: hibernate: - ddl-auto: create # 또는 update - show-sql: true - properties: - hibernate: - format_sql: true \ No newline at end of file + ddl-auto: create + show-sql: false + +jwt: + secret: dev-test-secret-key-for-joycrew-backend-at-least-256-bits-long! + +logging: + level: + org.hibernate.SQL: DEBUG + org.hibernate.type.descriptor.sql: TRACE + com.joycrew.backend: DEBUG \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 7326886..ea9917a 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -6,5 +6,16 @@ spring: password: ${DB_PASSWORD} jpa: hibernate: - ddl-auto: validate # 또는 none (배포 시에는 create/update 지양) - show-sql: false \ No newline at end of file + ddl-auto: validate + show-sql: false +jwt: + secret: ${JWT_SECRET_KEY} + expiration-ms: 3600000 + +logging: + level: + com.joycrew.backend: INFO + file: + name: /var/log/joycrew/app.log + max-size: 10MB + max-history: 7 \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bf6dc8c..3c54c6d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,14 +1,18 @@ server: - port: 8081 # ?? ??, ??? 8081 ??? ?? + port: 8080 spring: application: name: joycrew profiles: - active: dev # ??? H2 ?? ?? + active: dev + +jwt: + expiration-ms: 3600000 logging: + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" level: - org.hibernate.SQL: debug # ?? ?? - org.hibernate.type.descriptor.sql.BasicBinder: trace # ??? ? \ No newline at end of file + com.joycrew.backend: INFO \ No newline at end of file From b79b9a40f0d817c6b329321e7ae1c7385ffe116a Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Thu, 24 Jul 2025 20:47:51 +0900 Subject: [PATCH 012/135] =?UTF-8?q?test=20:=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/EmployeeRepositoryTest.java | 126 ++++++++++++++ .../repository/WalletRepositoryTest.java | 160 ++++++++++++++++++ .../backend/service/AuthServiceTest.java | 136 +++++++++++++++ .../backend/service/EmployeeServiceTest.java | 107 ++++++++++++ 4 files changed, 529 insertions(+) create mode 100644 src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java create mode 100644 src/test/java/com/joycrew/backend/repository/WalletRepositoryTest.java create mode 100644 src/test/java/com/joycrew/backend/service/AuthServiceTest.java create mode 100644 src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java diff --git a/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java b/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java new file mode 100644 index 0000000..4870557 --- /dev/null +++ b/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java @@ -0,0 +1,126 @@ +package com.joycrew.backend.repository; + +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.entity.Department; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.UserRole; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +class EmployeeRepositoryTest { + + @Autowired + private TestEntityManager entityManager; + @Autowired + private EmployeeRepository employeeRepository; + @Autowired + private WalletRepository walletRepository; + + private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + private Company testCompany; + private Department testDepartment; + private Employee testEmployee; + + @BeforeEach + void setUp() { + testCompany = Company.builder() + .companyName("테스트회사") + .status("ACTIVE") + .startAt(LocalDateTime.now()) + .totalCompanyBalance(0.0) + .build(); + entityManager.persist(testCompany); + + testDepartment = Department.builder() + .name("테스트부서") + .company(testCompany) + .build(); + entityManager.persist(testDepartment); + + testEmployee = Employee.builder() + .company(testCompany) + .department(testDepartment) + .email("test@joycrew.com") + .passwordHash(passwordEncoder.encode("password123")) + .employeeName("김테스트") + .position("사원") + .status("ACTIVE") + .role(UserRole.EMPLOYEE) + .lastLoginAt(null) + .build(); + entityManager.persist(testEmployee); + + Wallet testWallet = Wallet.builder() + .employee(testEmployee) + .balance(1000) + .giftablePoint(100) + .build(); + entityManager.persist(testWallet); + + entityManager.flush(); + entityManager.clear(); + } + + @Test + @DisplayName("이메일로 직원 조회 성공") + void findByEmail_Success() { + // When + Optional foundEmployee = employeeRepository.findByEmail("test@joycrew.com"); + + // Then + assertThat(foundEmployee).isPresent(); + assertThat(foundEmployee.get().getEmail()).isEqualTo("test@joycrew.com"); + assertThat(foundEmployee.get().getEmployeeName()).isEqualTo("김테스트"); + assertThat(foundEmployee.get().getCompany().getCompanyName()).isEqualTo("테스트회사"); + assertThat(foundEmployee.get().getDepartment().getName()).isEqualTo("테스트부서"); + } + + @Test + @DisplayName("이메일로 직원 조회 실패 - 존재하지 않는 이메일") + void findByEmail_NotFound() { + // When + Optional foundEmployee = employeeRepository.findByEmail("nonexistent@joycrew.com"); + + // Then + assertThat(foundEmployee).isEmpty(); + } + + @Test + @DisplayName("Employee 저장 및 조회 성공") + void saveAndFindEmployee() { + // Given + Employee newEmployee = Employee.builder() + .company(testCompany) + .department(testDepartment) + .email("new@joycrew.com") + .passwordHash(passwordEncoder.encode("newpass")) + .employeeName("새로운직원") + .position("대리") + .status("ACTIVE") + .role(UserRole.EMPLOYEE) + .build(); + + // When + Employee savedEmployee = employeeRepository.save(newEmployee); + Optional found = employeeRepository.findById(savedEmployee.getEmployeeId()); + + // Then + assertThat(found).isPresent(); + assertThat(found.get().getEmail()).isEqualTo("new@joycrew.com"); + assertThat(found.get().getEmployeeName()).isEqualTo("새로운직원"); + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/repository/WalletRepositoryTest.java b/src/test/java/com/joycrew/backend/repository/WalletRepositoryTest.java new file mode 100644 index 0000000..21eb203 --- /dev/null +++ b/src/test/java/com/joycrew/backend/repository/WalletRepositoryTest.java @@ -0,0 +1,160 @@ +package com.joycrew.backend.repository; + +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.entity.Department; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.UserRole; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +class WalletRepositoryTest { + + @Autowired + private TestEntityManager entityManager; + @Autowired + private EmployeeRepository employeeRepository; + @Autowired + private WalletRepository walletRepository; + + private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + private Company testCompany; + private Department testDepartment; + private Employee testEmployeeWithWallet; + private Employee testEmployeeWithoutWallet; + private Wallet testWallet; + + @BeforeEach + void setUp() { + testCompany = Company.builder() + .companyName("테스트회사") + .status("ACTIVE") + .startAt(LocalDateTime.now()) + .totalCompanyBalance(0.0) + .build(); + entityManager.persist(testCompany); + + testDepartment = Department.builder() + .name("테스트부서") + .company(testCompany) + .build(); + entityManager.persist(testDepartment); + + testEmployeeWithWallet = Employee.builder() + .company(testCompany) + .department(testDepartment) + .email("walletuser@joycrew.com") + .passwordHash(passwordEncoder.encode("pass123")) + .employeeName("지갑유저") + .position("선임") + .status("ACTIVE") + .role(UserRole.EMPLOYEE) + .build(); + entityManager.persist(testEmployeeWithWallet); + + testEmployeeWithoutWallet = Employee.builder() + .company(testCompany) + .department(testDepartment) + .email("nowallet@joycrew.com") + .passwordHash(passwordEncoder.encode("pass123")) + .employeeName("지갑없는유저") + .position("주니어") + .status("ACTIVE") + .role(UserRole.EMPLOYEE) + .build(); + entityManager.persist(testEmployeeWithoutWallet); + + + testWallet = Wallet.builder() + .employee(testEmployeeWithWallet) + .balance(5000) + .giftablePoint(500) + .build(); + entityManager.persist(testWallet); + + testEmployeeWithWallet.setWallet(testWallet); + entityManager.merge(testEmployeeWithWallet); + + entityManager.flush(); + entityManager.clear(); + } + + @Test + @DisplayName("Employee ID로 Wallet 조회 성공") + void findByEmployee_EmployeeId_Success() { + // When + Optional foundWallet = walletRepository.findByEmployee_EmployeeId(testEmployeeWithWallet.getEmployeeId()); + + // Then + assertThat(foundWallet).isPresent(); + assertThat(foundWallet.get().getEmployee().getEmployeeId()).isEqualTo(testEmployeeWithWallet.getEmployeeId()); + assertThat(foundWallet.get().getBalance()).isEqualTo(5000); + assertThat(foundWallet.get().getGiftablePoint()).isEqualTo(500); + } + + @Test + @DisplayName("Employee ID로 Wallet 조회 실패 - Wallet 없음") + void findByEmployee_EmployeeId_NotFound() { + // When + Optional foundWallet = walletRepository.findByEmployee_EmployeeId(testEmployeeWithoutWallet.getEmployeeId()); + + // Then + assertThat(foundWallet).isEmpty(); + } + + @Test + @DisplayName("Wallet 저장 및 조회 성공") + void saveAndFindWallet() { + // Given + Employee anotherEmployee = Employee.builder() + .company(testCompany) + .department(testDepartment) + .email("another@joycrew.com") + .passwordHash(passwordEncoder.encode("pass456")) + .employeeName("다른직원") + .position("팀장") + .status("ACTIVE") + .role(UserRole.MANAGER) + .build(); + entityManager.persist(anotherEmployee); + + Wallet newWallet = Wallet.builder() + .employee(anotherEmployee) + .balance(2000) + .giftablePoint(200) + .build(); + + // When + Wallet savedWallet = walletRepository.save(newWallet); + Optional found = walletRepository.findById(savedWallet.getWalletId()); + + // Then + assertThat(found).isPresent(); + assertThat(found.get().getBalance()).isEqualTo(2000); + assertThat(found.get().getEmployee().getEmployeeId()).isEqualTo(anotherEmployee.getEmployeeId()); + + // 양방향 관계 업데이트 + anotherEmployee.setWallet(savedWallet); + entityManager.merge(anotherEmployee); + entityManager.flush(); + entityManager.clear(); + + Optional foundEmployee = employeeRepository.findById(anotherEmployee.getEmployeeId()); + assertThat(foundEmployee).isPresent(); + assertThat(foundEmployee.get().getWallet()).isNotNull(); + assertThat(foundEmployee.get().getWallet().getWalletId()).isEqualTo(savedWallet.getWalletId()); + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/AuthServiceTest.java b/src/test/java/com/joycrew/backend/service/AuthServiceTest.java new file mode 100644 index 0000000..a78e633 --- /dev/null +++ b/src/test/java/com/joycrew/backend/service/AuthServiceTest.java @@ -0,0 +1,136 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.LoginRequest; +import com.joycrew.backend.dto.LoginResponse; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.enums.UserRole; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.security.JwtUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.quality.Strictness; +import org.mockito.junit.jupiter.MockitoSettings; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class AuthServiceTest { + + @Mock + private EmployeeRepository employeeRepository; + @Mock + private PasswordEncoder passwordEncoder; + @Mock + private JwtUtil jwtUtil; + @Mock + private AuthenticationManager authenticationManager; + + @InjectMocks + private AuthService authService; + + private Employee testEmployee; + private LoginRequest testLoginRequest; + private String encodedPassword; + private String testToken = "mocked.jwt.token"; + + @BeforeEach + void setUp() { + encodedPassword = new BCryptPasswordEncoder().encode("password123"); + + testEmployee = Employee.builder() + .employeeId(1L) + .email("test@joycrew.com") + .passwordHash(encodedPassword) + .employeeName("테스트유저") + .role(UserRole.EMPLOYEE) + .status("ACTIVE") + .build(); + + testLoginRequest = new LoginRequest(); + testLoginRequest.setEmail("test@joycrew.com"); + testLoginRequest.setPassword("password123"); + } + + @Test + @DisplayName("로그인 성공 시 JWT 토큰과 사용자 정보 반환") + void login_Success() { + // Given + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) + .thenReturn(mock(Authentication.class)); + when(employeeRepository.findByEmail(testLoginRequest.getEmail())).thenReturn(Optional.of(testEmployee)); + when(jwtUtil.generateToken(anyString())).thenReturn(testToken); + + // When + LoginResponse response = authService.login(testLoginRequest); + + // Then + assertThat(response).isNotNull(); + assertThat(response.getAccessToken()).isEqualTo(testToken); + assertThat(response.getMessage()).isEqualTo("로그인 성공"); + assertThat(response.getUserId()).isEqualTo(testEmployee.getEmployeeId()); + assertThat(response.getEmail()).isEqualTo(testEmployee.getEmail()); + assertThat(response.getRole()).isEqualTo(testEmployee.getRole()); + + // 메서드 호출 검증 + verify(authenticationManager, times(1)).authenticate(any(UsernamePasswordAuthenticationToken.class)); + verify(employeeRepository, times(1)).findByEmail(testLoginRequest.getEmail()); + verify(jwtUtil, times(1)).generateToken(testEmployee.getEmail()); + } + + @Test + @DisplayName("로그인 실패 - 이메일 없음 (UsernameNotFoundException)") + void login_Failure_EmailNotFound() { + // Given + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) + .thenThrow(new UsernameNotFoundException("이메일 또는 비밀번호가 올바르지 않습니다.")); + + // When & Then + assertThatThrownBy(() -> authService.login(testLoginRequest)) + .isInstanceOf(UsernameNotFoundException.class) + .hasMessageContaining("이메일 또는 비밀번호가 올바르지 않습니다."); + + // 메서드 호출 검증 + verify(authenticationManager, times(1)).authenticate(any(UsernamePasswordAuthenticationToken.class)); + verify(employeeRepository, never()).findByEmail(anyString()); + verify(passwordEncoder, never()).matches(anyString(), anyString()); + verify(jwtUtil, never()).generateToken(anyString()); + } + + @Test + @DisplayName("로그인 실패 - 비밀번호 불일치 (BadCredentialsException)") + void login_Failure_WrongPassword() { + // Given + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) + .thenThrow(new BadCredentialsException("이메일 또는 비밀번호가 올바르지 않습니다.")); + + // When & Then + assertThatThrownBy(() -> authService.login(testLoginRequest)) + .isInstanceOf(BadCredentialsException.class) + .hasMessageContaining("이메일 또는 비밀번호가 올바르지 않습니다."); + + // 메서드 호출 검증 + verify(authenticationManager, times(1)).authenticate(any(UsernamePasswordAuthenticationToken.class)); + verify(employeeRepository, never()).findByEmail(anyString()); + verify(passwordEncoder, never()).matches(anyString(), anyString()); + verify(jwtUtil, never()).generateToken(anyString()); + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java b/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java new file mode 100644 index 0000000..cb5069b --- /dev/null +++ b/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java @@ -0,0 +1,107 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.UserRole; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.WalletRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.quality.Strictness; +import org.mockito.junit.jupiter.MockitoSettings; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class EmployeeServiceTest { + + @Mock + private EmployeeRepository employeeRepository; + @Mock + private PasswordEncoder passwordEncoder; + @Mock + private Company mockCompany; + @Mock + private WalletRepository walletRepository; + + @InjectMocks + private EmployeeService employeeService; + + @BeforeEach + void setUp() { + employeeService = new EmployeeService(employeeRepository, passwordEncoder, walletRepository); + + when(mockCompany.getCompanyId()).thenReturn(1L); + } + + @Test + @DisplayName("직원 등록 성공") + void registerEmployee_Success() { + // Given + String email = "newuser@joycrew.com"; + String rawPassword = "newpassword123"; + String name = "새로운직원"; + String encodedPassword = "encodedPasswordHash"; + + when(employeeRepository.findByEmail(email)).thenReturn(Optional.empty()); + when(passwordEncoder.encode(rawPassword)).thenReturn(encodedPassword); + + when(employeeRepository.save(any(Employee.class))).thenAnswer(invocation -> { + Employee savedEmployee = invocation.getArgument(0); + if (savedEmployee.getEmployeeId() == null) { + savedEmployee.setEmployeeId(2L); + } + return savedEmployee; + }); + + when(walletRepository.save(any(Wallet.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + employeeService.registerEmployee(email, rawPassword, name, mockCompany); + + // Then + verify(employeeRepository, times(2)).save(any(Employee.class)); + verify(employeeRepository, times(2)).save(argThat(employee -> + employee.getEmail().equals(email) && + employee.getPasswordHash().equals(encodedPassword) && + employee.getEmployeeName().equals(name) && + employee.getStatus().equals("ACTIVE") && + employee.getRole().equals(UserRole.EMPLOYEE) && + employee.getCompany().getCompanyId().equals(mockCompany.getCompanyId()) + )); + verify(passwordEncoder, times(1)).encode(rawPassword); + verify(walletRepository, times(1)).save(any(Wallet.class)); + } + + @Test + @DisplayName("직원 등록 실패 - 이메일 중복") + void registerEmployee_Failure_EmailDuplicate() { + // Given + String email = "existing@joycrew.com"; + String rawPassword = "password123"; + String name = "기존직원"; + + when(employeeRepository.findByEmail(email)).thenReturn(Optional.of(Employee.builder().email(email).build())); + + // When & Then + assertThatThrownBy(() -> employeeService.registerEmployee(email, rawPassword, name, mockCompany)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("이미 존재하는 이메일입니다."); + + verify(employeeRepository, never()).save(any(Employee.class)); + verify(passwordEncoder, never()).encode(anyString()); + verify(walletRepository, never()).save(any(Wallet.class)); + } +} \ No newline at end of file From 1e528c779d55b41d26483898feeced6e03d0d72e Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Thu, 24 Jul 2025 20:48:37 +0900 Subject: [PATCH 013/135] =?UTF-8?q?test=20:=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AuthServiceIntegrationTest.java | 112 ++++++++++++++++++ .../EmployeeServiceIntegrationTest.java | 107 +++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java create mode 100644 src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java diff --git a/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java new file mode 100644 index 0000000..eebe61c --- /dev/null +++ b/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java @@ -0,0 +1,112 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.JoyCrewBackendApplication; +import com.joycrew.backend.dto.LoginRequest; +import com.joycrew.backend.dto.LoginResponse; +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.entity.enums.UserRole; +import com.joycrew.backend.repository.CompanyRepository; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.security.JwtUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest(classes = JoyCrewBackendApplication.class) +@ActiveProfiles("dev") +@Transactional +class AuthServiceIntegrationTest { + + @Autowired + private AuthService authService; + @Autowired + private EmployeeService employeeService; + @Autowired + private EmployeeRepository employeeRepository; + @Autowired + private JwtUtil jwtUtil; + @Autowired + private CompanyRepository companyRepository; + + private String testEmail = "integration@joycrew.com"; + private String testPassword = "integrationPass123!"; + private String testName = "통합테스트유저"; + private Company defaultCompany; + + @BeforeEach + void setUp() { + defaultCompany = Company.builder() + .companyName("테스트컴퍼니") + .status("ACTIVE") + .startAt(LocalDateTime.now()) + .totalCompanyBalance(0.0) + .build(); + defaultCompany = companyRepository.save(defaultCompany); + + employeeRepository.findByEmail(testEmail).ifPresent(employeeRepository::delete); + + employeeService.registerEmployee(testEmail, testPassword, testName, defaultCompany); + } + + @Test + @DisplayName("통합 테스트: 로그인 성공 시 JWT 토큰과 사용자 정보 반환") + void login_Integration_Success() { + // Given + LoginRequest request = new LoginRequest(); + request.setEmail(testEmail); + request.setPassword(testPassword); + + // When + LoginResponse response = authService.login(request); + + // Then + assertThat(response).isNotNull(); + assertThat(response.getAccessToken()).isNotBlank(); + assertThat(response.getMessage()).isEqualTo("로그인 성공"); + assertThat(response.getEmail()).isEqualTo(testEmail); + assertThat(response.getUserId()).isEqualTo(employeeRepository.findByEmail(testEmail).get().getEmployeeId()); + assertThat(response.getName()).isEqualTo(testName); + assertThat(response.getRole()).isEqualTo(UserRole.EMPLOYEE); + + String extractedEmail = jwtUtil.getEmailFromToken(response.getAccessToken()); + assertThat(extractedEmail).isEqualTo(testEmail); + } + + @Test + @DisplayName("통합 테스트: 로그인 실패 - 존재하지 않는 이메일") + void login_Integration_Failure_EmailNotFound() { + // Given + LoginRequest request = new LoginRequest(); + request.setEmail("nonexistent@joycrew.com"); + request.setPassword("anypassword"); + + // When & Then + assertThatThrownBy(() -> authService.login(request)) + .isInstanceOf(BadCredentialsException.class) + .hasMessageContaining("자격 증명에 실패하였습니다."); + } + + @Test + @DisplayName("통합 테스트: 로그인 실패 - 비밀번호 불일치") + void login_Integration_Failure_WrongPassword() { + // Given + LoginRequest request = new LoginRequest(); + request.setEmail(testEmail); + request.setPassword("wrongpassword"); + + // When & Then + assertThatThrownBy(() -> authService.login(request)) + .isInstanceOf(BadCredentialsException.class) + .hasMessageContaining("자격 증명에 실패하였습니다."); + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java new file mode 100644 index 0000000..16cbc3f --- /dev/null +++ b/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java @@ -0,0 +1,107 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.JoyCrewBackendApplication; +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.UserRole; +import com.joycrew.backend.repository.CompanyRepository; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.WalletRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest(classes = JoyCrewBackendApplication.class) +@Transactional +class EmployeeServiceIntegrationTest { + + @Autowired + private EmployeeService employeeService; + @Autowired + private EmployeeRepository employeeRepository; + @Autowired + private WalletRepository walletRepository; + @Autowired + private PasswordEncoder passwordEncoder; + @Autowired + private CompanyRepository companyRepository; + + private String testEmail = "integration_new@joycrew.com"; + private String testPassword = "newPass123!"; + private String testName = "새통합유저"; + private Company defaultCompany; + private Employee registeredEmployee; // <-- setUp에서 등록된 Employee를 저장할 필드 추가 + + @BeforeEach + void setUp() { + defaultCompany = Company.builder() + .companyName("테스트컴퍼니2") + .status("ACTIVE") + .startAt(LocalDateTime.now()) + .totalCompanyBalance(0.0) + .build(); + defaultCompany = companyRepository.save(defaultCompany); + + employeeRepository.findByEmail(testEmail).ifPresent(employeeRepository::delete); + + // --- setUp에서 Employee를 등록하고 필드에 저장 --- + employeeService.registerEmployee(testEmail, testPassword, testName, defaultCompany); + registeredEmployee = employeeRepository.findByEmail(testEmail).orElseThrow(); // 등록된 Employee 조회 + // --- 수정 끝 --- + } + + @Test + @DisplayName("통합 테스트: 직원 등록 성공 및 Wallet 자동 생성 확인") + void registerEmployee_Integration_Success_And_WalletCreated() { + // Given + // 이 테스트는 새로운 직원을 등록하는 것이 아니라, setUp에서 등록된 직원의 상태를 확인하는 테스트로 변경 + // 또는, 새로운 이메일을 가진 직원을 등록하는 테스트로 변경 + String newTestEmailForSuccess = "success_test@joycrew.com"; + employeeRepository.findByEmail(newTestEmailForSuccess).ifPresent(employeeRepository::delete); // 혹시 모를 잔여 데이터 삭제 + + // When + employeeService.registerEmployee(newTestEmailForSuccess, "successPass123", "성공유저", defaultCompany); + + // Then + Optional savedEmployeeOptional = employeeRepository.findByEmail(newTestEmailForSuccess); + assertThat(savedEmployeeOptional).isPresent(); + Employee savedEmployee = savedEmployeeOptional.get(); + + assertThat(savedEmployee.getEmployeeName()).isEqualTo("성공유저"); + assertThat(passwordEncoder.matches("successPass123", savedEmployee.getPasswordHash())).isTrue(); + assertThat(savedEmployee.getRole()).isEqualTo(UserRole.EMPLOYEE); + assertThat(savedEmployee.getStatus()).isEqualTo("ACTIVE"); + + Optional savedWalletOptional = walletRepository.findByEmployee_EmployeeId(savedEmployee.getEmployeeId()); + assertThat(savedWalletOptional).isPresent(); + Wallet savedWallet = savedWalletOptional.get(); + + assertThat(savedWallet.getEmployee().getEmployeeId()).isEqualTo(savedEmployee.getEmployeeId()); + assertThat(savedWallet.getBalance()).isEqualTo(0); + assertThat(savedWallet.getGiftablePoint()).isEqualTo(0); + } + + @Test + @DisplayName("통합 테스트: 직원 등록 실패 - 이메일 중복") + void registerEmployee_Integration_Failure_EmailDuplicate() { + // Given + // setUp에서 이미 testEmail로 직원이 등록되어 있음 + + // When & Then + // 동일한 이메일로 다시 등록 시도 시 예외 발생 확인 + assertThatThrownBy(() -> employeeService.registerEmployee(testEmail, "anotherPass", "다른이름", defaultCompany)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("이미 존재하는 이메일입니다."); + } +} \ No newline at end of file From b70dda0f540e85e953c4d92ed9e7091cbf79c40b Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Thu, 24 Jul 2025 20:49:03 +0900 Subject: [PATCH 014/135] =?UTF-8?q?test=20:=20API=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/TestUserDetailsService.java | 46 +++++++ .../controller/AuthControllerTest.java | 107 ++++++++++++++++ .../controller/UserControllerTest.java | 105 ++++++++++++++++ .../controller/WalletControllerTest.java | 116 ++++++++++++++++++ 4 files changed, 374 insertions(+) create mode 100644 src/test/java/com/joycrew/backend/config/TestUserDetailsService.java create mode 100644 src/test/java/com/joycrew/backend/controller/AuthControllerTest.java create mode 100644 src/test/java/com/joycrew/backend/controller/UserControllerTest.java create mode 100644 src/test/java/com/joycrew/backend/controller/WalletControllerTest.java diff --git a/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java b/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java new file mode 100644 index 0000000..c136b53 --- /dev/null +++ b/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java @@ -0,0 +1,46 @@ +package com.joycrew.backend.config; + +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.enums.UserRole; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; + +@Service +public class TestUserDetailsService implements UserDetailsService { + + private final Map users = new HashMap<>(); + + public TestUserDetailsService() { + // Pre-populate with test users + users.put("testuser@joycrew.com", Employee.builder() + .employeeId(1L) + .email("testuser@joycrew.com") + .employeeName("테스트유저") + .role(UserRole.EMPLOYEE) + .status("ACTIVE") + .passwordHash("{noop}password") // Use {noop} for plain text password in tests or your actual encoder + .build()); + + users.put("nowallet@joycrew.com", Employee.builder() + .employeeId(99L) + .email("nowallet@joycrew.com") + .employeeName("지갑없음") + .role(UserRole.EMPLOYEE) + .status("ACTIVE") + .passwordHash("{noop}password") + .build()); + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + if (!users.containsKey(username)) { + throw new UsernameNotFoundException("User not found: " + username); + } + return users.get(username); + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java b/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java new file mode 100644 index 0000000..0005049 --- /dev/null +++ b/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java @@ -0,0 +1,107 @@ +package com.joycrew.backend.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.joycrew.backend.dto.LoginRequest; +import com.joycrew.backend.dto.LoginResponse; +import com.joycrew.backend.service.AuthService; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = AuthController.class, + excludeAutoConfiguration = {SecurityAutoConfiguration.class}) +class AuthControllerTest { + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private AuthService authService; + + @Test + @DisplayName("POST /api/auth/login - 로그인 성공") + void login_Success() throws Exception { + LoginRequest request = new LoginRequest(); + request.setEmail("test@joycrew.com"); + request.setPassword("password123!"); + + LoginResponse successResponse = LoginResponse.builder() + .accessToken("mocked.jwt.token") + .message("로그인 성공") + .build(); + + when(authService.login(any(LoginRequest.class))).thenReturn(successResponse); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.accessToken").value("mocked.jwt.token")) + .andExpect(jsonPath("$.message").value("로그인 성공")); + } + + @Test + @DisplayName("POST /api/auth/login - 로그인 실패 (잘못된 비밀번호)") + void login_Failure_WrongPassword() throws Exception { + LoginRequest request = new LoginRequest(); + request.setEmail("test@joycrew.com"); + request.setPassword("wrongpassword"); + + when(authService.login(any(LoginRequest.class))) + .thenThrow(new BadCredentialsException("이메일 또는 비밀번호가 올바르지 않습니다.")); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.accessToken").isEmpty()) + .andExpect(jsonPath("$.message").value("이메일 또는 비밀번호가 올바르지 않습니다.")); + } + + @Test + @DisplayName("POST /api/auth/login - 로그인 실패 (이메일 없음)") + void login_Failure_EmailNotFound() throws Exception { + LoginRequest request = new LoginRequest(); + request.setEmail("nonexistent@joycrew.com"); + request.setPassword("anypassword"); + + when(authService.login(any(LoginRequest.class))) + .thenThrow(new UsernameNotFoundException("이메일 또는 비밀번호가 올바르지 않습니다.")); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.accessToken").isEmpty()) + .andExpect(jsonPath("$.message").value("이메일 또는 비밀번호가 올바르지 않습니다.")); + } + + @Test + @DisplayName("POST /api/auth/logout - 로그아웃 성공") + void logout_Success() throws Exception { + doNothing().when(authService).logout(any(HttpServletRequest.class)); + + mockMvc.perform(post("/api/auth/logout") + // 실제 요청처럼 Authorization 헤더를 포함하여 테스트 + .header("Authorization", "Bearer some.mock.token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("로그아웃 되었습니다.")); + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/UserControllerTest.java b/src/test/java/com/joycrew/backend/controller/UserControllerTest.java new file mode 100644 index 0000000..031b436 --- /dev/null +++ b/src/test/java/com/joycrew/backend/controller/UserControllerTest.java @@ -0,0 +1,105 @@ +package com.joycrew.backend.controller; + +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.UserRole; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.WalletRepository; +import com.joycrew.backend.security.EmployeeDetailsService; +import com.joycrew.backend.security.JwtUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +import java.util.Map; +import java.util.Optional; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = UserController.class) +@Import(UserControllerTest.TestControllerAdvice.class) +class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private EmployeeRepository employeeRepository; + @MockBean + private WalletRepository walletRepository; + @MockBean + private JwtUtil jwtUtil; + @MockBean + private EmployeeDetailsService employeeDetailsService; + + @ControllerAdvice + static class TestControllerAdvice { + @ExceptionHandler(RuntimeException.class) + public ResponseEntity> handleRuntimeException(RuntimeException e) { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("message", e.getMessage())); + } + } + + private Employee testEmployee; + private Wallet testWallet; + + @BeforeEach + void setUp() { + testEmployee = Employee.builder() + .employeeId(1L) + .email("testuser@joycrew.com") + .employeeName("테스트유저") + .role(UserRole.EMPLOYEE) + .build(); + + testWallet = Wallet.builder() + .balance(1500) + .giftablePoint(100) + .build(); + } + + @Test + @DisplayName("GET /api/user/profile - 프로필 조회 성공 (인증된 사용자)") + @WithMockUser(username = "testuser@joycrew.com") + void getProfile_Success_AuthenticatedUser() throws Exception { + when(employeeRepository.findByEmail("testuser@joycrew.com")).thenReturn(Optional.of(testEmployee)); + when(walletRepository.findByEmployee_EmployeeId(1L)).thenReturn(Optional.of(testWallet)); + + mockMvc.perform(get("/api/user/profile")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("테스트유저")); + } + + @Test + @DisplayName("GET /api/user/profile - 프로필 조회 실패 (인증되지 않은 사용자)") + void getProfile_Failure_Unauthenticated() throws Exception { + mockMvc.perform(get("/api/user/profile")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("GET /api/user/profile - 프로필 조회 실패 (인증은 되었으나 사용자 정보 없음)") + @WithMockUser(username = "nonexistent@joycrew.com") + void getProfile_Failure_UserNotFoundAfterAuth() throws Exception { + when(employeeRepository.findByEmail("nonexistent@joycrew.com")).thenReturn(Optional.empty()); + + mockMvc.perform(get("/api/user/profile")) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.message").value("인증된 사용자를 찾을 수 없습니다.")); + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/WalletControllerTest.java b/src/test/java/com/joycrew/backend/controller/WalletControllerTest.java new file mode 100644 index 0000000..37882f1 --- /dev/null +++ b/src/test/java/com/joycrew/backend/controller/WalletControllerTest.java @@ -0,0 +1,116 @@ +package com.joycrew.backend.controller; + +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.UserRole; +import com.joycrew.backend.repository.WalletRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import java.util.Optional; + +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +class WalletControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private WalletRepository walletRepository; + + @Autowired + private WebApplicationContext context; + + private Employee testEmployee; + private Wallet testWallet; + private Employee noWalletEmployee; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders + .webAppContextSetup(context) + .apply(springSecurity()) + .build(); + + testEmployee = Employee.builder() + .employeeId(1L) + .email("testuser@joycrew.com") + .employeeName("테스트유저") + .role(UserRole.EMPLOYEE) + .status("ACTIVE") + .passwordHash("{noop}password") + .build(); + + testWallet = Wallet.builder() + .walletId(100L) + .employee(testEmployee) + .balance(1500) + .giftablePoint(100) + .build(); + + noWalletEmployee = Employee.builder() + .employeeId(99L) + .email("nowallet@joycrew.com") + .passwordHash("{noop}password") + .employeeName("지갑없음") + .role(UserRole.EMPLOYEE) + .status("ACTIVE") + .build(); + } + + @Test + @DisplayName("GET /api/wallet/point - 포인트 잔액 조회 성공 (인증된 사용자)") + @WithUserDetails(value = "testuser@joycrew.com", userDetailsServiceBeanName = "testUserDetailsService") + void getWalletPoint_Success_WithValidToken() throws Exception { + when(walletRepository.findByEmployee_EmployeeId(testEmployee.getEmployeeId())) + .thenReturn(Optional.of(testWallet)); + + mockMvc.perform(get("/api/wallet/point") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.totalBalance").value(testWallet.getBalance())) + .andExpect(jsonPath("$.giftableBalance").value(testWallet.getGiftablePoint())); + } + + @Test + @DisplayName("GET /api/wallet/point - 포인트 잔액 조회 실패 (인증되지 않은 사용자)") + void getWalletPoint_Failure_Unauthenticated() throws Exception { + mockMvc.perform(get("/api/wallet/point") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isUnauthorized()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value("로그인이 필요합니다.")); + } + + @Test + @DisplayName("GET /api/wallet/point - 포인트 잔액 조회 성공 (인증은 되었으나 지갑 없음)") + @WithUserDetails(value = "nowallet@joycrew.com", userDetailsServiceBeanName = "testUserDetailsService") + void getWalletPoint_Success_WalletNotFound() throws Exception { + when(walletRepository.findByEmployee_EmployeeId(noWalletEmployee.getEmployeeId())) + .thenReturn(Optional.empty()); + + mockMvc.perform(get("/api/wallet/point") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.totalBalance").value(0)) + .andExpect(jsonPath("$.giftableBalance").value(0)); + } +} \ No newline at end of file From 05711d5233e9fe3ecc1e9e1e04f73974ee32bd94 Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Thu, 24 Jul 2025 21:01:24 +0900 Subject: [PATCH 015/135] =?UTF-8?q?test=20:=20test=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?CI=20=ED=99=98=EA=B2=BD=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/service/AuthServiceIntegrationTest.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java index eebe61c..3d7a8af 100644 --- a/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java +++ b/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java @@ -91,9 +91,9 @@ void login_Integration_Failure_EmailNotFound() { request.setPassword("anypassword"); // When & Then + // 발생하는 예외의 종류만 확인하도록 수정 assertThatThrownBy(() -> authService.login(request)) - .isInstanceOf(BadCredentialsException.class) - .hasMessageContaining("자격 증명에 실패하였습니다."); + .isInstanceOf(BadCredentialsException.class); } @Test @@ -105,8 +105,8 @@ void login_Integration_Failure_WrongPassword() { request.setPassword("wrongpassword"); // When & Then + // 발생하는 예외의 종류만 확인하도록 수정 assertThatThrownBy(() -> authService.login(request)) - .isInstanceOf(BadCredentialsException.class) - .hasMessageContaining("자격 증명에 실패하였습니다."); + .isInstanceOf(BadCredentialsException.class); } -} \ No newline at end of file +} From b774706a86342dda99c6e21d29bc2b73a5a39529 Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Sat, 26 Jul 2025 14:14:35 +0900 Subject: [PATCH 016/135] =?UTF-8?q?feat:=20user=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20API=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/controller/UserController.java | 46 ++++---- .../dto/AdminEmployeeUpdateRequest.java | 15 +++ .../dto/EmployeeRegistrationRequest.java | 35 ++++++ .../backend/dto/PasswordChangeRequest.java | 15 +++ .../backend/dto/UserProfileUpdateRequest.java | 52 +++++++++ .../com/joycrew/backend/entity/Employee.java | 30 +++-- .../repository/DepartmentRepository.java | 9 ++ .../backend/service/EmployeeService.java | 103 ++++++++++++++---- 8 files changed, 255 insertions(+), 50 deletions(-) create mode 100644 src/main/java/com/joycrew/backend/dto/AdminEmployeeUpdateRequest.java create mode 100644 src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java create mode 100644 src/main/java/com/joycrew/backend/dto/PasswordChangeRequest.java create mode 100644 src/main/java/com/joycrew/backend/dto/UserProfileUpdateRequest.java create mode 100644 src/main/java/com/joycrew/backend/repository/DepartmentRepository.java diff --git a/src/main/java/com/joycrew/backend/controller/UserController.java b/src/main/java/com/joycrew/backend/controller/UserController.java index c87e3ec..6af90cc 100644 --- a/src/main/java/com/joycrew/backend/controller/UserController.java +++ b/src/main/java/com/joycrew/backend/controller/UserController.java @@ -1,18 +1,21 @@ package com.joycrew.backend.controller; +import com.joycrew.backend.dto.PasswordChangeRequest; import com.joycrew.backend.dto.UserProfileResponse; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.Wallet; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.WalletRepository; +import com.joycrew.backend.service.EmployeeService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @@ -28,42 +31,31 @@ public class UserController { private final EmployeeRepository employeeRepository; private final WalletRepository walletRepository; + private final EmployeeService employeeService; - @Operation(summary = "사용자 프로필 조회", description = "JWT 토큰으로 인증된 사용자의 프로필 정보를 반환합니다.") + @Operation(summary = "사용자 프로필 조회", description = "JWT 토큰으로 인증된 사용자의 프로필 정보를 반환합니다.", + security = @SecurityRequirement(name = "bearerAuth")) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(schema = @Schema(implementation = UserProfileResponse.class))), - @ApiResponse(responseCode = "401", description = "토큰 없음 또는 유효하지 않음", - content = @Content(mediaType = "application/json", - schema = @Schema(example = "{\"message\": \"유효하지 않은 토큰입니다.\"}"))) + @ApiResponse(responseCode = "401", description = "인증 필요") }) @GetMapping("/profile") public ResponseEntity getProfile(Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("message", "유효하지 않은 토큰입니다.")); - } - String userEmail = authentication.getName(); - Employee employee = employeeRepository.findByEmail(userEmail) .orElseThrow(() -> new RuntimeException("인증된 사용자를 찾을 수 없습니다.")); Optional walletOptional = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()); - int totalBalance = 0; - int giftableBalance = 0; - if (walletOptional.isPresent()) { - Wallet wallet = walletOptional.get(); - totalBalance = wallet.getBalance(); - giftableBalance = wallet.getGiftablePoint(); - } + int totalBalance = walletOptional.map(Wallet::getBalance).orElse(0); + int giftableBalance = walletOptional.map(Wallet::getGiftablePoint).orElse(0); UserProfileResponse response = UserProfileResponse.builder() .employeeId(employee.getEmployeeId()) .name(employee.getEmployeeName()) .email(employee.getEmail()) .role(employee.getRole()) - .department(employee.getDepartment() != null ? employee.getDepartment().getName() : null) // 부서 이름 추가 + .department(employee.getDepartment() != null ? employee.getDepartment().getName() : null) .position(employee.getPosition()) .totalBalance(totalBalance) .giftableBalance(giftableBalance) @@ -71,4 +63,18 @@ public ResponseEntity getProfile(Authentication authentication) { return ResponseEntity.ok(response); } -} \ No newline at end of file + + @Operation(summary = "비밀번호 변경 (첫 로그인 시)", description = "초기 비밀번호를 받은 사용자가 자신의 비밀번호를 새로 설정합니다.", + security = @SecurityRequirement(name = "bearerAuth")) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "비밀번호 변경 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청 (비밀번호 정책 위반)"), + @ApiResponse(responseCode = "401", description = "인증 필요") + }) + @PostMapping("/password") + public ResponseEntity> forceChangePassword(Authentication authentication, @Valid @RequestBody PasswordChangeRequest request) { + String userEmail = authentication.getName(); + employeeService.forcePasswordChange(userEmail, request); + return ResponseEntity.ok(Map.of("message", "비밀번호가 성공적으로 변경되었습니다.")); + } +} diff --git a/src/main/java/com/joycrew/backend/dto/AdminEmployeeUpdateRequest.java b/src/main/java/com/joycrew/backend/dto/AdminEmployeeUpdateRequest.java new file mode 100644 index 0000000..a23113d --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/AdminEmployeeUpdateRequest.java @@ -0,0 +1,15 @@ +package com.joycrew.backend.dto; + +import com.joycrew.backend.entity.enums.UserRole; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class AdminEmployeeUpdateRequest { + private String name; + private Long departmentId; + private String position; + private UserRole role; + private String status; +} diff --git a/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java b/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java new file mode 100644 index 0000000..650c41f --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java @@ -0,0 +1,35 @@ +package com.joycrew.backend.dto; + +import com.joycrew.backend.entity.enums.UserRole; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class EmployeeRegistrationRequest { + @NotBlank(message = "이름은 필수입니다.") + private String name; + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "유효한 이메일 형식이 아닙니다.") + private String email; + + @NotBlank(message = "초기 비밀번호는 필수입니다.") + @Size(min = 8, message = "비밀번호는 8자 이상이어야 합니다.") + private String initialPassword; + + @NotNull(message = "회사 ID는 필수입니다.") + private Long companyId; + + private Long departmentId; + + @NotBlank(message = "직책은 필수입니다.") + private String position; + + @NotNull(message = "역할은 필수입니다.") + private UserRole role; +} diff --git a/src/main/java/com/joycrew/backend/dto/PasswordChangeRequest.java b/src/main/java/com/joycrew/backend/dto/PasswordChangeRequest.java new file mode 100644 index 0000000..c47ea1e --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/PasswordChangeRequest.java @@ -0,0 +1,15 @@ +package com.joycrew.backend.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class PasswordChangeRequest { + @NotBlank + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&])[A-Za-z\\d@$!%*#?&]{8,20}$", + message = "비밀번호는 영문, 숫자, 특수문자를 포함하여 8자 이상 20자 이하이어야 합니다.") + private String newPassword; +} diff --git a/src/main/java/com/joycrew/backend/dto/UserProfileUpdateRequest.java b/src/main/java/com/joycrew/backend/dto/UserProfileUpdateRequest.java new file mode 100644 index 0000000..74c496c --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/UserProfileUpdateRequest.java @@ -0,0 +1,52 @@ +package com.joycrew.backend.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.validator.constraints.URL; + +@Getter +@Setter +@Schema(description = "사용자 프로필 수정 요청 DTO") +public class UserProfileUpdateRequest { + + @Schema(description = "새로운 사용자 이름 (선호하는 이름)", example = "김조이", nullable = true) + @Size(min = 2, max = 20, message = "이름은 2자 이상 20자 이하로 입력해주세요.") + private String name; + + @Schema(description = "새로운 비밀번호 (영문, 숫자, 특수문자 포함 8~20자)", example = "newPassword123!", nullable = true) + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&])[A-Za-z\\d@$!%*#?&]{8,20}$", + message = "비밀번호는 영문, 숫자, 특수문자를 포함하여 8자 이상 20자 이하이어야 합니다.") + private String password; + + @Schema(description = "프로필 사진 이미지 URL", example = "https://example.com/profile.jpg", nullable = true) + @URL(message = "유효한 URL 형식이 아닙니다.") + private String profileImageUrl; + + @Schema(description = "개인 이메일 주소", example = "joy@personal.com", nullable = true) + @Email(message = "유효한 이메일 형식이 아닙니다.") + private String personalEmail; + + @Schema(description = "휴대폰 번호", example = "010-1234-5678", nullable = true) + @Pattern(regexp = "^\\d{2,3}-\\d{3,4}-\\d{4}$", message = "유효한 휴대폰 번호 형식이 아닙니다. (예: 010-1234-5678)") + private String phoneNumber; + + @Schema(description = "배송 주소 (리워드 배송 시 필요)", example = "서울시 강남구 테헤란로 123", nullable = true) + @Size(max = 255, message = "주소는 255자를 초과할 수 없습니다.") + private String shippingAddress; + + @Schema(description = "이메일 알림 수신 여부", example = "true", nullable = true) + private Boolean emailNotificationEnabled; + + @Schema(description = "앱 내 알림 수신 여부", example = "true", nullable = true) + private Boolean appNotificationEnabled; + + @Schema(description = "선호 언어 설정", example = "ko-KR", nullable = true) + private String language; + + @Schema(description = "시간대 설정", example = "Asia/Seoul", nullable = true) + private String timezone; +} diff --git a/src/main/java/com/joycrew/backend/entity/Employee.java b/src/main/java/com/joycrew/backend/entity/Employee.java index 16faf18..8b0428a 100644 --- a/src/main/java/com/joycrew/backend/entity/Employee.java +++ b/src/main/java/com/joycrew/backend/entity/Employee.java @@ -3,14 +3,14 @@ import com.joycrew.backend.entity.enums.UserRole; import jakarta.persistence.*; import lombok.*; -import java.time.LocalDateTime; -import java.util.List; - import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; + +import java.time.LocalDateTime; import java.util.Collection; import java.util.Collections; +import java.util.List; @Entity @Table(name = "employee") @@ -45,6 +45,17 @@ public class Employee implements UserDetails { @Column(nullable = false) private UserRole role; + // 사용자 셀프 서비스 필드 + @Column(length = 2048) // URL은 길 수 있으므로 길이 확장 + private String profileImageUrl; + private String personalEmail; + private String phoneNumber; + private String shippingAddress; + private Boolean emailNotificationEnabled; + private Boolean appNotificationEnabled; + private String language; + private String timezone; + private LocalDateTime lastLoginAt; @Column(nullable = false) private LocalDateTime createdAt; @@ -66,12 +77,10 @@ public class Employee implements UserDetails { @PrePersist protected void onCreate() { this.createdAt = this.updatedAt = LocalDateTime.now(); - if (this.status == null) { - this.status = "ACTIVE"; - } - if (this.role == null) { - this.role = UserRole.EMPLOYEE; - } + if (this.status == null) this.status = "ACTIVE"; + if (this.role == null) this.role = UserRole.EMPLOYEE; + if (this.emailNotificationEnabled == null) this.emailNotificationEnabled = true; + if (this.appNotificationEnabled == null) this.appNotificationEnabled = true; } @PreUpdate @@ -79,6 +88,7 @@ protected void onUpdate() { this.updatedAt = LocalDateTime.now(); } + // UserDetails 구현 메서드들... @Override public Collection getAuthorities() { return Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + this.role)); @@ -113,4 +123,4 @@ public boolean isCredentialsNonExpired() { public boolean isEnabled() { return "ACTIVE".equals(this.status); } -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java b/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java new file mode 100644 index 0000000..e0bd8d4 --- /dev/null +++ b/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java @@ -0,0 +1,9 @@ +package com.joycrew.backend.repository; + +import com.joycrew.backend.entity.Department; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface DepartmentRepository extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/EmployeeService.java b/src/main/java/com/joycrew/backend/service/EmployeeService.java index 8a1829b..4c11266 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeService.java @@ -1,9 +1,14 @@ package com.joycrew.backend.service; +import com.joycrew.backend.dto.AdminEmployeeUpdateRequest; +import com.joycrew.backend.dto.EmployeeRegistrationRequest; +import com.joycrew.backend.dto.PasswordChangeRequest; import com.joycrew.backend.entity.Company; +import com.joycrew.backend.entity.Department; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.Wallet; -import com.joycrew.backend.entity.enums.UserRole; +import com.joycrew.backend.repository.CompanyRepository; +import com.joycrew.backend.repository.DepartmentRepository; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.WalletRepository; import lombok.RequiredArgsConstructor; @@ -11,33 +16,46 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; - @Service @RequiredArgsConstructor +@Transactional public class EmployeeService { - private final EmployeeRepository employeeRepository; - private final PasswordEncoder passwordEncoder; + private final CompanyRepository companyRepository; + private final DepartmentRepository departmentRepository; private final WalletRepository walletRepository; + private final PasswordEncoder passwordEncoder; - @Transactional - public void registerEmployee(String email, String rawPassword, String name, Company company) { - if (employeeRepository.findByEmail(email).isPresent()) { - throw new RuntimeException("이미 존재하는 이메일입니다."); + /** + * [HR 관리자 기능] 신규 직원을 등록합니다. + * 초기 비밀번호가 설정되며, 첫 로그인 시 변경해야 합니다. + * + * @param request 신규 직원 정보 DTO + * @return 생성된 Employee 엔티티 + */ + public Employee registerEmployee(EmployeeRegistrationRequest request) { + if (employeeRepository.findByEmail(request.getEmail()).isPresent()) { + throw new IllegalStateException("이미 사용 중인 이메일입니다."); } - String encodedPassword = passwordEncoder.encode(rawPassword); + Company company = companyRepository.findById(request.getCompanyId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회사 ID입니다.")); + + Department department = null; + if (request.getDepartmentId() != null) { + department = departmentRepository.findById(request.getDepartmentId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 부서 ID입니다.")); + } Employee newEmployee = Employee.builder() - .email(email) - .passwordHash(encodedPassword) - .employeeName(name) - .status("ACTIVE") - .role(UserRole.EMPLOYEE) - .createdAt(LocalDateTime.now()) - .updatedAt(LocalDateTime.now()) + .employeeName(request.getName()) + .email(request.getEmail()) + .passwordHash(passwordEncoder.encode(request.getInitialPassword())) .company(company) + .department(department) + .position(request.getPosition()) + .role(request.getRole()) + .status("ACTIVE") .build(); Employee savedEmployee = employeeRepository.save(newEmployee); @@ -49,7 +67,52 @@ public void registerEmployee(String email, String rawPassword, String name, Comp .build(); walletRepository.save(newWallet); - savedEmployee.setWallet(newWallet); - employeeRepository.save(savedEmployee); + return savedEmployee; + } + + /** + * [HR 관리자 기능] 직원의 기본 정보를 수정합니다. + * + * @param employeeId 수정 대상 직원 ID + * @param request 수정할 정보 DTO + * @return 업데이트된 Employee 엔티티 + */ + public Employee updateEmployeeDetailsByAdmin(Long employeeId, AdminEmployeeUpdateRequest request) { + Employee employee = employeeRepository.findById(employeeId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 직원 ID입니다.")); + + if (request.getName() != null) { + employee.setEmployeeName(request.getName()); + } + if (request.getDepartmentId() != null) { + Department department = departmentRepository.findById(request.getDepartmentId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 부서 ID입니다.")); + employee.setDepartment(department); + } + if (request.getPosition() != null) { + employee.setPosition(request.getPosition()); + } + if (request.getRole() != null) { + employee.setRole(request.getRole()); + } + if (request.getStatus() != null) { + employee.setStatus(request.getStatus()); + } + + return employeeRepository.save(employee); + } + + /** + * [직원 기능] 첫 로그인 시 비밀번호를 변경합니다. + * + * @param userEmail 현재 로그인된 사용자 이메일 + * @param request 새 비밀번호 정보 DTO + */ + public void forcePasswordChange(String userEmail, PasswordChangeRequest request) { + Employee employee = employeeRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("인증된 사용자를 찾을 수 없습니다.")); + + employee.setPasswordHash(passwordEncoder.encode(request.getNewPassword())); + employeeRepository.save(employee); } -} \ No newline at end of file +} From 3fd9ea9bcdb0e22a1a5d1e8458bfc7c49fa58dfd Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Sat, 26 Jul 2025 14:21:55 +0900 Subject: [PATCH 017/135] =?UTF-8?q?test=20:=20user=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=9C=A0=EB=8B=9B=20=ED=86=B5=ED=95=A9=20API=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AuthControllerTest.java | 1 - .../controller/UserControllerTest.java | 56 +++++--- .../repository/EmployeeRepositoryTest.java | 2 - .../repository/WalletRepositoryTest.java | 1 - .../service/AuthServiceIntegrationTest.java | 15 +- .../EmployeeServiceIntegrationTest.java | 130 ++++++++++-------- .../backend/service/EmployeeServiceTest.java | 126 ++++++++++------- 7 files changed, 198 insertions(+), 133 deletions(-) diff --git a/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java b/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java index 0005049..7702ba6 100644 --- a/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java @@ -99,7 +99,6 @@ void logout_Success() throws Exception { doNothing().when(authService).logout(any(HttpServletRequest.class)); mockMvc.perform(post("/api/auth/logout") - // 실제 요청처럼 Authorization 헤더를 포함하여 테스트 .header("Authorization", "Bearer some.mock.token")) .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("로그아웃 되었습니다.")); diff --git a/src/test/java/com/joycrew/backend/controller/UserControllerTest.java b/src/test/java/com/joycrew/backend/controller/UserControllerTest.java index 031b436..8e9dc38 100644 --- a/src/test/java/com/joycrew/backend/controller/UserControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/UserControllerTest.java @@ -1,12 +1,13 @@ package com.joycrew.backend.controller; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.joycrew.backend.dto.PasswordChangeRequest; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.Wallet; import com.joycrew.backend.entity.enums.UserRole; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.WalletRepository; -import com.joycrew.backend.security.EmployeeDetailsService; -import com.joycrew.backend.security.JwtUtil; +import com.joycrew.backend.service.EmployeeService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -15,6 +16,7 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; @@ -24,8 +26,13 @@ import java.util.Map; import java.util.Optional; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -36,14 +43,15 @@ class UserControllerTest { @Autowired private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @MockBean private EmployeeRepository employeeRepository; @MockBean private WalletRepository walletRepository; @MockBean - private JwtUtil jwtUtil; - @MockBean - private EmployeeDetailsService employeeDetailsService; + private EmployeeService employeeService; @ControllerAdvice static class TestControllerAdvice { @@ -86,20 +94,36 @@ void getProfile_Success_AuthenticatedUser() throws Exception { } @Test - @DisplayName("GET /api/user/profile - 프로필 조회 실패 (인증되지 않은 사용자)") - void getProfile_Failure_Unauthenticated() throws Exception { - mockMvc.perform(get("/api/user/profile")) - .andExpect(status().isUnauthorized()); + @DisplayName("POST /api/user/password - 비밀번호 변경 성공") + @WithMockUser(username = "testuser@joycrew.com") + void forceChangePassword_Success() throws Exception { + // Given + PasswordChangeRequest request = new PasswordChangeRequest(); + request.setNewPassword("newPassword123!"); + doNothing().when(employeeService).forcePasswordChange(eq("testuser@joycrew.com"), any(PasswordChangeRequest.class)); + + // When & Then + mockMvc.perform(post("/api/user/password") + .with(csrf()) // CSRF 토큰 추가 + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("비밀번호가 성공적으로 변경되었습니다.")); } @Test - @DisplayName("GET /api/user/profile - 프로필 조회 실패 (인증은 되었으나 사용자 정보 없음)") - @WithMockUser(username = "nonexistent@joycrew.com") - void getProfile_Failure_UserNotFoundAfterAuth() throws Exception { - when(employeeRepository.findByEmail("nonexistent@joycrew.com")).thenReturn(Optional.empty()); + @DisplayName("POST /api/user/password - 비밀번호 변경 실패 (유효성 검사 실패)") + @WithMockUser(username = "testuser@joycrew.com") + void forceChangePassword_Failure_InvalidPassword() throws Exception { + // Given + PasswordChangeRequest request = new PasswordChangeRequest(); + request.setNewPassword("short"); - mockMvc.perform(get("/api/user/profile")) - .andExpect(status().isInternalServerError()) - .andExpect(jsonPath("$.message").value("인증된 사용자를 찾을 수 없습니다.")); + // When & Then + mockMvc.perform(post("/api/user/password") + .with(csrf()) // CSRF 토큰 추가 + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java b/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java index 4870557..d5ce1ec 100644 --- a/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java +++ b/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java @@ -26,8 +26,6 @@ class EmployeeRepositoryTest { private TestEntityManager entityManager; @Autowired private EmployeeRepository employeeRepository; - @Autowired - private WalletRepository walletRepository; private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); diff --git a/src/test/java/com/joycrew/backend/repository/WalletRepositoryTest.java b/src/test/java/com/joycrew/backend/repository/WalletRepositoryTest.java index 21eb203..a014e4d 100644 --- a/src/test/java/com/joycrew/backend/repository/WalletRepositoryTest.java +++ b/src/test/java/com/joycrew/backend/repository/WalletRepositoryTest.java @@ -146,7 +146,6 @@ void saveAndFindWallet() { assertThat(found.get().getBalance()).isEqualTo(2000); assertThat(found.get().getEmployee().getEmployeeId()).isEqualTo(anotherEmployee.getEmployeeId()); - // 양방향 관계 업데이트 anotherEmployee.setWallet(savedWallet); entityManager.merge(anotherEmployee); entityManager.flush(); diff --git a/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java index 3d7a8af..9922b3b 100644 --- a/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java +++ b/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java @@ -1,6 +1,7 @@ package com.joycrew.backend.service; import com.joycrew.backend.JoyCrewBackendApplication; +import com.joycrew.backend.dto.EmployeeRegistrationRequest; import com.joycrew.backend.dto.LoginRequest; import com.joycrew.backend.dto.LoginResponse; import com.joycrew.backend.entity.Company; @@ -55,7 +56,17 @@ void setUp() { employeeRepository.findByEmail(testEmail).ifPresent(employeeRepository::delete); - employeeService.registerEmployee(testEmail, testPassword, testName, defaultCompany); + // --- DTO를 사용하도록 수정된 부분 --- + EmployeeRegistrationRequest request = new EmployeeRegistrationRequest(); + request.setEmail(testEmail); + request.setInitialPassword(testPassword); + request.setName(testName); + request.setCompanyId(defaultCompany.getCompanyId()); + request.setPosition("사원"); // DTO에 필요한 기본값 설정 + request.setRole(UserRole.EMPLOYEE); // DTO에 필요한 기본값 설정 + + employeeService.registerEmployee(request); + // --- 수정 끝 --- } @Test @@ -91,7 +102,6 @@ void login_Integration_Failure_EmailNotFound() { request.setPassword("anypassword"); // When & Then - // 발생하는 예외의 종류만 확인하도록 수정 assertThatThrownBy(() -> authService.login(request)) .isInstanceOf(BadCredentialsException.class); } @@ -105,7 +115,6 @@ void login_Integration_Failure_WrongPassword() { request.setPassword("wrongpassword"); // When & Then - // 발생하는 예외의 종류만 확인하도록 수정 assertThatThrownBy(() -> authService.login(request)) .isInstanceOf(BadCredentialsException.class); } diff --git a/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java index 16cbc3f..ef76002 100644 --- a/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java +++ b/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java @@ -1,11 +1,14 @@ package com.joycrew.backend.service; -import com.joycrew.backend.JoyCrewBackendApplication; +import com.joycrew.backend.dto.AdminEmployeeUpdateRequest; +import com.joycrew.backend.dto.EmployeeRegistrationRequest; +import com.joycrew.backend.dto.PasswordChangeRequest; import com.joycrew.backend.entity.Company; +import com.joycrew.backend.entity.Department; import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.entity.Wallet; import com.joycrew.backend.entity.enums.UserRole; import com.joycrew.backend.repository.CompanyRepository; +import com.joycrew.backend.repository.DepartmentRepository; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.WalletRepository; import org.junit.jupiter.api.BeforeEach; @@ -16,13 +19,10 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; -import java.util.Optional; - import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -@SpringBootTest(classes = JoyCrewBackendApplication.class) +@SpringBootTest @Transactional class EmployeeServiceIntegrationTest { @@ -33,75 +33,87 @@ class EmployeeServiceIntegrationTest { @Autowired private WalletRepository walletRepository; @Autowired - private PasswordEncoder passwordEncoder; - @Autowired private CompanyRepository companyRepository; + @Autowired + private DepartmentRepository departmentRepository; + @Autowired + private PasswordEncoder passwordEncoder; - private String testEmail = "integration_new@joycrew.com"; - private String testPassword = "newPass123!"; - private String testName = "새통합유저"; - private Company defaultCompany; - private Employee registeredEmployee; // <-- setUp에서 등록된 Employee를 저장할 필드 추가 + private Company testCompany; + private Department testDepartment; @BeforeEach void setUp() { - defaultCompany = Company.builder() - .companyName("테스트컴퍼니2") - .status("ACTIVE") - .startAt(LocalDateTime.now()) - .totalCompanyBalance(0.0) - .build(); - defaultCompany = companyRepository.save(defaultCompany); - - employeeRepository.findByEmail(testEmail).ifPresent(employeeRepository::delete); - - // --- setUp에서 Employee를 등록하고 필드에 저장 --- - employeeService.registerEmployee(testEmail, testPassword, testName, defaultCompany); - registeredEmployee = employeeRepository.findByEmail(testEmail).orElseThrow(); // 등록된 Employee 조회 - // --- 수정 끝 --- + // 테스트에 필요한 기본 회사와 부서 생성 + testCompany = companyRepository.save(Company.builder().companyName("테스트 회사").build()); + testDepartment = departmentRepository.save(Department.builder().name("테스트 부서").company(testCompany).build()); } @Test - @DisplayName("통합 테스트: 직원 등록 성공 및 Wallet 자동 생성 확인") - void registerEmployee_Integration_Success_And_WalletCreated() { + @DisplayName("[Integration] 신규 직원 등록 성공") + void registerEmployee_Success() { // Given - // 이 테스트는 새로운 직원을 등록하는 것이 아니라, setUp에서 등록된 직원의 상태를 확인하는 테스트로 변경 - // 또는, 새로운 이메일을 가진 직원을 등록하는 테스트로 변경 - String newTestEmailForSuccess = "success_test@joycrew.com"; - employeeRepository.findByEmail(newTestEmailForSuccess).ifPresent(employeeRepository::delete); // 혹시 모를 잔여 데이터 삭제 + EmployeeRegistrationRequest request = new EmployeeRegistrationRequest(); + request.setEmail("new.employee@joycrew.com"); + request.setName("신규직원"); + request.setInitialPassword("password123!"); + request.setCompanyId(testCompany.getCompanyId()); + request.setDepartmentId(testDepartment.getDepartmentId()); + request.setPosition("사원"); + request.setRole(UserRole.EMPLOYEE); // When - employeeService.registerEmployee(newTestEmailForSuccess, "successPass123", "성공유저", defaultCompany); + Employee savedEmployee = employeeService.registerEmployee(request); // Then - Optional savedEmployeeOptional = employeeRepository.findByEmail(newTestEmailForSuccess); - assertThat(savedEmployeeOptional).isPresent(); - Employee savedEmployee = savedEmployeeOptional.get(); - - assertThat(savedEmployee.getEmployeeName()).isEqualTo("성공유저"); - assertThat(passwordEncoder.matches("successPass123", savedEmployee.getPasswordHash())).isTrue(); - assertThat(savedEmployee.getRole()).isEqualTo(UserRole.EMPLOYEE); - assertThat(savedEmployee.getStatus()).isEqualTo("ACTIVE"); - - Optional savedWalletOptional = walletRepository.findByEmployee_EmployeeId(savedEmployee.getEmployeeId()); - assertThat(savedWalletOptional).isPresent(); - Wallet savedWallet = savedWalletOptional.get(); - - assertThat(savedWallet.getEmployee().getEmployeeId()).isEqualTo(savedEmployee.getEmployeeId()); - assertThat(savedWallet.getBalance()).isEqualTo(0); - assertThat(savedWallet.getGiftablePoint()).isEqualTo(0); + assertThat(savedEmployee.getEmployeeId()).isNotNull(); + assertThat(savedEmployee.getEmail()).isEqualTo("new.employee@joycrew.com"); + assertThat(walletRepository.findByEmployee_EmployeeId(savedEmployee.getEmployeeId())).isPresent(); } @Test - @DisplayName("통합 테스트: 직원 등록 실패 - 이메일 중복") - void registerEmployee_Integration_Failure_EmailDuplicate() { + @DisplayName("[Integration] 관리자에 의한 직원 정보 수정 성공") + void updateEmployeeDetailsByAdmin_Success() { // Given - // setUp에서 이미 testEmail로 직원이 등록되어 있음 + Employee employee = employeeRepository.save(Employee.builder() + .email("update.target@joycrew.com") + .employeeName("수정대상") + .passwordHash("password") + .company(testCompany) + .build()); + + AdminEmployeeUpdateRequest request = new AdminEmployeeUpdateRequest(); + request.setName("이름수정됨"); + request.setPosition("대리"); - // When & Then - // 동일한 이메일로 다시 등록 시도 시 예외 발생 확인 - assertThatThrownBy(() -> employeeService.registerEmployee(testEmail, "anotherPass", "다른이름", defaultCompany)) - .isInstanceOf(RuntimeException.class) - .hasMessageContaining("이미 존재하는 이메일입니다."); + // When + employeeService.updateEmployeeDetailsByAdmin(employee.getEmployeeId(), request); + + // Then + Employee updatedEmployee = employeeRepository.findById(employee.getEmployeeId()).orElseThrow(); + assertThat(updatedEmployee.getEmployeeName()).isEqualTo("이름수정됨"); + assertThat(updatedEmployee.getPosition()).isEqualTo("대리"); + } + + @Test + @DisplayName("[Integration] 직원 비밀번호 변경 성공") + void forcePasswordChange_Success() { + // Given + Employee employee = employeeRepository.save(Employee.builder() + .email("pw.change@joycrew.com") + .employeeName("패스워드변경") + .passwordHash(passwordEncoder.encode("oldPassword")) + .company(testCompany) + .build()); + + PasswordChangeRequest request = new PasswordChangeRequest(); + request.setNewPassword("newPassword123!"); + + // When + employeeService.forcePasswordChange(employee.getEmail(), request); + + // Then + Employee updatedEmployee = employeeRepository.findByEmail(employee.getEmail()).orElseThrow(); + assertThat(passwordEncoder.matches("newPassword123!", updatedEmployee.getPasswordHash())).isTrue(); } -} \ No newline at end of file +} diff --git a/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java b/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java index cb5069b..267fe57 100644 --- a/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java +++ b/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java @@ -1,107 +1,131 @@ package com.joycrew.backend.service; +import com.joycrew.backend.dto.AdminEmployeeUpdateRequest; +import com.joycrew.backend.dto.EmployeeRegistrationRequest; +import com.joycrew.backend.dto.PasswordChangeRequest; import com.joycrew.backend.entity.Company; +import com.joycrew.backend.entity.Department; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.Wallet; import com.joycrew.backend.entity.enums.UserRole; +import com.joycrew.backend.repository.CompanyRepository; +import com.joycrew.backend.repository.DepartmentRepository; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.WalletRepository; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.quality.Strictness; -import org.mockito.junit.jupiter.MockitoSettings; import org.springframework.security.crypto.password.PasswordEncoder; import java.util.Optional; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) -@MockitoSettings(strictness = Strictness.LENIENT) class EmployeeServiceTest { @Mock private EmployeeRepository employeeRepository; @Mock - private PasswordEncoder passwordEncoder; + private CompanyRepository companyRepository; @Mock - private Company mockCompany; + private DepartmentRepository departmentRepository; @Mock private WalletRepository walletRepository; + @Mock + private PasswordEncoder passwordEncoder; @InjectMocks private EmployeeService employeeService; - @BeforeEach - void setUp() { - employeeService = new EmployeeService(employeeRepository, passwordEncoder, walletRepository); + @Test + @DisplayName("[Service] 신규 직원 등록 성공") + void registerEmployee_Success() { + // Given + EmployeeRegistrationRequest request = new EmployeeRegistrationRequest(); + request.setEmail("new@joycrew.com"); + request.setName("신규직원"); + request.setInitialPassword("password123!"); + request.setCompanyId(1L); + request.setDepartmentId(10L); + request.setPosition("사원"); + request.setRole(UserRole.EMPLOYEE); + + when(employeeRepository.findByEmail(request.getEmail())).thenReturn(Optional.empty()); + when(companyRepository.findById(1L)).thenReturn(Optional.of(new Company())); + when(departmentRepository.findById(10L)).thenReturn(Optional.of(new Department())); + when(passwordEncoder.encode(anyString())).thenReturn("encodedPassword"); + when(employeeRepository.save(any(Employee.class))).thenAnswer(i -> i.getArgument(0)); + + // When + Employee result = employeeService.registerEmployee(request); - when(mockCompany.getCompanyId()).thenReturn(1L); + // Then + assertThat(result.getEmail()).isEqualTo(request.getEmail()); + assertThat(result.getPasswordHash()).isEqualTo("encodedPassword"); + verify(walletRepository, times(1)).save(any(Wallet.class)); } @Test - @DisplayName("직원 등록 성공") - void registerEmployee_Success() { + @DisplayName("[Service] 직원 정보 수정 (관리자) 성공") + void updateEmployeeDetailsByAdmin_Success() { // Given - String email = "newuser@joycrew.com"; - String rawPassword = "newpassword123"; - String name = "새로운직원"; - String encodedPassword = "encodedPasswordHash"; + Long employeeId = 1L; + AdminEmployeeUpdateRequest request = new AdminEmployeeUpdateRequest(); + request.setName("이름변경"); + request.setPosition("대리"); + + Employee existingEmployee = Employee.builder().employeeId(employeeId).employeeName("기존이름").position("사원").build(); + when(employeeRepository.findById(employeeId)).thenReturn(Optional.of(existingEmployee)); + when(employeeRepository.save(any(Employee.class))).thenAnswer(i -> i.getArgument(0)); - when(employeeRepository.findByEmail(email)).thenReturn(Optional.empty()); - when(passwordEncoder.encode(rawPassword)).thenReturn(encodedPassword); + // When + Employee result = employeeService.updateEmployeeDetailsByAdmin(employeeId, request); + + // Then + assertThat(result.getEmployeeName()).isEqualTo("이름변경"); + assertThat(result.getPosition()).isEqualTo("대리"); + verify(employeeRepository, times(1)).save(existingEmployee); + } - when(employeeRepository.save(any(Employee.class))).thenAnswer(invocation -> { - Employee savedEmployee = invocation.getArgument(0); - if (savedEmployee.getEmployeeId() == null) { - savedEmployee.setEmployeeId(2L); - } - return savedEmployee; - }); + @Test + @DisplayName("[Service] 비밀번호 변경 (첫 로그인) 성공") + void forcePasswordChange_Success() { + // Given + String userEmail = "test@joycrew.com"; + PasswordChangeRequest request = new PasswordChangeRequest(); + request.setNewPassword("newPassword123!"); - when(walletRepository.save(any(Wallet.class))).thenAnswer(invocation -> invocation.getArgument(0)); + Employee existingEmployee = Employee.builder().email(userEmail).passwordHash("oldPassword").build(); + when(employeeRepository.findByEmail(userEmail)).thenReturn(Optional.of(existingEmployee)); + when(passwordEncoder.encode("newPassword123!")).thenReturn("newEncodedPassword"); // When - employeeService.registerEmployee(email, rawPassword, name, mockCompany); + employeeService.forcePasswordChange(userEmail, request); // Then - verify(employeeRepository, times(2)).save(any(Employee.class)); - verify(employeeRepository, times(2)).save(argThat(employee -> - employee.getEmail().equals(email) && - employee.getPasswordHash().equals(encodedPassword) && - employee.getEmployeeName().equals(name) && - employee.getStatus().equals("ACTIVE") && - employee.getRole().equals(UserRole.EMPLOYEE) && - employee.getCompany().getCompanyId().equals(mockCompany.getCompanyId()) - )); - verify(passwordEncoder, times(1)).encode(rawPassword); - verify(walletRepository, times(1)).save(any(Wallet.class)); + verify(employeeRepository, times(1)).save(existingEmployee); + assertThat(existingEmployee.getPasswordHash()).isEqualTo("newEncodedPassword"); } @Test - @DisplayName("직원 등록 실패 - 이메일 중복") + @DisplayName("[Service] 직원 등록 실패 - 이메일 중복") void registerEmployee_Failure_EmailDuplicate() { // Given - String email = "existing@joycrew.com"; - String rawPassword = "password123"; - String name = "기존직원"; + EmployeeRegistrationRequest request = new EmployeeRegistrationRequest(); + request.setEmail("duplicate@joycrew.com"); - when(employeeRepository.findByEmail(email)).thenReturn(Optional.of(Employee.builder().email(email).build())); + when(employeeRepository.findByEmail("duplicate@joycrew.com")).thenReturn(Optional.of(new Employee())); // When & Then - assertThatThrownBy(() -> employeeService.registerEmployee(email, rawPassword, name, mockCompany)) - .isInstanceOf(RuntimeException.class) - .hasMessageContaining("이미 존재하는 이메일입니다."); - - verify(employeeRepository, never()).save(any(Employee.class)); - verify(passwordEncoder, never()).encode(anyString()); - verify(walletRepository, never()).save(any(Wallet.class)); + assertThatThrownBy(() -> employeeService.registerEmployee(request)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("이미 사용 중인 이메일입니다."); } -} \ No newline at end of file +} From 55d674988fd59fa66882253cbfea046ab16441d2 Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Sat, 26 Jul 2025 15:32:41 +0900 Subject: [PATCH 018/135] =?UTF-8?q?refactor=20:=20=EA=B3=84=EC=B8=B5=20?= =?UTF-8?q?=EA=B0=84=EC=9D=98=20=EC=B1=85=EC=9E=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/controller/AuthController.java | 44 ++----- .../backend/controller/UserController.java | 55 +-------- .../backend/controller/WalletController.java | 54 ++------- .../com/joycrew/backend/entity/Employee.java | 13 ++ .../com/joycrew/backend/entity/Wallet.java | 32 ++++- .../exception/GlobalExceptionHandler.java | 35 ++++++ .../InsufficientPointsException.java | 7 ++ .../exception/UserNotFoundException.java | 7 ++ .../backend/service/EmployeeService.java | 112 +++++------------- .../backend/service/WalletService.java | 35 ++++++ 10 files changed, 175 insertions(+), 219 deletions(-) create mode 100644 src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/joycrew/backend/exception/InsufficientPointsException.java create mode 100644 src/main/java/com/joycrew/backend/exception/UserNotFoundException.java create mode 100644 src/main/java/com/joycrew/backend/service/WalletService.java diff --git a/src/main/java/com/joycrew/backend/controller/AuthController.java b/src/main/java/com/joycrew/backend/controller/AuthController.java index 6350d98..2841448 100644 --- a/src/main/java/com/joycrew/backend/controller/AuthController.java +++ b/src/main/java/com/joycrew/backend/controller/AuthController.java @@ -4,20 +4,13 @@ import com.joycrew.backend.dto.LoginResponse; import com.joycrew.backend.service.AuthService; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.web.bind.annotation.*; - import java.util.Map; @Tag(name = "인증", description = "로그인 관련 API") @@ -28,40 +21,17 @@ public class AuthController { private final AuthService authService; - @Operation(summary = "로그인", description = "이메일과 비밀번호를 이용해 JWT 토큰과 사용자 정보를 반환합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "로그인 성공", - content = @Content(mediaType = "application/json", - schema = @Schema(implementation = LoginResponse.class))), - @ApiResponse(responseCode = "401", description = "인증 실패 (이메일 또는 비밀번호 오류)", - content = @Content(mediaType = "application/json", - schema = @Schema(example = "{\"accessToken\": \"\", \"message\": \"이메일 또는 비밀번호가 올바르지 않습니다.\"}"))) - }) + @Operation(summary = "로그인") + @ApiResponse(responseCode = "200", description = "로그인 성공") + @ApiResponse(responseCode = "401", description = "인증 실패") @PostMapping("/login") public ResponseEntity login(@RequestBody @Valid LoginRequest request) { - try { - return ResponseEntity.ok(authService.login(request)); - } catch (UsernameNotFoundException | BadCredentialsException e) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(LoginResponse.builder() - .accessToken("") - .message(e.getMessage()) - .build()); - } catch (RuntimeException e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(LoginResponse.builder() - .accessToken("") - .message("서버 오류가 발생했습니다.") - .build()); - } + LoginResponse loginResponse = authService.login(request); + return ResponseEntity.ok(loginResponse); } - @Operation(summary = "로그아웃", description = "사용자의 로그아웃 요청을 처리합니다. 클라이언트는 이 응답을 받은 후 토큰을 삭제해야 합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "로그아웃 성공", - content = @Content(mediaType = "application/json", - schema = @Schema(example = "{\"message\": \"로그아웃 되었습니다.\"}"))) - }) + @Operation(summary = "로그아웃") + @ApiResponse(responseCode = "200", description = "로그아웃 성공") @PostMapping("/logout") public ResponseEntity> logout(HttpServletRequest request) { authService.logout(request); diff --git a/src/main/java/com/joycrew/backend/controller/UserController.java b/src/main/java/com/joycrew/backend/controller/UserController.java index 6af90cc..da61e42 100644 --- a/src/main/java/com/joycrew/backend/controller/UserController.java +++ b/src/main/java/com/joycrew/backend/controller/UserController.java @@ -2,16 +2,8 @@ import com.joycrew.backend.dto.PasswordChangeRequest; import com.joycrew.backend.dto.UserProfileResponse; -import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.entity.Wallet; -import com.joycrew.backend.repository.EmployeeRepository; -import com.joycrew.backend.repository.WalletRepository; import com.joycrew.backend.service.EmployeeService; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -19,9 +11,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; - import java.util.Map; -import java.util.Optional; @Tag(name = "사용자", description = "사용자 정보 관련 API") @RestController @@ -29,52 +19,19 @@ @RequiredArgsConstructor public class UserController { - private final EmployeeRepository employeeRepository; - private final WalletRepository walletRepository; private final EmployeeService employeeService; - @Operation(summary = "사용자 프로필 조회", description = "JWT 토큰으로 인증된 사용자의 프로필 정보를 반환합니다.", - security = @SecurityRequirement(name = "bearerAuth")) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", - content = @Content(schema = @Schema(implementation = UserProfileResponse.class))), - @ApiResponse(responseCode = "401", description = "인증 필요") - }) + @Operation(summary = "사용자 프로필 조회", security = @SecurityRequirement(name = "Authorization")) @GetMapping("/profile") - public ResponseEntity getProfile(Authentication authentication) { - String userEmail = authentication.getName(); - Employee employee = employeeRepository.findByEmail(userEmail) - .orElseThrow(() -> new RuntimeException("인증된 사용자를 찾을 수 없습니다.")); - - Optional walletOptional = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()); - int totalBalance = walletOptional.map(Wallet::getBalance).orElse(0); - int giftableBalance = walletOptional.map(Wallet::getGiftablePoint).orElse(0); - - UserProfileResponse response = UserProfileResponse.builder() - .employeeId(employee.getEmployeeId()) - .name(employee.getEmployeeName()) - .email(employee.getEmail()) - .role(employee.getRole()) - .department(employee.getDepartment() != null ? employee.getDepartment().getName() : null) - .position(employee.getPosition()) - .totalBalance(totalBalance) - .giftableBalance(giftableBalance) - .build(); - + public ResponseEntity getProfile(Authentication authentication) { + UserProfileResponse response = employeeService.getUserProfile(authentication.getName()); return ResponseEntity.ok(response); } - @Operation(summary = "비밀번호 변경 (첫 로그인 시)", description = "초기 비밀번호를 받은 사용자가 자신의 비밀번호를 새로 설정합니다.", - security = @SecurityRequirement(name = "bearerAuth")) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "비밀번호 변경 성공"), - @ApiResponse(responseCode = "400", description = "잘못된 요청 (비밀번호 정책 위반)"), - @ApiResponse(responseCode = "401", description = "인증 필요") - }) + @Operation(summary = "비밀번호 변경 (첫 로그인 시)", security = @SecurityRequirement(name = "Authorization")) @PostMapping("/password") public ResponseEntity> forceChangePassword(Authentication authentication, @Valid @RequestBody PasswordChangeRequest request) { - String userEmail = authentication.getName(); - employeeService.forcePasswordChange(userEmail, request); + employeeService.forcePasswordChange(authentication.getName(), request); return ResponseEntity.ok(Map.of("message", "비밀번호가 성공적으로 변경되었습니다.")); } -} +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/controller/WalletController.java b/src/main/java/com/joycrew/backend/controller/WalletController.java index 0a6a5b4..6118abe 100644 --- a/src/main/java/com/joycrew/backend/controller/WalletController.java +++ b/src/main/java/com/joycrew/backend/controller/WalletController.java @@ -1,23 +1,16 @@ package com.joycrew.backend.controller; import com.joycrew.backend.dto.PointBalanceResponse; -import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.entity.Wallet; -import com.joycrew.backend.repository.WalletRepository; +import com.joycrew.backend.service.WalletService; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; - -import java.util.Map; -import java.util.Optional; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; @Tag(name = "지갑", description = "포인트 관련 API") @RestController @@ -25,39 +18,12 @@ @RequiredArgsConstructor public class WalletController { - private final WalletRepository walletRepository; + private final WalletService walletService; - @Operation(summary = "포인트 잔액 조회", description = "현재 로그인된 사용자의 포인트 잔액을 반환합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", - content = @Content(mediaType = "application/json", - schema = @Schema(implementation = PointBalanceResponse.class))), - @ApiResponse(responseCode = "401", description = "인증 필요", - content = @Content(mediaType = "application/json", - schema = @Schema(example = "{\"message\": \"로그인이 필요합니다.\"}"))) - }) + @Operation(summary = "포인트 잔액 조회", security = @SecurityRequirement(name = "Authorization")) @GetMapping("/point") - public ResponseEntity getWalletPoint(Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("message", "로그인이 필요합니다.")); - } - - String userEmail = authentication.getName(); - - Long employeeId = ((Employee) authentication.getPrincipal()).getEmployeeId(); - - Optional walletOptional = walletRepository.findByEmployee_EmployeeId(employeeId); - - int totalBalance = 0; - int giftableBalance = 0; - - if (walletOptional.isPresent()) { - Wallet wallet = walletOptional.get(); - totalBalance = wallet.getBalance(); - giftableBalance = wallet.getGiftablePoint(); - } - - return ResponseEntity.ok(new PointBalanceResponse(totalBalance, giftableBalance)); + public ResponseEntity getWalletPoint(Authentication authentication) { + PointBalanceResponse response = walletService.getPointBalance(authentication.getName()); + return ResponseEntity.ok(response); } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/Employee.java b/src/main/java/com/joycrew/backend/entity/Employee.java index 8b0428a..23c82b0 100644 --- a/src/main/java/com/joycrew/backend/entity/Employee.java +++ b/src/main/java/com/joycrew/backend/entity/Employee.java @@ -94,6 +94,19 @@ public Collection getAuthorities() { return Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + this.role)); } + public void changePassword(String newEncodedPassword) { + this.passwordHash = newEncodedPassword; + } + + public void updateProfile(String newName, String newPosition) { + if (newName != null) { + this.employeeName = newName; + } + if (newPosition != null) { + this.position = newPosition; + } + } + @Override public String getPassword() { return this.passwordHash; diff --git a/src/main/java/com/joycrew/backend/entity/Wallet.java b/src/main/java/com/joycrew/backend/entity/Wallet.java index 277de88..3262233 100644 --- a/src/main/java/com/joycrew/backend/entity/Wallet.java +++ b/src/main/java/com/joycrew/backend/entity/Wallet.java @@ -1,16 +1,13 @@ package com.joycrew.backend.entity; +import com.joycrew.backend.exception.InsufficientPointsException; import jakarta.persistence.*; import lombok.*; import java.time.LocalDateTime; @Entity -@Table(name = "wallet") @Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class Wallet { @Id @@ -30,6 +27,31 @@ public class Wallet { @Column(nullable = false) private LocalDateTime updatedAt; + public Wallet(Employee employee) { + this.employee = employee; + this.balance = 0; + this.giftablePoint = 0; + } + + public void addPoints(int amount) { + if (amount < 0) { + throw new IllegalArgumentException("포인트는 음수일 수 없습니다."); + } + this.balance += amount; + this.giftablePoint += amount; + } + + public void spendPoints(int amount) { + if (amount < 0) { + throw new IllegalArgumentException("포인트는 음수일 수 없습니다."); + } + if (this.balance < amount || this.giftablePoint < amount) { + throw new InsufficientPointsException("선물 가능한 포인트가 부족합니다."); + } + this.balance -= amount; + this.giftablePoint -= amount; + } + @PrePersist protected void onCreate() { this.createdAt = this.updatedAt = LocalDateTime.now(); diff --git a/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java b/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..c787087 --- /dev/null +++ b/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java @@ -0,0 +1,35 @@ +package com.joycrew.backend.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + // 인증 실패 (로그인 실패 등) + @ExceptionHandler({BadCredentialsException.class, UserNotFoundException.class}) + public ResponseEntity> handleAuthenticationException(Exception e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("message", e.getMessage())); + } + + // DTO 유효성 검사 실패 + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationExceptions(MethodArgumentNotValidException ex) { + String errorMessage = ex.getBindingResult().getAllErrors().get(0).getDefaultMessage(); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + + // 기타 서버 내부 오류 + @ExceptionHandler(Exception.class) + public ResponseEntity> handleAllUncaughtException(Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("message", "서버 내부 오류가 발생했습니다.")); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/exception/InsufficientPointsException.java b/src/main/java/com/joycrew/backend/exception/InsufficientPointsException.java new file mode 100644 index 0000000..61430a0 --- /dev/null +++ b/src/main/java/com/joycrew/backend/exception/InsufficientPointsException.java @@ -0,0 +1,7 @@ +package com.joycrew.backend.exception; + +public class InsufficientPointsException extends RuntimeException { + public InsufficientPointsException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/exception/UserNotFoundException.java b/src/main/java/com/joycrew/backend/exception/UserNotFoundException.java new file mode 100644 index 0000000..c51e17d --- /dev/null +++ b/src/main/java/com/joycrew/backend/exception/UserNotFoundException.java @@ -0,0 +1,7 @@ +package com.joycrew.backend.exception; + +public class UserNotFoundException extends RuntimeException { + public UserNotFoundException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/EmployeeService.java b/src/main/java/com/joycrew/backend/service/EmployeeService.java index 4c11266..253df19 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeService.java @@ -1,14 +1,10 @@ package com.joycrew.backend.service; -import com.joycrew.backend.dto.AdminEmployeeUpdateRequest; -import com.joycrew.backend.dto.EmployeeRegistrationRequest; import com.joycrew.backend.dto.PasswordChangeRequest; -import com.joycrew.backend.entity.Company; -import com.joycrew.backend.entity.Department; +import com.joycrew.backend.dto.UserProfileResponse; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.Wallet; -import com.joycrew.backend.repository.CompanyRepository; -import com.joycrew.backend.repository.DepartmentRepository; +import com.joycrew.backend.exception.UserNotFoundException; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.WalletRepository; import lombok.RequiredArgsConstructor; @@ -21,98 +17,46 @@ @Transactional public class EmployeeService { private final EmployeeRepository employeeRepository; - private final CompanyRepository companyRepository; - private final DepartmentRepository departmentRepository; - private final WalletRepository walletRepository; + private final WalletRepository walletRepository; // Wallet 정보 조회를 위해 주입 private final PasswordEncoder passwordEncoder; /** - * [HR 관리자 기능] 신규 직원을 등록합니다. - * 초기 비밀번호가 설정되며, 첫 로그인 시 변경해야 합니다. - * - * @param request 신규 직원 정보 DTO - * @return 생성된 Employee 엔티티 + * [리팩토링] + * 사용자 프로필 조회 로직을 서비스 계층으로 이동. + * 컨트롤러는 이 메서드를 호출하여 DTO를 받기만 하면 됨. + * @param userEmail 조회할 사용자의 이메일 + * @return UserProfileResponse DTO */ - public Employee registerEmployee(EmployeeRegistrationRequest request) { - if (employeeRepository.findByEmail(request.getEmail()).isPresent()) { - throw new IllegalStateException("이미 사용 중인 이메일입니다."); - } - - Company company = companyRepository.findById(request.getCompanyId()) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회사 ID입니다.")); - - Department department = null; - if (request.getDepartmentId() != null) { - department = departmentRepository.findById(request.getDepartmentId()) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 부서 ID입니다.")); - } - - Employee newEmployee = Employee.builder() - .employeeName(request.getName()) - .email(request.getEmail()) - .passwordHash(passwordEncoder.encode(request.getInitialPassword())) - .company(company) - .department(department) - .position(request.getPosition()) - .role(request.getRole()) - .status("ACTIVE") - .build(); - - Employee savedEmployee = employeeRepository.save(newEmployee); - - Wallet newWallet = Wallet.builder() - .employee(savedEmployee) - .balance(0) - .giftablePoint(0) + @Transactional(readOnly = true) + public UserProfileResponse getUserProfile(String userEmail) { + Employee employee = employeeRepository.findByEmail(userEmail) + .orElseThrow(() -> new UserNotFoundException("인증된 사용자를 찾을 수 없습니다.")); + + // Wallet 정보 조회 로직도 서비스 계층에서 처리 + Wallet wallet = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) + .orElse(new Wallet(employee)); + + return UserProfileResponse.builder() + .employeeId(employee.getEmployeeId()) + .name(employee.getEmployeeName()) + .email(employee.getEmail()) + .role(employee.getRole()) + .department(employee.getDepartment() != null ? employee.getDepartment().getName() : null) + .position(employee.getPosition()) + .totalBalance(wallet.getBalance()) + .giftableBalance(wallet.getGiftablePoint()) .build(); - walletRepository.save(newWallet); - - return savedEmployee; - } - - /** - * [HR 관리자 기능] 직원의 기본 정보를 수정합니다. - * - * @param employeeId 수정 대상 직원 ID - * @param request 수정할 정보 DTO - * @return 업데이트된 Employee 엔티티 - */ - public Employee updateEmployeeDetailsByAdmin(Long employeeId, AdminEmployeeUpdateRequest request) { - Employee employee = employeeRepository.findById(employeeId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 직원 ID입니다.")); - - if (request.getName() != null) { - employee.setEmployeeName(request.getName()); - } - if (request.getDepartmentId() != null) { - Department department = departmentRepository.findById(request.getDepartmentId()) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 부서 ID입니다.")); - employee.setDepartment(department); - } - if (request.getPosition() != null) { - employee.setPosition(request.getPosition()); - } - if (request.getRole() != null) { - employee.setRole(request.getRole()); - } - if (request.getStatus() != null) { - employee.setStatus(request.getStatus()); - } - - return employeeRepository.save(employee); } /** * [직원 기능] 첫 로그인 시 비밀번호를 변경합니다. - * * @param userEmail 현재 로그인된 사용자 이메일 * @param request 새 비밀번호 정보 DTO */ public void forcePasswordChange(String userEmail, PasswordChangeRequest request) { Employee employee = employeeRepository.findByEmail(userEmail) - .orElseThrow(() -> new RuntimeException("인증된 사용자를 찾을 수 없습니다.")); - + .orElseThrow(() -> new UserNotFoundException("인증된 사용자를 찾을 수 없습니다.")); employee.setPasswordHash(passwordEncoder.encode(request.getNewPassword())); employeeRepository.save(employee); } -} +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/WalletService.java b/src/main/java/com/joycrew/backend/service/WalletService.java new file mode 100644 index 0000000..0c500bf --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/WalletService.java @@ -0,0 +1,35 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.PointBalanceResponse; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.exception.UserNotFoundException; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.WalletRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class WalletService { + private final WalletRepository walletRepository; + private final EmployeeRepository employeeRepository; + + /** + * [리팩토링] + * 포인트 잔액 조회 로직을 별도의 WalletService로 분리하여 응집도를 높임. + * @param userEmail 조회할 사용자의 이메일 + * @return PointBalanceResponse DTO + */ + public PointBalanceResponse getPointBalance(String userEmail) { + Employee employee = employeeRepository.findByEmail(userEmail) + .orElseThrow(() -> new UserNotFoundException("인증된 사용자를 찾을 수 없습니다.")); + + Wallet wallet = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) + .orElse(new Wallet(employee)); + + return new PointBalanceResponse(wallet.getBalance(), wallet.getGiftablePoint()); + } +} From bbf3f0731d03e08e70f0d2f23dadca909a9fada6 Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Sat, 26 Jul 2025 15:35:23 +0900 Subject: [PATCH 019/135] =?UTF-8?q?refactor=20:=20event=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=95=84=ED=82=A4=ED=85=8D=EC=B2=98=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/event/NotificationListener.java | 25 +++++++++++++++++++ .../backend/event/RecognitionEvent.java | 20 +++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 src/main/java/com/joycrew/backend/event/NotificationListener.java create mode 100644 src/main/java/com/joycrew/backend/event/RecognitionEvent.java diff --git a/src/main/java/com/joycrew/backend/event/NotificationListener.java b/src/main/java/com/joycrew/backend/event/NotificationListener.java new file mode 100644 index 0000000..f6d2ad4 --- /dev/null +++ b/src/main/java/com/joycrew/backend/event/NotificationListener.java @@ -0,0 +1,25 @@ +package com.joycrew.backend.event; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class NotificationListener { + + @Async + @EventListener + public void handleRecognitionEvent(RecognitionEvent event) { + log.info("포인트 전송 이벤트 수신 (비동기 처리 시작)"); + try { + Thread.sleep(2000); + log.info("{}님이 {}님에게 {} 포인트를 선물했습니다. 메시지: {}", + event.getSenderId(), event.getReceiverId(), event.getPoints(), event.getMessage()); + } catch (InterruptedException e) { + log.error("알림 처리 중 오류 발생", e); + Thread.currentThread().interrupt(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/event/RecognitionEvent.java b/src/main/java/com/joycrew/backend/event/RecognitionEvent.java new file mode 100644 index 0000000..75a6d91 --- /dev/null +++ b/src/main/java/com/joycrew/backend/event/RecognitionEvent.java @@ -0,0 +1,20 @@ +package com.joycrew.backend.event; + +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +@Getter +public class RecognitionEvent extends ApplicationEvent { + private final Long senderId; + private final Long receiverId; + private final int points; + private final String message; + + public RecognitionEvent(Object source, Long senderId, Long receiverId, int points, String message) { + super(source); + this.senderId = senderId; + this.receiverId = receiverId; + this.points = points; + this.message = message; + } +} \ No newline at end of file From d2b04a9baedee90657c1dab337ff559a0eb22ceb Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Sat, 26 Jul 2025 15:42:41 +0900 Subject: [PATCH 020/135] =?UTF-8?q?refactor=20:=20=EA=B0=80=EB=B3=80?= =?UTF-8?q?=EC=84=B1=20=ED=97=88=EC=9A=A9=20=EB=93=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/config/SecurityConfig.java | 3 ++ .../controller/RecognitionController.java | 30 +++++++++++ .../joycrew/backend/dto/ErrorResponse.java | 4 ++ .../backend/dto/RecognitionRequest.java | 11 ++++ .../entity/RewardPointTransaction.java | 7 +-- .../backend/event/NotificationListener.java | 1 - .../exception/GlobalExceptionHandler.java | 13 +++++ .../backend/service/RecognitionService.java | 50 +++++++++++++++++++ .../backend/service/WalletService.java | 6 --- 9 files changed, 115 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/joycrew/backend/controller/RecognitionController.java create mode 100644 src/main/java/com/joycrew/backend/dto/ErrorResponse.java create mode 100644 src/main/java/com/joycrew/backend/dto/RecognitionRequest.java create mode 100644 src/main/java/com/joycrew/backend/service/RecognitionService.java diff --git a/src/main/java/com/joycrew/backend/config/SecurityConfig.java b/src/main/java/com/joycrew/backend/config/SecurityConfig.java index cacc67e..0516ba1 100644 --- a/src/main/java/com/joycrew/backend/config/SecurityConfig.java +++ b/src/main/java/com/joycrew/backend/config/SecurityConfig.java @@ -27,6 +27,8 @@ import java.util.Map; +import static com.joycrew.backend.entity.enums.UserRole.HR_ADMIN; + @Configuration @EnableWebSecurity @@ -52,6 +54,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/swagger-ui/**", "/swagger-ui.html" ).permitAll() + .requestMatchers("/api/admin/**").hasRole(HR_ADMIN.name()) .anyRequest().authenticated() ) .exceptionHandling(exceptions -> exceptions diff --git a/src/main/java/com/joycrew/backend/controller/RecognitionController.java b/src/main/java/com/joycrew/backend/controller/RecognitionController.java new file mode 100644 index 0000000..50674fa --- /dev/null +++ b/src/main/java/com/joycrew/backend/controller/RecognitionController.java @@ -0,0 +1,30 @@ +package com.joycrew.backend.controller; + +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.service.RecognitionService; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@Tag(name = "인정/보상", description = "동료 간 포인트 보상 API") +@RestController +@RequestMapping("/api/recognitions") +@RequiredArgsConstructor +public class RecognitionController { + private final RecognitionService recognitionService; + + @PostMapping + public ResponseEntity> sendPoints( + // [L3] @AuthenticationPrincipal을 사용하여 프레임워크 의존성 제거 + @AuthenticationPrincipal Employee sender, + @Valid @RequestBody RecognitionRequest request + ) { + recognitionService.sendRecognition(sender.getEmail(), request); + return ResponseEntity.ok(Map.of("message", "포인트를 성공적으로 보냈습니다.")); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/ErrorResponse.java b/src/main/java/com/joycrew/backend/dto/ErrorResponse.java new file mode 100644 index 0000000..5be4b2c --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/ErrorResponse.java @@ -0,0 +1,4 @@ +package com.joycrew.backend.dto; + +public record ErrorResponse(String code, String message) { +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/RecognitionRequest.java b/src/main/java/com/joycrew/backend/dto/RecognitionRequest.java new file mode 100644 index 0000000..a21a057 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/RecognitionRequest.java @@ -0,0 +1,11 @@ +package com.joycrew.backend.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record RecognitionRequest( + @NotNull Long receiverId, + @Min(1) int points, + @Size(max = 255) String message +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/RewardPointTransaction.java b/src/main/java/com/joycrew/backend/entity/RewardPointTransaction.java index 75e3c5a..50fc2ab 100644 --- a/src/main/java/com/joycrew/backend/entity/RewardPointTransaction.java +++ b/src/main/java/com/joycrew/backend/entity/RewardPointTransaction.java @@ -3,14 +3,14 @@ import com.joycrew.backend.entity.enums.TransactionType; import jakarta.persistence.*; import lombok.*; + import java.time.LocalDateTime; @Entity @Table(name = "reward_point_transaction") @Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Builder public class RewardPointTransaction { @@ -38,6 +38,7 @@ public class RewardPointTransaction { @Column(nullable = false) private LocalDateTime transactionDate; + @Column(nullable = false) private LocalDateTime createdAt; diff --git a/src/main/java/com/joycrew/backend/event/NotificationListener.java b/src/main/java/com/joycrew/backend/event/NotificationListener.java index f6d2ad4..adc2896 100644 --- a/src/main/java/com/joycrew/backend/event/NotificationListener.java +++ b/src/main/java/com/joycrew/backend/event/NotificationListener.java @@ -22,4 +22,3 @@ public void handleRecognitionEvent(RecognitionEvent event) { Thread.currentThread().interrupt(); } } -} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java b/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java index c787087..53556b2 100644 --- a/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.web.ErrorResponse; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -12,6 +13,18 @@ @RestControllerAdvice public class GlobalExceptionHandler { + @ExceptionHandler(InsufficientPointsException.class) + public ResponseEntity handleInsufficientPoints(InsufficientPointsException ex) { + ErrorResponse response = new ErrorResponse("INSUFFICIENT_POINTS", ex.getMessage()); + return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(UserNotFoundException.class) + public ResponseEntity handleUserNotFound(UserNotFoundException ex) { + ErrorResponse response = new ErrorResponse("USER_NOT_FOUND", ex.getMessage()); + return new ResponseEntity<>(response, HttpStatus.NOT_FOUND); + } + // 인증 실패 (로그인 실패 등) @ExceptionHandler({BadCredentialsException.class, UserNotFoundException.class}) public ResponseEntity> handleAuthenticationException(Exception e) { diff --git a/src/main/java/com/joycrew/backend/service/RecognitionService.java b/src/main/java/com/joycrew/backend/service/RecognitionService.java new file mode 100644 index 0000000..487e098 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/RecognitionService.java @@ -0,0 +1,50 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.RewardPointTransaction; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.TransactionType; +import com.joycrew.backend.event.RecognitionEvent; +import com.joycrew.backend.exception.UserNotFoundException; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.WalletRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class RecognitionService { + private final EmployeeRepository employeeRepository; + private final WalletRepository walletRepository; + private final RewardPointTransactionRepository transactionRepository; + private final ApplicationEventPublisher eventPublisher; // [L3] 이벤트 발행을 위한 주입 + + @Transactional + public void sendRecognition(String senderEmail, RecognitionRequest request) { + Employee sender = employeeRepository.findByEmail(senderEmail) + .orElseThrow(() -> new UserNotFoundException("보내는 사용자를 찾을 수 없습니다.")); + Employee receiver = employeeRepository.findById(request.receiverId()) + .orElseThrow(() -> new UserNotFoundException("받는 사용자를 찾을 수 없습니다.")); + + Wallet senderWallet = walletRepository.findByEmployee_EmployeeId(sender.getEmployeeId()) + .orElseThrow(() -> new IllegalStateException("보내는 사용자의 지갑이 없습니다.")); + Wallet receiverWallet = walletRepository.findByEmployee_EmployeeId(receiver.getEmployeeId()) + .orElseThrow(() -> new IllegalStateException("받는 사용자의 지갑이 없습니다.")); + + senderWallet.spendPoints(request.points()); + receiverWallet.addPoints(request.points()); + + RewardPointTransaction transaction = RewardPointTransaction.builder() + .sender(sender) + .receiver(receiver) + .pointAmount(request.points()) + .message(request.message()) + .type(TransactionType.AWARD_P2P) + .build(); + transactionRepository.save(transaction); + + eventPublisher.publishEvent(new RecognitionEvent(this, sender.getEmployeeId(), receiver.getEmployeeId(), request.points(), request.message())); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/WalletService.java b/src/main/java/com/joycrew/backend/service/WalletService.java index 0c500bf..9715fdb 100644 --- a/src/main/java/com/joycrew/backend/service/WalletService.java +++ b/src/main/java/com/joycrew/backend/service/WalletService.java @@ -17,12 +17,6 @@ public class WalletService { private final WalletRepository walletRepository; private final EmployeeRepository employeeRepository; - /** - * [리팩토링] - * 포인트 잔액 조회 로직을 별도의 WalletService로 분리하여 응집도를 높임. - * @param userEmail 조회할 사용자의 이메일 - * @return PointBalanceResponse DTO - */ public PointBalanceResponse getPointBalance(String userEmail) { Employee employee = employeeRepository.findByEmail(userEmail) .orElseThrow(() -> new UserNotFoundException("인증된 사용자를 찾을 수 없습니다.")); From f13e2acff10c80c0281c9f421bf4f0ef079d0fac Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Sat, 26 Jul 2025 15:47:55 +0900 Subject: [PATCH 021/135] =?UTF-8?q?refactor=20:=20=EB=82=AE=EC=9D=80=20?= =?UTF-8?q?=EC=9D=91=EC=A7=91=EB=8F=84=20=EB=B0=8F=20=EC=BA=A1=EC=8A=90?= =?UTF-8?q?=ED=99=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/joycrew/backend/entity/Employee.java | 44 +++++++------- .../security/EmployeeDetailsService.java | 5 +- .../backend/security/UserPrincipal.java | 57 +++++++++++++++++++ 3 files changed, 86 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/joycrew/backend/security/UserPrincipal.java diff --git a/src/main/java/com/joycrew/backend/entity/Employee.java b/src/main/java/com/joycrew/backend/entity/Employee.java index 23c82b0..2a33517 100644 --- a/src/main/java/com/joycrew/backend/entity/Employee.java +++ b/src/main/java/com/joycrew/backend/entity/Employee.java @@ -8,6 +8,7 @@ import org.springframework.security.core.userdetails.UserDetails; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; @@ -15,9 +16,8 @@ @Entity @Table(name = "employee") @Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Builder public class Employee implements UserDetails { @@ -65,14 +65,33 @@ public class Employee implements UserDetails { @OneToOne(mappedBy = "employee", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) private Wallet wallet; + @Builder.Default @OneToMany(mappedBy = "sender", cascade = CascadeType.ALL, orphanRemoval = true) - private List sentTransactions; + private List sentTransactions = new ArrayList<>(); + @Builder.Default @OneToMany(mappedBy = "receiver", cascade = CascadeType.ALL, orphanRemoval = true) - private List receivedTransactions; + private List receivedTransactions = new ArrayList<>(); + @Builder.Default @OneToMany(mappedBy = "employee", cascade = CascadeType.ALL, orphanRemoval = true) - private List adminAccesses; + private List adminAccesses = new ArrayList<>(); + + public void changePassword(String newEncodedPassword) { + this.passwordHash = newEncodedPassword; + } + + public void updateLastLogin() { + this.lastLoginAt = LocalDateTime.now(); + } + + public void deactivate() { + this.status = "INACTIVE"; + } + + public void assignToDepartment(Department newDepartment) { + this.department = newDepartment; + } @PrePersist protected void onCreate() { @@ -94,19 +113,6 @@ public Collection getAuthorities() { return Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + this.role)); } - public void changePassword(String newEncodedPassword) { - this.passwordHash = newEncodedPassword; - } - - public void updateProfile(String newName, String newPosition) { - if (newName != null) { - this.employeeName = newName; - } - if (newPosition != null) { - this.position = newPosition; - } - } - @Override public String getPassword() { return this.passwordHash; diff --git a/src/main/java/com/joycrew/backend/security/EmployeeDetailsService.java b/src/main/java/com/joycrew/backend/security/EmployeeDetailsService.java index 86f1233..66ee340 100644 --- a/src/main/java/com/joycrew/backend/security/EmployeeDetailsService.java +++ b/src/main/java/com/joycrew/backend/security/EmployeeDetailsService.java @@ -7,6 +7,7 @@ import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -15,10 +16,12 @@ public class EmployeeDetailsService implements UserDetailsService { private final EmployeeRepository employeeRepository; @Override + @Transactional(readOnly = true) public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { Employee employee = employeeRepository.findByEmail(email) .orElseThrow(() -> new UsernameNotFoundException("User not found with email: " + email)); - return employee; + // Employee 엔티티를 UserPrincipal로 감싸서 반환 + return new UserPrincipal(employee); } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/security/UserPrincipal.java b/src/main/java/com/joycrew/backend/security/UserPrincipal.java new file mode 100644 index 0000000..838e375 --- /dev/null +++ b/src/main/java/com/joycrew/backend/security/UserPrincipal.java @@ -0,0 +1,57 @@ +package com.joycrew.backend.security; + +import com.joycrew.backend.entity.Employee; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.Collections; + +@Getter +public class UserPrincipal implements UserDetails { + + private final Employee employee; + + public UserPrincipal(Employee employee) { + this.employee = employee; + } + + @Override + public Collection getAuthorities() { + return Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + employee.getRole().name())); + } + + @Override + public String getPassword() { + return employee.getPasswordHash(); + } + + @Override + public String getUsername() { + return employee.getEmail(); + } + + // Employee의 상태에 따라 계정 활성화 여부 결정 + @Override + public boolean isEnabled() { + return "ACTIVE".equals(employee.getStatus()); + } + + // 필요에 따라 아래 메서드들도 Employee의 필드와 연동 가능 + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } +} \ No newline at end of file From 6a444209cd6edc0a0b767d526c31084777ecf9c9 Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Sat, 26 Jul 2025 15:50:52 +0900 Subject: [PATCH 022/135] =?UTF-8?q?refactor=20:=20CascadeType.ALL=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../joycrew/backend/entity/Department.java | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/joycrew/backend/entity/Department.java b/src/main/java/com/joycrew/backend/entity/Department.java index 25e6426..88d3760 100644 --- a/src/main/java/com/joycrew/backend/entity/Department.java +++ b/src/main/java/com/joycrew/backend/entity/Department.java @@ -3,12 +3,12 @@ import jakarta.persistence.*; import lombok.*; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; @Entity @Table(name = "department") @Getter -@Setter @NoArgsConstructor @AllArgsConstructor @Builder @@ -26,17 +26,31 @@ public class Department { private Company company; @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "department_head_id", nullable = true) + @JoinColumn(name = "department_head_id", unique = true) private Employee departmentHead; - @OneToMany(mappedBy = "department", cascade = CascadeType.ALL, orphanRemoval = true) - private List employees; + @Builder.Default + @OneToMany(mappedBy = "department") + private List employees = new ArrayList<>(); @Column(nullable = false) private LocalDateTime createdAt; @Column(nullable = false) private LocalDateTime updatedAt; + public void changeName(String newName) { + this.name = newName; + } + + public void assignHead(Employee head) { + this.departmentHead = head; + } + + public void addEmployee(Employee employee) { + this.employees.add(employee); + employee.assignToDepartment(this); + } + @PrePersist protected void onCreate() { this.createdAt = this.updatedAt = LocalDateTime.now(); From 3c25999fdda21ef808d693f3200556cab0f48272 Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Sat, 26 Jul 2025 15:52:48 +0900 Subject: [PATCH 023/135] =?UTF-8?q?refactor=20:=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=97=B0=EC=87=84=20=EC=82=AD=EC=A0=9C=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EB=93=B1=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/joycrew/backend/entity/Company.java | 36 +++++++++++++++---- .../backend/entity/CompanyAdminAccess.java | 13 ++++--- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/joycrew/backend/entity/Company.java b/src/main/java/com/joycrew/backend/entity/Company.java index 0750571..827047c 100644 --- a/src/main/java/com/joycrew/backend/entity/Company.java +++ b/src/main/java/com/joycrew/backend/entity/Company.java @@ -2,15 +2,16 @@ import jakarta.persistence.*; import lombok.*; + import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; @Entity @Table(name = "company") @Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Builder public class Company { @@ -25,24 +26,45 @@ public class Company { @Column(nullable = false) private Double totalCompanyBalance; - @OneToMany(mappedBy = "company", cascade = CascadeType.ALL, orphanRemoval = true) - private List employees; + @Builder.Default + @OneToMany(mappedBy = "company") + private List employees = new ArrayList<>(); + @Builder.Default @OneToMany(mappedBy = "company", cascade = CascadeType.ALL, orphanRemoval = true) - private List departments; + private List departments = new ArrayList<>(); + @Builder.Default @OneToMany(mappedBy = "company", cascade = CascadeType.ALL, orphanRemoval = true) - private List adminAccessList; + private List adminAccessList = new ArrayList<>(); private LocalDateTime createdAt; private LocalDateTime updatedAt; + public void changeName(String newCompanyName) { + this.companyName = newCompanyName; + } + + public void changeStatus(String newStatus) { + this.status = newStatus; + } + + public void addBudget(double amount) { + if (amount < 0) { + throw new IllegalArgumentException("예산은 음수일 수 없습니다."); + } + this.totalCompanyBalance += amount; + } + @PrePersist protected void onCreate() { this.createdAt = this.updatedAt = LocalDateTime.now(); if (this.totalCompanyBalance == null) { this.totalCompanyBalance = 0.0; } + if (this.status == null) { + this.status = "ACTIVE"; + } } @PreUpdate diff --git a/src/main/java/com/joycrew/backend/entity/CompanyAdminAccess.java b/src/main/java/com/joycrew/backend/entity/CompanyAdminAccess.java index c7d9a00..d5983d4 100644 --- a/src/main/java/com/joycrew/backend/entity/CompanyAdminAccess.java +++ b/src/main/java/com/joycrew/backend/entity/CompanyAdminAccess.java @@ -4,14 +4,14 @@ import com.joycrew.backend.entity.enums.AdminLevel; import jakarta.persistence.*; import lombok.*; + import java.time.LocalDateTime; @Entity @Table(name = "company_admin_access") @Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Builder public class CompanyAdminAccess { @@ -47,6 +47,11 @@ public class CompanyAdminAccess { @Column(nullable = false) private LocalDateTime updatedAt; + public void revoke() { + this.status = AccessStatus.REVOKED; + } + + @PrePersist protected void onCreate() { this.createdAt = this.updatedAt = LocalDateTime.now(); @@ -62,4 +67,4 @@ protected void onCreate() { protected void onUpdate() { this.updatedAt = LocalDateTime.now(); } -} \ No newline at end of file +} From 3f335f0e25d7eac0335a9df63ac290cba568b1ee Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Sat, 26 Jul 2025 15:58:24 +0900 Subject: [PATCH 024/135] =?UTF-8?q?refactor=20:=20Java=20Record=20Type=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/AdminEmployeeUpdateRequest.java | 18 ++++---- .../dto/EmployeeRegistrationRequest.java | 22 ++++------ .../com/joycrew/backend/dto/LoginRequest.java | 24 ++++------- .../joycrew/backend/dto/LoginResponse.java | 41 ++++++++----------- .../backend/event/NotificationListener.java | 1 + 5 files changed, 42 insertions(+), 64 deletions(-) diff --git a/src/main/java/com/joycrew/backend/dto/AdminEmployeeUpdateRequest.java b/src/main/java/com/joycrew/backend/dto/AdminEmployeeUpdateRequest.java index a23113d..659c348 100644 --- a/src/main/java/com/joycrew/backend/dto/AdminEmployeeUpdateRequest.java +++ b/src/main/java/com/joycrew/backend/dto/AdminEmployeeUpdateRequest.java @@ -1,15 +1,11 @@ package com.joycrew.backend.dto; import com.joycrew.backend.entity.enums.UserRole; -import lombok.Getter; -import lombok.Setter; -@Getter -@Setter -public class AdminEmployeeUpdateRequest { - private String name; - private Long departmentId; - private String position; - private UserRole role; - private String status; -} +public record AdminEmployeeUpdateRequest( + String name, + Long departmentId, + String position, + UserRole role, + String status +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java b/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java index 650c41f..781f88b 100644 --- a/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java +++ b/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java @@ -5,31 +5,27 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; -import lombok.Getter; -import lombok.Setter; -@Getter -@Setter -public class EmployeeRegistrationRequest { +public record EmployeeRegistrationRequest ( @NotBlank(message = "이름은 필수입니다.") - private String name; + String name, @NotBlank(message = "이메일은 필수입니다.") @Email(message = "유효한 이메일 형식이 아닙니다.") - private String email; + String email, @NotBlank(message = "초기 비밀번호는 필수입니다.") @Size(min = 8, message = "비밀번호는 8자 이상이어야 합니다.") - private String initialPassword; + String initialPassword, @NotNull(message = "회사 ID는 필수입니다.") - private Long companyId; + Long companyId, - private Long departmentId; + Long departmentId, @NotBlank(message = "직책은 필수입니다.") - private String position; + String position, @NotNull(message = "역할은 필수입니다.") - private UserRole role; -} + UserRole role +){} diff --git a/src/main/java/com/joycrew/backend/dto/LoginRequest.java b/src/main/java/com/joycrew/backend/dto/LoginRequest.java index 114286d..690a41f 100644 --- a/src/main/java/com/joycrew/backend/dto/LoginRequest.java +++ b/src/main/java/com/joycrew/backend/dto/LoginRequest.java @@ -3,20 +3,14 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; -import lombok.Getter; -import lombok.Setter; -@Getter -@Setter -@Schema(description = "로그인 요청 DTO") -public class LoginRequest { +public record LoginRequest( + @Schema(description = "이메일 주소", example = "user@example.com") + @Email(message = "유효한 이메일 형식이 아닙니다.") + @NotBlank(message = "이메일은 필수입니다.") + String email, - @Schema(description = "이메일 주소", example = "user@example.com", required = true) - @Email - @NotBlank - private String email; - - @Schema(description = "비밀번호", example = "password123!", required = true) - @NotBlank - private String password; -} + @Schema(description = "비밀번호", example = "password123!") + @NotBlank(message = "비밀번호는 필수입니다.") + String password +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/LoginResponse.java b/src/main/java/com/joycrew/backend/dto/LoginResponse.java index 97c4622..f5fa572 100644 --- a/src/main/java/com/joycrew/backend/dto/LoginResponse.java +++ b/src/main/java/com/joycrew/backend/dto/LoginResponse.java @@ -2,31 +2,22 @@ import com.joycrew.backend.entity.enums.UserRole; import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; // @Builder 어노테이션 추가 -import lombok.Getter; -@Getter -@AllArgsConstructor -@Builder @Schema(description = "로그인 응답 DTO") -public class LoginResponse { - - @Schema(description = "JWT 토큰", example = "eyJhbGciOiJIUzI1NiJ9...") - private String accessToken; - - @Schema(description = "응답 메시지 (성공/실패)", example = "로그인 성공" ) - private String message; - - @Schema(description = "사용자 고유 ID", example = "1") - private Long userId; - - @Schema(description = "사용자 이름", example = "홍길동") - private String name; - - @Schema(description = "사용자 이메일", example = "user@example.com") - private String email; - - @Schema(description = "사용자 역할", example = "EMPLOYEE", allowableValues = {"EMPLOYEE", "MANAGER", "HR_ADMIN", "SUPER_ADMIN"}) - private UserRole role; // ENUM 타입 유지 +public record LoginResponse( + @Schema(description = "JWT 토큰") + String accessToken, + @Schema(description = "응답 메시지") + String message, + @Schema(description = "사용자 고유 ID") + Long userId, + @Schema(description = "사용자 이름") + String name, + @Schema(description = "사용자 이메일") + String email, + @Schema(description = "사용자 역할") + UserRole role +) { public static LoginResponse fail(String message) { + return new LoginResponse(null, message, null, null, null, null); +} } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/event/NotificationListener.java b/src/main/java/com/joycrew/backend/event/NotificationListener.java index adc2896..f6d2ad4 100644 --- a/src/main/java/com/joycrew/backend/event/NotificationListener.java +++ b/src/main/java/com/joycrew/backend/event/NotificationListener.java @@ -22,3 +22,4 @@ public void handleRecognitionEvent(RecognitionEvent event) { Thread.currentThread().interrupt(); } } +} \ No newline at end of file From eea612a13c3d016fdea973e2964669cdfa87a08f Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Sat, 26 Jul 2025 16:01:58 +0900 Subject: [PATCH 025/135] =?UTF-8?q?refactor=20:=20Java=20Record=20Type=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/dto/PasswordChangeRequest.java | 14 ++--- .../backend/dto/PointBalanceResponse.java | 14 ++--- .../backend/dto/RecognitionRequest.java | 6 +- .../backend/dto/UserProfileResponse.java | 56 +++++++++---------- .../backend/dto/UserProfileUpdateRequest.java | 52 ----------------- 5 files changed, 38 insertions(+), 104 deletions(-) delete mode 100644 src/main/java/com/joycrew/backend/dto/UserProfileUpdateRequest.java diff --git a/src/main/java/com/joycrew/backend/dto/PasswordChangeRequest.java b/src/main/java/com/joycrew/backend/dto/PasswordChangeRequest.java index c47ea1e..07e73d6 100644 --- a/src/main/java/com/joycrew/backend/dto/PasswordChangeRequest.java +++ b/src/main/java/com/joycrew/backend/dto/PasswordChangeRequest.java @@ -5,11 +5,9 @@ import lombok.Getter; import lombok.Setter; -@Getter -@Setter -public class PasswordChangeRequest { - @NotBlank - @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&])[A-Za-z\\d@$!%*#?&]{8,20}$", - message = "비밀번호는 영문, 숫자, 특수문자를 포함하여 8자 이상 20자 이하이어야 합니다.") - private String newPassword; -} +public record PasswordChangeRequest( + @NotBlank(message = "새로운 비밀번호는 필수입니다.") + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&])[A-Za-z\\d@$!%*#?&]{8,20}$", + message = "비밀번호는 영문, 숫자, 특수문자를 포함하여 8자 이상 20자 이하이어야 합니다.") + String newPassword +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/PointBalanceResponse.java b/src/main/java/com/joycrew/backend/dto/PointBalanceResponse.java index 2a0c9ad..66c6918 100644 --- a/src/main/java/com/joycrew/backend/dto/PointBalanceResponse.java +++ b/src/main/java/com/joycrew/backend/dto/PointBalanceResponse.java @@ -4,14 +4,8 @@ import lombok.AllArgsConstructor; import lombok.Getter; -@Getter -@AllArgsConstructor @Schema(description = "지갑 잔액 응답 DTO") -public class PointBalanceResponse { - - @Schema(description = "현재 잔액", example = "12000") - private Integer totalBalance; - - @Schema(description = "선물 가능한 포인트", example = "500") - private Integer giftableBalance; -} \ No newline at end of file +public record PointBalanceResponse( + @Schema(description = "현재 잔액") Integer totalBalance, + @Schema(description = "선물 가능한 포인트") Integer giftableBalance +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/RecognitionRequest.java b/src/main/java/com/joycrew/backend/dto/RecognitionRequest.java index a21a057..ac5ad9e 100644 --- a/src/main/java/com/joycrew/backend/dto/RecognitionRequest.java +++ b/src/main/java/com/joycrew/backend/dto/RecognitionRequest.java @@ -5,7 +5,7 @@ import jakarta.validation.constraints.Size; public record RecognitionRequest( - @NotNull Long receiverId, - @Min(1) int points, - @Size(max = 255) String message + @NotNull(message = "받는 사람 ID는 필수입니다.") Long receiverId, + @NotNull(message = "포인트는 필수입니다.") @Min(value = 1, message = "포인트는 1 이상이어야 합니다.") int points, + @Size(max = 255, message = "메시지는 255자를 초과할 수 없습니다.") String message ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java b/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java index 785e540..1099a0d 100644 --- a/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java +++ b/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java @@ -1,38 +1,32 @@ package com.joycrew.backend.dto; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; import com.joycrew.backend.entity.enums.UserRole; import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -@Getter -@AllArgsConstructor -@Builder @Schema(description = "사용자 프로필 응답 DTO") -public class UserProfileResponse { - - @Schema(description = "사용자 고유 ID", example = "1") - private Long employeeId; - - @Schema(description = "사용자 이름", example = "홍길동") - private String name; - - @Schema(description = "이메일 주소", example = "user@example.com") - private String email; - - @Schema(description = "현재 총 포인트 잔액", example = "1200") - private Integer totalBalance; - - @Schema(description = "현재 선물 가능한 포인트 잔액", example = "50") - private Integer giftableBalance; - - @Schema(description = "사용자 역할", example = "EMPLOYEE", allowableValues = {"EMPLOYEE", "MANAGER", "HR_ADMIN", "SUPER_ADMIN"}) - private UserRole role; - - @Schema(description = "소속 부서", example = "개발팀", nullable = true) - private String department; - - @Schema(description = "직책", example = "대리", nullable = true) - private String position; +public record UserProfileResponse( + @Schema(description = "사용자 고유 ID") Long employeeId, + @Schema(description = "사용자 이름") String name, + @Schema(description = "이메일 주소") String email, + @Schema(description = "현재 총 포인트 잔액") Integer totalBalance, + @Schema(description = "현재 선물 가능한 포인트 잔액") Integer giftableBalance, + @Schema(description = "사용자 역할") UserRole role, + @Schema(description = "소속 부서") String department, + @Schema(description = "직책") String position +) { + public static UserProfileResponse from(Employee employee, Wallet wallet) { + String departmentName = employee.getDepartment() != null ? employee.getDepartment().getName() : null; + return new UserProfileResponse( + employee.getEmployeeId(), + employee.getEmployeeName(), + employee.getEmail(), + wallet.getBalance(), + wallet.getGiftablePoint(), + employee.getRole(), + departmentName, + employee.getPosition() + ); + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/UserProfileUpdateRequest.java b/src/main/java/com/joycrew/backend/dto/UserProfileUpdateRequest.java deleted file mode 100644 index 74c496c..0000000 --- a/src/main/java/com/joycrew/backend/dto/UserProfileUpdateRequest.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.joycrew.backend.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; -import lombok.Getter; -import lombok.Setter; -import org.hibernate.validator.constraints.URL; - -@Getter -@Setter -@Schema(description = "사용자 프로필 수정 요청 DTO") -public class UserProfileUpdateRequest { - - @Schema(description = "새로운 사용자 이름 (선호하는 이름)", example = "김조이", nullable = true) - @Size(min = 2, max = 20, message = "이름은 2자 이상 20자 이하로 입력해주세요.") - private String name; - - @Schema(description = "새로운 비밀번호 (영문, 숫자, 특수문자 포함 8~20자)", example = "newPassword123!", nullable = true) - @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&])[A-Za-z\\d@$!%*#?&]{8,20}$", - message = "비밀번호는 영문, 숫자, 특수문자를 포함하여 8자 이상 20자 이하이어야 합니다.") - private String password; - - @Schema(description = "프로필 사진 이미지 URL", example = "https://example.com/profile.jpg", nullable = true) - @URL(message = "유효한 URL 형식이 아닙니다.") - private String profileImageUrl; - - @Schema(description = "개인 이메일 주소", example = "joy@personal.com", nullable = true) - @Email(message = "유효한 이메일 형식이 아닙니다.") - private String personalEmail; - - @Schema(description = "휴대폰 번호", example = "010-1234-5678", nullable = true) - @Pattern(regexp = "^\\d{2,3}-\\d{3,4}-\\d{4}$", message = "유효한 휴대폰 번호 형식이 아닙니다. (예: 010-1234-5678)") - private String phoneNumber; - - @Schema(description = "배송 주소 (리워드 배송 시 필요)", example = "서울시 강남구 테헤란로 123", nullable = true) - @Size(max = 255, message = "주소는 255자를 초과할 수 없습니다.") - private String shippingAddress; - - @Schema(description = "이메일 알림 수신 여부", example = "true", nullable = true) - private Boolean emailNotificationEnabled; - - @Schema(description = "앱 내 알림 수신 여부", example = "true", nullable = true) - private Boolean appNotificationEnabled; - - @Schema(description = "선호 언어 설정", example = "ko-KR", nullable = true) - private String language; - - @Schema(description = "시간대 설정", example = "Asia/Seoul", nullable = true) - private String timezone; -} From 5340918cb736014763c0396c4d8e05567696286a Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Sat, 26 Jul 2025 16:04:48 +0900 Subject: [PATCH 026/135] =?UTF-8?q?refactor=20:=20N+1=20=EB=AC=B8=EC=A0=9C?= =?UTF-8?q?=20=EC=84=A0=EC=A0=9C=EC=A0=81=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/joycrew/backend/repository/DepartmentRepository.java | 4 +++- .../com/joycrew/backend/repository/EmployeeRepository.java | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java b/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java index e0bd8d4..14a2fe0 100644 --- a/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java +++ b/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java @@ -4,6 +4,8 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -@Repository +import java.util.List; + public interface DepartmentRepository extends JpaRepository { + List findAllByCompanyId(Long companyId); } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java b/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java index 7d06509..f62f1e3 100644 --- a/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java +++ b/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java @@ -1,10 +1,12 @@ package com.joycrew.backend.repository; import com.joycrew.backend.entity.Employee; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface EmployeeRepository extends JpaRepository { + @EntityGraph(attributePaths = {"company", "department"}) Optional findByEmail(String email); } \ No newline at end of file From 3e2eef89694423e545879482768c1a4b0718ab4f Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Sat, 26 Jul 2025 16:09:29 +0900 Subject: [PATCH 027/135] =?UTF-8?q?refactor=20:=20security=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/JwtAuthenticationFilter.java | 40 +++++++++++++------ .../com/joycrew/backend/security/JwtUtil.java | 5 ++- .../backend/security/UserPrincipal.java | 2 - 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java b/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java index 4ab39a9..6ecd20e 100644 --- a/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java @@ -1,10 +1,14 @@ package com.joycrew.backend.security; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.web.filter.OncePerRequestFilter; import jakarta.servlet.FilterChain; @@ -13,6 +17,7 @@ import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +@Slf4j @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { @@ -27,22 +32,31 @@ protected void doFilterInternal(HttpServletRequest request, String authHeader = request.getHeader("Authorization"); - if (authHeader != null && authHeader.startsWith("Bearer ")) { - String token = authHeader.substring(7); - String email = jwtUtil.getEmailFromToken(token); + if (authHeader == null || !authHeader.startsWith("Bearer ") || SecurityContextHolder.getContext().getAuthentication() != null) { + filterChain.doFilter(request, response); + return; + } - UserDetails userDetails = userDetailsService.loadUserByUsername(email); + String token = authHeader.substring(7); + String email = null; + try { + email = jwtUtil.getEmailFromToken(token); + } catch (ExpiredJwtException e) { + log.warn("JWT token has expired: {}", e.getMessage()); + } catch (JwtException e) { + log.warn("Invalid JWT token: {}", e.getMessage()); + } - if (userDetails != null) { - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken( - userDetails, - null, - userDetails.getAuthorities() - ); + if (email != null) { + UserDetails userDetails = this.userDetailsService.loadUserByUsername(email); - SecurityContextHolder.getContext().setAuthentication(authentication); - } + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); } filterChain.doFilter(request, response); diff --git a/src/main/java/com/joycrew/backend/security/JwtUtil.java b/src/main/java/com/joycrew/backend/security/JwtUtil.java index 10d37ef..6ed88b9 100644 --- a/src/main/java/com/joycrew/backend/security/JwtUtil.java +++ b/src/main/java/com/joycrew/backend/security/JwtUtil.java @@ -16,7 +16,8 @@ public class JwtUtil { @Value("${jwt.secret}") private String secretKey; - private final long EXPIRATION_TIME = 86400000L; + @Value("${jwt.expiration-ms}") + private long expirationTime; private SecretKey getSigningKey() { byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8); @@ -27,7 +28,7 @@ public String generateToken(String email) { return Jwts.builder() .setSubject(email) .setIssuedAt(new Date()) - .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) + .setExpiration(new Date(System.currentTimeMillis() + expirationTime)) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } diff --git a/src/main/java/com/joycrew/backend/security/UserPrincipal.java b/src/main/java/com/joycrew/backend/security/UserPrincipal.java index 838e375..7e66307 100644 --- a/src/main/java/com/joycrew/backend/security/UserPrincipal.java +++ b/src/main/java/com/joycrew/backend/security/UserPrincipal.java @@ -33,13 +33,11 @@ public String getUsername() { return employee.getEmail(); } - // Employee의 상태에 따라 계정 활성화 여부 결정 @Override public boolean isEnabled() { return "ACTIVE".equals(employee.getStatus()); } - // 필요에 따라 아래 메서드들도 Employee의 필드와 연동 가능 @Override public boolean isAccountNonExpired() { return true; From f3a49d8f9cc5e09bf168cf76072bd9315bb54234 Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Sat, 26 Jul 2025 16:11:59 +0900 Subject: [PATCH 028/135] =?UTF-8?q?refactor=20:=20exception=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/GlobalExceptionHandler.java | 51 +++++++++++-------- .../security/EmployeeDetailsService.java | 1 - 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java b/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java index 53556b2..2fe5abc 100644 --- a/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java @@ -1,48 +1,55 @@ package com.joycrew.backend.exception; +import com.joycrew.backend.dto.ErrorResponse; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.web.ErrorResponse; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; -import java.util.Map; +import java.util.stream.Collectors; +@Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(InsufficientPointsException.class) - public ResponseEntity handleInsufficientPoints(InsufficientPointsException ex) { - ErrorResponse response = new ErrorResponse("INSUFFICIENT_POINTS", ex.getMessage()); + public ResponseEntity handleBusinessException(InsufficientPointsException ex) { + log.warn("Business logic violation: {}", ex.getMessage()); + ErrorResponse response = new ErrorResponse("BUSINESS_LOGIC_ERROR", ex.getMessage()); return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); } - @ExceptionHandler(UserNotFoundException.class) - public ResponseEntity handleUserNotFound(UserNotFoundException ex) { - ErrorResponse response = new ErrorResponse("USER_NOT_FOUND", ex.getMessage()); - return new ResponseEntity<>(response, HttpStatus.NOT_FOUND); + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationExceptions(MethodArgumentNotValidException ex) { + String allErrors = ex.getBindingResult().getAllErrors().stream() + .map(error -> error.getDefaultMessage()) + .collect(Collectors.joining(", ")); + log.warn("Validation failed: {}", allErrors); + ErrorResponse response = new ErrorResponse("VALIDATION_FAILED", allErrors); + return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); } - // 인증 실패 (로그인 실패 등) - @ExceptionHandler({BadCredentialsException.class, UserNotFoundException.class}) - public ResponseEntity> handleAuthenticationException(Exception e) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("message", e.getMessage())); + @ExceptionHandler(BadCredentialsException.class) + public ResponseEntity handleAuthenticationException(BadCredentialsException ex) { + log.warn("Authentication failed: {}", ex.getMessage()); + ErrorResponse response = new ErrorResponse("AUTHENTICATION_FAILED", "이메일 또는 비밀번호가 올바르지 않습니다."); + return new ResponseEntity<>(response, HttpStatus.UNAUTHORIZED); } - // DTO 유효성 검사 실패 - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidationExceptions(MethodArgumentNotValidException ex) { - String errorMessage = ex.getBindingResult().getAllErrors().get(0).getDefaultMessage(); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + @ExceptionHandler(UserNotFoundException.class) + public ResponseEntity handleNotFoundException(UserNotFoundException ex) { + log.warn("Resource not found: {}", ex.getMessage()); + ErrorResponse response = new ErrorResponse("RESOURCE_NOT_FOUND", ex.getMessage()); + return new ResponseEntity<>(response, HttpStatus.NOT_FOUND); } - // 기타 서버 내부 오류 @ExceptionHandler(Exception.class) - public ResponseEntity> handleAllUncaughtException(Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("message", "서버 내부 오류가 발생했습니다.")); + public ResponseEntity handleAllUncaughtException(Exception ex) { + log.error("Unhandled internal server error occurred", ex); + ErrorResponse response = new ErrorResponse("INTERNAL_SERVER_ERROR", "서버 내부 오류가 발생했습니다. 관리자에게 문의하세요."); + return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/security/EmployeeDetailsService.java b/src/main/java/com/joycrew/backend/security/EmployeeDetailsService.java index 66ee340..aec4968 100644 --- a/src/main/java/com/joycrew/backend/security/EmployeeDetailsService.java +++ b/src/main/java/com/joycrew/backend/security/EmployeeDetailsService.java @@ -21,7 +21,6 @@ public UserDetails loadUserByUsername(String email) throws UsernameNotFoundExcep Employee employee = employeeRepository.findByEmail(email) .orElseThrow(() -> new UsernameNotFoundException("User not found with email: " + email)); - // Employee 엔티티를 UserPrincipal로 감싸서 반환 return new UserPrincipal(employee); } } \ No newline at end of file From 2f80620c0b163cbdb508b6da05f1300b2b904eb8 Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Sat, 26 Jul 2025 16:28:14 +0900 Subject: [PATCH 029/135] refactor : Tell, Don't Ask --- .../backend/config/SecurityConfig.java | 38 ++++++------- .../controller/RecognitionController.java | 2 +- .../com/joycrew/backend/entity/Employee.java | 16 +++--- .../joycrew/backend/service/AuthService.java | 53 +++++++++---------- .../backend/service/EmployeeService.java | 29 ++-------- .../backend/service/RecognitionService.java | 3 +- 6 files changed, 52 insertions(+), 89 deletions(-) diff --git a/src/main/java/com/joycrew/backend/config/SecurityConfig.java b/src/main/java/com/joycrew/backend/config/SecurityConfig.java index 0516ba1..1ea4f93 100644 --- a/src/main/java/com/joycrew/backend/config/SecurityConfig.java +++ b/src/main/java/com/joycrew/backend/config/SecurityConfig.java @@ -1,7 +1,7 @@ package com.joycrew.backend.config; import com.fasterxml.jackson.databind.ObjectMapper; -import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.dto.ErrorResponse; import com.joycrew.backend.security.JwtAuthenticationFilter; import com.joycrew.backend.security.JwtUtil; import com.joycrew.backend.security.EmployeeDetailsService; @@ -9,7 +9,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpStatus; -import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; @@ -23,9 +22,6 @@ import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; -import org.springframework.security.core.userdetails.UserDetailsService; - -import java.util.Map; import static com.joycrew.backend.entity.enums.UserRole.HR_ADMIN; @@ -36,13 +32,13 @@ public class SecurityConfig { private final JwtUtil jwtUtil; - private final EmployeeRepository employeeRepository; + private final EmployeeDetailsService employeeDetailsService; private final ObjectMapper objectMapper; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http - .cors(Customizer.withDefaults()) + .cors(cors -> {}) .csrf(csrf -> csrf.disable()) .headers(headers -> headers .frameOptions(frameOptions -> frameOptions.disable())) @@ -61,21 +57,24 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .authenticationEntryPoint((request, response, authException) -> { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType("application/json;charset=UTF-8"); - - // JSON 형식으로 에러 메시지 생성 String jsonResponse = objectMapper.writeValueAsString( - Map.of("message", "로그인이 필요합니다.") + new ErrorResponse("UNAUTHENTICATED", "인증이 필요합니다. 로그인을 진행해주세요.") + ); + response.getWriter().write(jsonResponse); + }) + .accessDeniedHandler((request, response, accessDeniedException) -> { + response.setStatus(HttpStatus.FORBIDDEN.value()); + response.setContentType("application/json;charset=UTF-8"); + String jsonResponse = objectMapper.writeValueAsString( + new ErrorResponse("ACCESS_DENIED", "해당 리소스에 접근할 권한이 없습니다.") ); - response.getWriter().write(jsonResponse); }) ) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, userDetailsService()), - UsernamePasswordAuthenticationFilter.class) - .formLogin(form -> form.disable()) - .httpBasic(httpBasic -> httpBasic.disable()); + .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, employeeDetailsService), + UsernamePasswordAuthenticationFilter.class); return http.build(); } @@ -100,14 +99,9 @@ public CorsFilter corsFilter() { } @Bean - public UserDetailsService userDetailsService() { - return new EmployeeDetailsService(employeeRepository); - } - - @Bean - public AuthenticationManager authenticationManager(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) { + public AuthenticationManager authenticationManager(PasswordEncoder passwordEncoder) { DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider(); - authenticationProvider.setUserDetailsService(userDetailsService); + authenticationProvider.setUserDetailsService(employeeDetailsService); authenticationProvider.setPasswordEncoder(passwordEncoder); return new ProviderManager(authenticationProvider); } diff --git a/src/main/java/com/joycrew/backend/controller/RecognitionController.java b/src/main/java/com/joycrew/backend/controller/RecognitionController.java index 50674fa..81d1ad0 100644 --- a/src/main/java/com/joycrew/backend/controller/RecognitionController.java +++ b/src/main/java/com/joycrew/backend/controller/RecognitionController.java @@ -1,5 +1,6 @@ package com.joycrew.backend.controller; +import com.joycrew.backend.dto.RecognitionRequest; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.service.RecognitionService; import io.swagger.v3.oas.annotations.tags.Tag; @@ -20,7 +21,6 @@ public class RecognitionController { @PostMapping public ResponseEntity> sendPoints( - // [L3] @AuthenticationPrincipal을 사용하여 프레임워크 의존성 제거 @AuthenticationPrincipal Employee sender, @Valid @RequestBody RecognitionRequest request ) { diff --git a/src/main/java/com/joycrew/backend/entity/Employee.java b/src/main/java/com/joycrew/backend/entity/Employee.java index 2a33517..44bd6e9 100644 --- a/src/main/java/com/joycrew/backend/entity/Employee.java +++ b/src/main/java/com/joycrew/backend/entity/Employee.java @@ -6,6 +6,7 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.password.PasswordEncoder; import java.time.LocalDateTime; import java.util.ArrayList; @@ -45,8 +46,7 @@ public class Employee implements UserDetails { @Column(nullable = false) private UserRole role; - // 사용자 셀프 서비스 필드 - @Column(length = 2048) // URL은 길 수 있으므로 길이 확장 + @Column(length = 2048) private String profileImageUrl; private String personalEmail; private String phoneNumber; @@ -77,18 +77,10 @@ public class Employee implements UserDetails { @OneToMany(mappedBy = "employee", cascade = CascadeType.ALL, orphanRemoval = true) private List adminAccesses = new ArrayList<>(); - public void changePassword(String newEncodedPassword) { - this.passwordHash = newEncodedPassword; - } - public void updateLastLogin() { this.lastLoginAt = LocalDateTime.now(); } - public void deactivate() { - this.status = "INACTIVE"; - } - public void assignToDepartment(Department newDepartment) { this.department = newDepartment; } @@ -142,4 +134,8 @@ public boolean isCredentialsNonExpired() { public boolean isEnabled() { return "ACTIVE".equals(this.status); } + + public void changePassword(String rawPassword, PasswordEncoder encoder) { + this.passwordHash = encoder.encode(rawPassword); + } } diff --git a/src/main/java/com/joycrew/backend/service/AuthService.java b/src/main/java/com/joycrew/backend/service/AuthService.java index be08034..881694d 100644 --- a/src/main/java/com/joycrew/backend/service/AuthService.java +++ b/src/main/java/com/joycrew/backend/service/AuthService.java @@ -3,8 +3,8 @@ import com.joycrew.backend.dto.LoginRequest; import com.joycrew.backend.dto.LoginResponse; import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.security.JwtUtil; +import com.joycrew.backend.security.UserPrincipal; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; @@ -14,8 +14,8 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -23,51 +23,46 @@ public class AuthService { private static final Logger log = LoggerFactory.getLogger(AuthService.class); - private final EmployeeRepository employeeRepository; - private final PasswordEncoder passwordEncoder; private final JwtUtil jwtUtil; private final AuthenticationManager authenticationManager; + @Transactional public LoginResponse login(LoginRequest request) { - log.info("Attempting login for email: {}", request.getEmail()); + log.info("Attempting login for email: {}", request.email()); try { Authentication authentication = authenticationManager.authenticate( - new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword()) + new UsernamePasswordAuthenticationToken(request.email(), request.password()) ); - Employee employee = employeeRepository.findByEmail(request.getEmail()) - .orElseThrow(() -> new UsernameNotFoundException("사용자 정보를 찾을 수 없습니다.")); + UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal(); + Employee employee = userPrincipal.getEmployee(); + + employee.updateLastLogin(); String accessToken = jwtUtil.generateToken(employee.getEmail()); - return LoginResponse.builder() - .accessToken(accessToken) - .message("로그인 성공") - .userId(employee.getEmployeeId()) - .name(employee.getEmployeeName()) - .email(employee.getEmail()) - .role(employee.getRole()) - .build(); + return new LoginResponse( + accessToken, + "로그인 성공", + employee.getEmployeeId(), + employee.getEmployeeName(), + employee.getEmail(), + employee.getRole() + ); + } catch (UsernameNotFoundException | BadCredentialsException e) { - log.warn("Login failed for email {}: {}", request.getEmail(), e.getMessage()); + log.warn("Login failed for email {}: {}", request.email(), e.getMessage()); throw e; - } catch (Exception e) { - log.error("An unexpected error occurred during login for email {}: {}", request.getEmail(), e.getMessage(), e); - throw new RuntimeException("로그인 중 서버 오류가 발생했습니다."); } } public void logout(HttpServletRequest request) { final String authHeader = request.getHeader("Authorization"); - final String jwt; - - if (authHeader == null || !authHeader.startsWith("Bearer ")) { - log.warn("Logout request received without a valid Bearer token."); - return; + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String jwt = authHeader.substring(7); + log.info("Logout request received. Token blacklisting can be implemented here."); + // TODO: Redis 등을 이용한 토큰 블랙리스트 처리 로직 추가 } - - jwt = authHeader.substring(7); - log.info("Logout request received for a token. In a real application, this token should be blacklisted."); } -} +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/EmployeeService.java b/src/main/java/com/joycrew/backend/service/EmployeeService.java index 253df19..61fcccd 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeService.java @@ -17,46 +17,23 @@ @Transactional public class EmployeeService { private final EmployeeRepository employeeRepository; - private final WalletRepository walletRepository; // Wallet 정보 조회를 위해 주입 + private final WalletRepository walletRepository; private final PasswordEncoder passwordEncoder; - /** - * [리팩토링] - * 사용자 프로필 조회 로직을 서비스 계층으로 이동. - * 컨트롤러는 이 메서드를 호출하여 DTO를 받기만 하면 됨. - * @param userEmail 조회할 사용자의 이메일 - * @return UserProfileResponse DTO - */ @Transactional(readOnly = true) public UserProfileResponse getUserProfile(String userEmail) { Employee employee = employeeRepository.findByEmail(userEmail) .orElseThrow(() -> new UserNotFoundException("인증된 사용자를 찾을 수 없습니다.")); - // Wallet 정보 조회 로직도 서비스 계층에서 처리 Wallet wallet = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) .orElse(new Wallet(employee)); - return UserProfileResponse.builder() - .employeeId(employee.getEmployeeId()) - .name(employee.getEmployeeName()) - .email(employee.getEmail()) - .role(employee.getRole()) - .department(employee.getDepartment() != null ? employee.getDepartment().getName() : null) - .position(employee.getPosition()) - .totalBalance(wallet.getBalance()) - .giftableBalance(wallet.getGiftablePoint()) - .build(); + return UserProfileResponse.from(employee, wallet); } - /** - * [직원 기능] 첫 로그인 시 비밀번호를 변경합니다. - * @param userEmail 현재 로그인된 사용자 이메일 - * @param request 새 비밀번호 정보 DTO - */ public void forcePasswordChange(String userEmail, PasswordChangeRequest request) { Employee employee = employeeRepository.findByEmail(userEmail) .orElseThrow(() -> new UserNotFoundException("인증된 사용자를 찾을 수 없습니다.")); - employee.setPasswordHash(passwordEncoder.encode(request.getNewPassword())); - employeeRepository.save(employee); + employee.changePassword(request.newPassword(), passwordEncoder); } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/RecognitionService.java b/src/main/java/com/joycrew/backend/service/RecognitionService.java index 487e098..92509d0 100644 --- a/src/main/java/com/joycrew/backend/service/RecognitionService.java +++ b/src/main/java/com/joycrew/backend/service/RecognitionService.java @@ -1,5 +1,6 @@ package com.joycrew.backend.service; +import com.joycrew.backend.dto.RecognitionRequest; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.RewardPointTransaction; import com.joycrew.backend.entity.Wallet; @@ -19,7 +20,7 @@ public class RecognitionService { private final EmployeeRepository employeeRepository; private final WalletRepository walletRepository; private final RewardPointTransactionRepository transactionRepository; - private final ApplicationEventPublisher eventPublisher; // [L3] 이벤트 발행을 위한 주입 + private final ApplicationEventPublisher eventPublisher; @Transactional public void sendRecognition(String senderEmail, RecognitionRequest request) { From 3dca736c6aa8bb9b793743a354d2c0f0c5b35eef Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Sat, 26 Jul 2025 16:35:38 +0900 Subject: [PATCH 030/135] =?UTF-8?q?refactor=20:=20controller=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/controller/AuthController.java | 7 ++++--- .../controller/RecognitionController.java | 16 +++++++------- .../backend/controller/UserController.java | 21 ++++++++++++------- .../backend/controller/WalletController.java | 10 +++++---- .../backend/dto/PointBalanceResponse.java | 9 +++++--- .../joycrew/backend/dto/SuccessResponse.java | 13 ++++++++++++ .../RewardPointTransactionRepository.java | 12 +++++++++++ .../backend/service/RecognitionService.java | 1 + .../backend/service/WalletService.java | 2 +- .../config/TestUserDetailsService.java | 3 +-- 10 files changed, 67 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/joycrew/backend/dto/SuccessResponse.java create mode 100644 src/main/java/com/joycrew/backend/repository/RewardPointTransactionRepository.java diff --git a/src/main/java/com/joycrew/backend/controller/AuthController.java b/src/main/java/com/joycrew/backend/controller/AuthController.java index 2841448..30e6398 100644 --- a/src/main/java/com/joycrew/backend/controller/AuthController.java +++ b/src/main/java/com/joycrew/backend/controller/AuthController.java @@ -2,6 +2,7 @@ import com.joycrew.backend.dto.LoginRequest; import com.joycrew.backend.dto.LoginResponse; +import com.joycrew.backend.dto.SuccessResponse; import com.joycrew.backend.service.AuthService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -31,10 +32,10 @@ public ResponseEntity login(@RequestBody @Valid LoginRequest requ } @Operation(summary = "로그아웃") - @ApiResponse(responseCode = "200", description = "로그아웃 성공") @PostMapping("/logout") - public ResponseEntity> logout(HttpServletRequest request) { + public ResponseEntity logout(HttpServletRequest request) { authService.logout(request); - return ResponseEntity.ok(Map.of("message", "로그아웃 되었습니다.")); + // [L3] Map 대신 일관된 SuccessResponse DTO 사용 + return ResponseEntity.ok(new SuccessResponse("로그아웃 되었습니다.")); } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/controller/RecognitionController.java b/src/main/java/com/joycrew/backend/controller/RecognitionController.java index 81d1ad0..5cd3e7a 100644 --- a/src/main/java/com/joycrew/backend/controller/RecognitionController.java +++ b/src/main/java/com/joycrew/backend/controller/RecognitionController.java @@ -1,8 +1,11 @@ package com.joycrew.backend.controller; import com.joycrew.backend.dto.RecognitionRequest; -import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.dto.SuccessResponse; +import com.joycrew.backend.security.UserPrincipal; import com.joycrew.backend.service.RecognitionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -10,8 +13,6 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import java.util.Map; - @Tag(name = "인정/보상", description = "동료 간 포인트 보상 API") @RestController @RequestMapping("/api/recognitions") @@ -19,12 +20,13 @@ public class RecognitionController { private final RecognitionService recognitionService; + @Operation(summary = "동료에게 포인트 선물하기", security = @SecurityRequirement(name = "Authorization")) @PostMapping - public ResponseEntity> sendPoints( - @AuthenticationPrincipal Employee sender, + public ResponseEntity sendPoints( + @AuthenticationPrincipal UserPrincipal principal, @Valid @RequestBody RecognitionRequest request ) { - recognitionService.sendRecognition(sender.getEmail(), request); - return ResponseEntity.ok(Map.of("message", "포인트를 성공적으로 보냈습니다.")); + recognitionService.sendRecognition(principal.getUsername(), request); + return ResponseEntity.ok(new SuccessResponse("포인트를 성공적으로 보냈습니다.")); } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/controller/UserController.java b/src/main/java/com/joycrew/backend/controller/UserController.java index da61e42..d557968 100644 --- a/src/main/java/com/joycrew/backend/controller/UserController.java +++ b/src/main/java/com/joycrew/backend/controller/UserController.java @@ -1,7 +1,9 @@ package com.joycrew.backend.controller; import com.joycrew.backend.dto.PasswordChangeRequest; +import com.joycrew.backend.dto.SuccessResponse; import com.joycrew.backend.dto.UserProfileResponse; +import com.joycrew.backend.security.UserPrincipal; import com.joycrew.backend.service.EmployeeService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; @@ -10,6 +12,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.Map; @@ -23,15 +26,19 @@ public class UserController { @Operation(summary = "사용자 프로필 조회", security = @SecurityRequirement(name = "Authorization")) @GetMapping("/profile") - public ResponseEntity getProfile(Authentication authentication) { - UserProfileResponse response = employeeService.getUserProfile(authentication.getName()); - return ResponseEntity.ok(response); + public ResponseEntity getProfile( + @AuthenticationPrincipal UserPrincipal principal + ) { + return ResponseEntity.ok(employeeService.getUserProfile(principal.getUsername())); } - @Operation(summary = "비밀번호 변경 (첫 로그인 시)", security = @SecurityRequirement(name = "Authorization")) + @Operation(summary = "비밀번호 변경", security = @SecurityRequirement(name = "Authorization")) @PostMapping("/password") - public ResponseEntity> forceChangePassword(Authentication authentication, @Valid @RequestBody PasswordChangeRequest request) { - employeeService.forcePasswordChange(authentication.getName(), request); - return ResponseEntity.ok(Map.of("message", "비밀번호가 성공적으로 변경되었습니다.")); + public ResponseEntity forceChangePassword( + @AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody PasswordChangeRequest request + ) { + employeeService.forcePasswordChange(principal.getUsername(), request); + return ResponseEntity.ok(new SuccessResponse("비밀번호가 성공적으로 변경되었습니다.")); } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/controller/WalletController.java b/src/main/java/com/joycrew/backend/controller/WalletController.java index 6118abe..acdafcd 100644 --- a/src/main/java/com/joycrew/backend/controller/WalletController.java +++ b/src/main/java/com/joycrew/backend/controller/WalletController.java @@ -1,13 +1,14 @@ package com.joycrew.backend.controller; import com.joycrew.backend.dto.PointBalanceResponse; +import com.joycrew.backend.security.UserPrincipal; import com.joycrew.backend.service.WalletService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -22,8 +23,9 @@ public class WalletController { @Operation(summary = "포인트 잔액 조회", security = @SecurityRequirement(name = "Authorization")) @GetMapping("/point") - public ResponseEntity getWalletPoint(Authentication authentication) { - PointBalanceResponse response = walletService.getPointBalance(authentication.getName()); - return ResponseEntity.ok(response); + public ResponseEntity getWalletPoint( + @AuthenticationPrincipal UserPrincipal principal + ) { + return ResponseEntity.ok(walletService.getPointBalance(principal.getUsername())); } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/PointBalanceResponse.java b/src/main/java/com/joycrew/backend/dto/PointBalanceResponse.java index 66c6918..ad08ee6 100644 --- a/src/main/java/com/joycrew/backend/dto/PointBalanceResponse.java +++ b/src/main/java/com/joycrew/backend/dto/PointBalanceResponse.java @@ -1,11 +1,14 @@ package com.joycrew.backend.dto; +import com.joycrew.backend.entity.Wallet; import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Getter; @Schema(description = "지갑 잔액 응답 DTO") public record PointBalanceResponse( @Schema(description = "현재 잔액") Integer totalBalance, @Schema(description = "선물 가능한 포인트") Integer giftableBalance -) {} \ No newline at end of file +) { + public static PointBalanceResponse from(Wallet wallet) { + return new PointBalanceResponse(wallet.getBalance(), wallet.getGiftablePoint()); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/SuccessResponse.java b/src/main/java/com/joycrew/backend/dto/SuccessResponse.java new file mode 100644 index 0000000..57ac18b --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/SuccessResponse.java @@ -0,0 +1,13 @@ +package com.joycrew.backend.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "작업 성공 응답 DTO") +public record SuccessResponse( + @Schema(description = "성공 메시지", example = "작업이 성공적으로 완료되었습니다.") + String message +) { + public static SuccessResponse defaultSuccess() { + return new SuccessResponse("성공적으로 처리되었습니다."); + } +} diff --git a/src/main/java/com/joycrew/backend/repository/RewardPointTransactionRepository.java b/src/main/java/com/joycrew/backend/repository/RewardPointTransactionRepository.java new file mode 100644 index 0000000..587bb38 --- /dev/null +++ b/src/main/java/com/joycrew/backend/repository/RewardPointTransactionRepository.java @@ -0,0 +1,12 @@ +package com.joycrew.backend.repository; + +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.RewardPointTransaction; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface RewardPointTransactionRepository extends JpaRepository { + List findBySenderOrReceiverOrderByTransactionDateDesc(Employee sender, Employee receiver); + List findAllByOrderByTransactionDateDesc(); +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/RecognitionService.java b/src/main/java/com/joycrew/backend/service/RecognitionService.java index 92509d0..b99602c 100644 --- a/src/main/java/com/joycrew/backend/service/RecognitionService.java +++ b/src/main/java/com/joycrew/backend/service/RecognitionService.java @@ -8,6 +8,7 @@ import com.joycrew.backend.event.RecognitionEvent; import com.joycrew.backend.exception.UserNotFoundException; import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.RewardPointTransactionRepository; import com.joycrew.backend.repository.WalletRepository; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; diff --git a/src/main/java/com/joycrew/backend/service/WalletService.java b/src/main/java/com/joycrew/backend/service/WalletService.java index 9715fdb..0123100 100644 --- a/src/main/java/com/joycrew/backend/service/WalletService.java +++ b/src/main/java/com/joycrew/backend/service/WalletService.java @@ -24,6 +24,6 @@ public PointBalanceResponse getPointBalance(String userEmail) { Wallet wallet = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) .orElse(new Wallet(employee)); - return new PointBalanceResponse(wallet.getBalance(), wallet.getGiftablePoint()); + return PointBalanceResponse.from(wallet); } } diff --git a/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java b/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java index c136b53..10e4f55 100644 --- a/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java +++ b/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java @@ -16,14 +16,13 @@ public class TestUserDetailsService implements UserDetailsService { private final Map users = new HashMap<>(); public TestUserDetailsService() { - // Pre-populate with test users users.put("testuser@joycrew.com", Employee.builder() .employeeId(1L) .email("testuser@joycrew.com") .employeeName("테스트유저") .role(UserRole.EMPLOYEE) .status("ACTIVE") - .passwordHash("{noop}password") // Use {noop} for plain text password in tests or your actual encoder + .passwordHash("{noop}password") .build()); users.put("nowallet@joycrew.com", Employee.builder() From c7eb49a6ed94c9abb1388732bbc3dba7c6e0229f Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Sat, 26 Jul 2025 16:53:06 +0900 Subject: [PATCH 031/135] =?UTF-8?q?refactor=20:=20test=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/controller/AuthController.java | 1 - .../repository/DepartmentRepository.java | 2 +- .../backend/service/AdminEmployeeService.java | 54 +++++++++ .../controller/AuthControllerTest.java | 34 ++---- .../controller/UserControllerTest.java | 100 ++++------------ .../controller/WalletControllerTest.java | 112 ++++-------------- .../repository/EmployeeRepositoryTest.java | 65 ++-------- .../repository/WalletRepositoryTest.java | 93 ++------------- .../service/AuthServiceIntegrationTest.java | 61 ++++------ .../backend/service/AuthServiceTest.java | 78 +++--------- .../EmployeeServiceIntegrationTest.java | 44 ++----- .../backend/service/EmployeeServiceTest.java | 100 ++++------------ 12 files changed, 208 insertions(+), 536 deletions(-) create mode 100644 src/main/java/com/joycrew/backend/service/AdminEmployeeService.java diff --git a/src/main/java/com/joycrew/backend/controller/AuthController.java b/src/main/java/com/joycrew/backend/controller/AuthController.java index 30e6398..1579e0a 100644 --- a/src/main/java/com/joycrew/backend/controller/AuthController.java +++ b/src/main/java/com/joycrew/backend/controller/AuthController.java @@ -35,7 +35,6 @@ public ResponseEntity login(@RequestBody @Valid LoginRequest requ @PostMapping("/logout") public ResponseEntity logout(HttpServletRequest request) { authService.logout(request); - // [L3] Map 대신 일관된 SuccessResponse DTO 사용 return ResponseEntity.ok(new SuccessResponse("로그아웃 되었습니다.")); } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java b/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java index 14a2fe0..ff3ba38 100644 --- a/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java +++ b/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java @@ -7,5 +7,5 @@ import java.util.List; public interface DepartmentRepository extends JpaRepository { - List findAllByCompanyId(Long companyId); + List findAllByCompanyCompanyId(Long companyId); } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java b/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java new file mode 100644 index 0000000..481044c --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java @@ -0,0 +1,54 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.AdminEmployeeUpdateRequest; +import com.joycrew.backend.dto.EmployeeRegistrationRequest; +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.entity.Department; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.exception.UserNotFoundException; +import com.joycrew.backend.repository.CompanyRepository; +import com.joycrew.backend.repository.DepartmentRepository; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.WalletRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class AdminEmployeeService { + private final EmployeeRepository employeeRepository; + private final CompanyRepository companyRepository; + private final DepartmentRepository departmentRepository; + private final WalletRepository walletRepository; + private final PasswordEncoder passwordEncoder; + + public Employee registerEmployee(EmployeeRegistrationRequest request) { + if (employeeRepository.findByEmail(request.email()).isPresent()) { + throw new IllegalStateException("이미 사용 중인 이메일입니다."); + } + Company company = companyRepository.findById(request.companyId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회사 ID입니다.")); + Department department = null; + if (request.departmentId() != null) { + department = departmentRepository.findById(request.departmentId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 부서 ID입니다.")); + } + Employee newEmployee = Employee.builder() + .employeeName(request.name()) + .email(request.email()) + .passwordHash(passwordEncoder.encode(request.initialPassword())) + .company(company) + .department(department) + .position(request.position()) + .role(request.role()) + .status("ACTIVE") + .build(); + Employee savedEmployee = employeeRepository.save(newEmployee); + walletRepository.save(new Wallet(savedEmployee)); + return savedEmployee; + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java b/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java index 7702ba6..4e5314a 100644 --- a/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.joycrew.backend.dto.LoginRequest; import com.joycrew.backend.dto.LoginResponse; +import com.joycrew.backend.entity.enums.UserRole; import com.joycrew.backend.service.AuthService; import jakarta.servlet.http.HttpServletRequest; import org.junit.jupiter.api.DisplayName; @@ -10,11 +11,11 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; import org.springframework.http.MediaType; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doNothing; @@ -38,14 +39,11 @@ class AuthControllerTest { @Test @DisplayName("POST /api/auth/login - 로그인 성공") void login_Success() throws Exception { - LoginRequest request = new LoginRequest(); - request.setEmail("test@joycrew.com"); - request.setPassword("password123!"); - - LoginResponse successResponse = LoginResponse.builder() - .accessToken("mocked.jwt.token") - .message("로그인 성공") - .build(); + // [L3 Refactoring] Record 타입 생성자 사용 + LoginRequest request = new LoginRequest("test@joycrew.com", "password123!"); + LoginResponse successResponse = new LoginResponse( + "mocked.jwt.token", "로그인 성공", 1L, "테스트유저", "test@joycrew.com", UserRole.EMPLOYEE + ); when(authService.login(any(LoginRequest.class))).thenReturn(successResponse); @@ -60,37 +58,27 @@ void login_Success() throws Exception { @Test @DisplayName("POST /api/auth/login - 로그인 실패 (잘못된 비밀번호)") void login_Failure_WrongPassword() throws Exception { - LoginRequest request = new LoginRequest(); - request.setEmail("test@joycrew.com"); - request.setPassword("wrongpassword"); - + LoginRequest request = new LoginRequest("test@joycrew.com", "wrongpassword"); when(authService.login(any(LoginRequest.class))) .thenThrow(new BadCredentialsException("이메일 또는 비밀번호가 올바르지 않습니다.")); mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.accessToken").isEmpty()) - .andExpect(jsonPath("$.message").value("이메일 또는 비밀번호가 올바르지 않습니다.")); + .andExpect(status().isUnauthorized()); } @Test @DisplayName("POST /api/auth/login - 로그인 실패 (이메일 없음)") void login_Failure_EmailNotFound() throws Exception { - LoginRequest request = new LoginRequest(); - request.setEmail("nonexistent@joycrew.com"); - request.setPassword("anypassword"); - + LoginRequest request = new LoginRequest("nonexistent@joycrew.com", "anypassword"); when(authService.login(any(LoginRequest.class))) .thenThrow(new UsernameNotFoundException("이메일 또는 비밀번호가 올바르지 않습니다.")); mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.accessToken").isEmpty()) - .andExpect(jsonPath("$.message").value("이메일 또는 비밀번호가 올바르지 않습니다.")); + .andExpect(status().isUnauthorized()); } @Test diff --git a/src/test/java/com/joycrew/backend/controller/UserControllerTest.java b/src/test/java/com/joycrew/backend/controller/UserControllerTest.java index 8e9dc38..b9d2b29 100644 --- a/src/test/java/com/joycrew/backend/controller/UserControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/UserControllerTest.java @@ -2,29 +2,21 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.joycrew.backend.dto.PasswordChangeRequest; -import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.dto.UserProfileResponse; import com.joycrew.backend.entity.enums.UserRole; -import com.joycrew.backend.repository.EmployeeRepository; -import com.joycrew.backend.repository.WalletRepository; +import com.joycrew.backend.exception.GlobalExceptionHandler; +import com.joycrew.backend.security.EmployeeDetailsService; +import com.joycrew.backend.security.JwtUtil; import com.joycrew.backend.service.EmployeeService; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; -import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.web.bind.annotation.ControllerAdvice; -import org.springframework.web.bind.annotation.ExceptionHandler; - -import java.util.Map; -import java.util.Optional; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -37,60 +29,31 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(controllers = UserController.class) -@Import(UserControllerTest.TestControllerAdvice.class) +@Import(GlobalExceptionHandler.class) // [L3] 실제 전역 예외 핸들러를 가져와 테스트의 정확성 높임 class UserControllerTest { - @Autowired - private MockMvc mockMvc; - - @Autowired - private ObjectMapper objectMapper; - - @MockBean - private EmployeeRepository employeeRepository; - @MockBean - private WalletRepository walletRepository; - @MockBean - private EmployeeService employeeService; - - @ControllerAdvice - static class TestControllerAdvice { - @ExceptionHandler(RuntimeException.class) - public ResponseEntity> handleRuntimeException(RuntimeException e) { - return ResponseEntity - .status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("message", e.getMessage())); - } - } - - private Employee testEmployee; - private Wallet testWallet; - - @BeforeEach - void setUp() { - testEmployee = Employee.builder() - .employeeId(1L) - .email("testuser@joycrew.com") - .employeeName("테스트유저") - .role(UserRole.EMPLOYEE) - .build(); - - testWallet = Wallet.builder() - .balance(1500) - .giftablePoint(100) - .build(); - } + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @MockBean private EmployeeService employeeService; + @MockBean private JwtUtil jwtUtil; // SecurityConfig 구성에 필요 + @MockBean private EmployeeDetailsService employeeDetailsService; // SecurityConfig 구성에 필요 @Test - @DisplayName("GET /api/user/profile - 프로필 조회 성공 (인증된 사용자)") + @DisplayName("GET /api/user/profile - 프로필 조회 성공") @WithMockUser(username = "testuser@joycrew.com") - void getProfile_Success_AuthenticatedUser() throws Exception { - when(employeeRepository.findByEmail("testuser@joycrew.com")).thenReturn(Optional.of(testEmployee)); - when(walletRepository.findByEmployee_EmployeeId(1L)).thenReturn(Optional.of(testWallet)); + void getProfile_Success() throws Exception { + // Given + UserProfileResponse mockResponse = new UserProfileResponse( + 1L, "테스트유저", "testuser@joycrew.com", 1500, 100, + UserRole.EMPLOYEE, "개발팀", "사원" + ); + when(employeeService.getUserProfile("testuser@joycrew.com")).thenReturn(mockResponse); + // When & Then mockMvc.perform(get("/api/user/profile")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.name").value("테스트유저")); + .andExpect(jsonPath("$.name").value("테스트유저")) + .andExpect(jsonPath("$.totalBalance").value(1500)); } @Test @@ -98,32 +61,15 @@ void getProfile_Success_AuthenticatedUser() throws Exception { @WithMockUser(username = "testuser@joycrew.com") void forceChangePassword_Success() throws Exception { // Given - PasswordChangeRequest request = new PasswordChangeRequest(); - request.setNewPassword("newPassword123!"); + PasswordChangeRequest request = new PasswordChangeRequest("newPassword123!"); doNothing().when(employeeService).forcePasswordChange(eq("testuser@joycrew.com"), any(PasswordChangeRequest.class)); // When & Then mockMvc.perform(post("/api/user/password") - .with(csrf()) // CSRF 토큰 추가 + .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("비밀번호가 성공적으로 변경되었습니다.")); } - - @Test - @DisplayName("POST /api/user/password - 비밀번호 변경 실패 (유효성 검사 실패)") - @WithMockUser(username = "testuser@joycrew.com") - void forceChangePassword_Failure_InvalidPassword() throws Exception { - // Given - PasswordChangeRequest request = new PasswordChangeRequest(); - request.setNewPassword("short"); - - // When & Then - mockMvc.perform(post("/api/user/password") - .with(csrf()) // CSRF 토큰 추가 - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()); - } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/WalletControllerTest.java b/src/test/java/com/joycrew/backend/controller/WalletControllerTest.java index 37882f1..711214b 100644 --- a/src/test/java/com/joycrew/backend/controller/WalletControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/WalletControllerTest.java @@ -1,116 +1,54 @@ package com.joycrew.backend.controller; -import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.entity.Wallet; -import com.joycrew.backend.entity.enums.UserRole; -import com.joycrew.backend.repository.WalletRepository; -import org.junit.jupiter.api.BeforeEach; +import com.joycrew.backend.service.WalletService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import org.springframework.web.context.WebApplicationContext; - -import java.util.Optional; import static org.mockito.Mockito.when; -import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import com.joycrew.backend.dto.PointBalanceResponse; +import com.joycrew.backend.security.EmployeeDetailsService; +import com.joycrew.backend.security.JwtUtil; -@SpringBootTest -@AutoConfigureMockMvc +@WebMvcTest(controllers = WalletController.class) class WalletControllerTest { @Autowired private MockMvc mockMvc; @MockBean - private WalletRepository walletRepository; - - @Autowired - private WebApplicationContext context; - - private Employee testEmployee; - private Wallet testWallet; - private Employee noWalletEmployee; - - @BeforeEach - void setUp() { - mockMvc = MockMvcBuilders - .webAppContextSetup(context) - .apply(springSecurity()) - .build(); - - testEmployee = Employee.builder() - .employeeId(1L) - .email("testuser@joycrew.com") - .employeeName("테스트유저") - .role(UserRole.EMPLOYEE) - .status("ACTIVE") - .passwordHash("{noop}password") - .build(); - - testWallet = Wallet.builder() - .walletId(100L) - .employee(testEmployee) - .balance(1500) - .giftablePoint(100) - .build(); - - noWalletEmployee = Employee.builder() - .employeeId(99L) - .email("nowallet@joycrew.com") - .passwordHash("{noop}password") - .employeeName("지갑없음") - .role(UserRole.EMPLOYEE) - .status("ACTIVE") - .build(); - } + private WalletService walletService; + @MockBean private JwtUtil jwtUtil; // SecurityConfig 구성에 필요 + @MockBean private EmployeeDetailsService employeeDetailsService; // SecurityConfig 구성에 필요 @Test - @DisplayName("GET /api/wallet/point - 포인트 잔액 조회 성공 (인증된 사용자)") - @WithUserDetails(value = "testuser@joycrew.com", userDetailsServiceBeanName = "testUserDetailsService") - void getWalletPoint_Success_WithValidToken() throws Exception { - when(walletRepository.findByEmployee_EmployeeId(testEmployee.getEmployeeId())) - .thenReturn(Optional.of(testWallet)); - - mockMvc.perform(get("/api/wallet/point") - .contentType(MediaType.APPLICATION_JSON)) + @DisplayName("GET /api/wallet/point - 포인트 잔액 조회 성공") + @WithMockUser(username = "testuser@joycrew.com") + void getWalletPoint_Success() throws Exception { + // Given + PointBalanceResponse mockResponse = new PointBalanceResponse(1500, 100); + when(walletService.getPointBalance("testuser@joycrew.com")).thenReturn(mockResponse); + + // When & Then + mockMvc.perform(get("/api/wallet/point")) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.totalBalance").value(testWallet.getBalance())) - .andExpect(jsonPath("$.giftableBalance").value(testWallet.getGiftablePoint())); + .andExpect(jsonPath("$.totalBalance").value(1500)) + .andExpect(jsonPath("$.giftableBalance").value(100)); } @Test - @DisplayName("GET /api/wallet/point - 포인트 잔액 조회 실패 (인증되지 않은 사용자)") + @DisplayName("GET /api/wallet/point - 인증되지 않은 사용자 접근 시 401 반환") void getWalletPoint_Failure_Unauthenticated() throws Exception { - mockMvc.perform(get("/api/wallet/point") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isUnauthorized()) - .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.message").value("로그인이 필요합니다.")); - } - - @Test - @DisplayName("GET /api/wallet/point - 포인트 잔액 조회 성공 (인증은 되었으나 지갑 없음)") - @WithUserDetails(value = "nowallet@joycrew.com", userDetailsServiceBeanName = "testUserDetailsService") - void getWalletPoint_Success_WalletNotFound() throws Exception { - when(walletRepository.findByEmployee_EmployeeId(noWalletEmployee.getEmployeeId())) - .thenReturn(Optional.empty()); - - mockMvc.perform(get("/api/wallet/point") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.totalBalance").value(0)) - .andExpect(jsonPath("$.giftableBalance").value(0)); + // When & Then + mockMvc.perform(get("/api/wallet/point")) + .andExpect(status().isUnauthorized()); } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java b/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java index d5ce1ec..5ef9fa7 100644 --- a/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java +++ b/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java @@ -11,10 +11,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; -import java.time.LocalDateTime; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -27,54 +24,41 @@ class EmployeeRepositoryTest { @Autowired private EmployeeRepository employeeRepository; - private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); - private Company testCompany; private Department testDepartment; - private Employee testEmployee; @BeforeEach void setUp() { - testCompany = Company.builder() - .companyName("테스트회사") - .status("ACTIVE") - .startAt(LocalDateTime.now()) - .totalCompanyBalance(0.0) - .build(); + // 1. 의존하는 엔티티 먼저 생성 및 영속화 + testCompany = Company.builder().companyName("테스트회사").build(); entityManager.persist(testCompany); - testDepartment = Department.builder() - .name("테스트부서") - .company(testCompany) - .build(); + testDepartment = Department.builder().name("테스트부서").company(testCompany).build(); entityManager.persist(testDepartment); - testEmployee = Employee.builder() + // 2. 테스트 대상 주체인 Employee 생성 + Employee testEmployee = Employee.builder() .company(testCompany) .department(testDepartment) .email("test@joycrew.com") - .passwordHash(passwordEncoder.encode("password123")) + .passwordHash("encodedPassword") .employeeName("김테스트") .position("사원") - .status("ACTIVE") .role(UserRole.EMPLOYEE) - .lastLoginAt(null) .build(); entityManager.persist(testEmployee); - Wallet testWallet = Wallet.builder() - .employee(testEmployee) - .balance(1000) - .giftablePoint(100) - .build(); + // 3. [수정] Wallet은 이제 new 키워드와 생성자를 통해서만 생성 + Wallet testWallet = new Wallet(testEmployee); entityManager.persist(testWallet); + // 4. DB에 변경사항을 반영하고, 영속성 컨텍스트를 비워 순수한 DB 조회 테스트 환경 구축 entityManager.flush(); entityManager.clear(); } @Test - @DisplayName("이메일로 직원 조회 성공") + @DisplayName("이메일로 직원 조회 성공 (@EntityGraph 적용 확인)") void findByEmail_Success() { // When Optional foundEmployee = employeeRepository.findByEmail("test@joycrew.com"); @@ -82,7 +66,7 @@ void findByEmail_Success() { // Then assertThat(foundEmployee).isPresent(); assertThat(foundEmployee.get().getEmail()).isEqualTo("test@joycrew.com"); - assertThat(foundEmployee.get().getEmployeeName()).isEqualTo("김테스트"); + // @EntityGraph 덕분에 추가 쿼리 없이 연관 엔티티 접근 가능 assertThat(foundEmployee.get().getCompany().getCompanyName()).isEqualTo("테스트회사"); assertThat(foundEmployee.get().getDepartment().getName()).isEqualTo("테스트부서"); } @@ -96,29 +80,4 @@ void findByEmail_NotFound() { // Then assertThat(foundEmployee).isEmpty(); } - - @Test - @DisplayName("Employee 저장 및 조회 성공") - void saveAndFindEmployee() { - // Given - Employee newEmployee = Employee.builder() - .company(testCompany) - .department(testDepartment) - .email("new@joycrew.com") - .passwordHash(passwordEncoder.encode("newpass")) - .employeeName("새로운직원") - .position("대리") - .status("ACTIVE") - .role(UserRole.EMPLOYEE) - .build(); - - // When - Employee savedEmployee = employeeRepository.save(newEmployee); - Optional found = employeeRepository.findById(savedEmployee.getEmployeeId()); - - // Then - assertThat(found).isPresent(); - assertThat(found.get().getEmail()).isEqualTo("new@joycrew.com"); - assertThat(found.get().getEmployeeName()).isEqualTo("새로운직원"); - } -} \ No newline at end of file +} diff --git a/src/test/java/com/joycrew/backend/repository/WalletRepositoryTest.java b/src/test/java/com/joycrew/backend/repository/WalletRepositoryTest.java index a014e4d..7209195 100644 --- a/src/test/java/com/joycrew/backend/repository/WalletRepositoryTest.java +++ b/src/test/java/com/joycrew/backend/repository/WalletRepositoryTest.java @@ -1,7 +1,6 @@ package com.joycrew.backend.repository; import com.joycrew.backend.entity.Company; -import com.joycrew.backend.entity.Department; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.Wallet; import com.joycrew.backend.entity.enums.UserRole; @@ -11,10 +10,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; -import java.time.LocalDateTime; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -25,68 +21,42 @@ class WalletRepositoryTest { @Autowired private TestEntityManager entityManager; @Autowired - private EmployeeRepository employeeRepository; - @Autowired private WalletRepository walletRepository; - private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); - - private Company testCompany; - private Department testDepartment; private Employee testEmployeeWithWallet; private Employee testEmployeeWithoutWallet; - private Wallet testWallet; @BeforeEach void setUp() { - testCompany = Company.builder() - .companyName("테스트회사") - .status("ACTIVE") - .startAt(LocalDateTime.now()) - .totalCompanyBalance(0.0) - .build(); + Company testCompany = Company.builder().companyName("테스트회사").build(); entityManager.persist(testCompany); - testDepartment = Department.builder() - .name("테스트부서") - .company(testCompany) - .build(); - entityManager.persist(testDepartment); - testEmployeeWithWallet = Employee.builder() .company(testCompany) - .department(testDepartment) .email("walletuser@joycrew.com") - .passwordHash(passwordEncoder.encode("pass123")) + .passwordHash("pass123") .employeeName("지갑유저") - .position("선임") - .status("ACTIVE") .role(UserRole.EMPLOYEE) .build(); entityManager.persist(testEmployeeWithWallet); testEmployeeWithoutWallet = Employee.builder() .company(testCompany) - .department(testDepartment) .email("nowallet@joycrew.com") - .passwordHash(passwordEncoder.encode("pass123")) + .passwordHash("pass123") .employeeName("지갑없는유저") - .position("주니어") - .status("ACTIVE") .role(UserRole.EMPLOYEE) .build(); entityManager.persist(testEmployeeWithoutWallet); - - testWallet = Wallet.builder() - .employee(testEmployeeWithWallet) - .balance(5000) - .giftablePoint(500) - .build(); + // [수정] Wallet은 이제 new 키워드와 생성자를 통해서만 생성 + Wallet testWallet = new Wallet(testEmployeeWithWallet); + // [수정] 도메인 메서드를 사용하여 상태 변경 (Setter 대신) + testWallet.addPoints(5000); entityManager.persist(testWallet); - testEmployeeWithWallet.setWallet(testWallet); - entityManager.merge(testEmployeeWithWallet); + // [수정] employee.setWallet()은 불가능하며, 불필요하므로 제거. + // Wallet이 Employee의 참조를 가지고 있으므로 관계는 이미 설정됨. entityManager.flush(); entityManager.clear(); @@ -102,7 +72,6 @@ void findByEmployee_EmployeeId_Success() { assertThat(foundWallet).isPresent(); assertThat(foundWallet.get().getEmployee().getEmployeeId()).isEqualTo(testEmployeeWithWallet.getEmployeeId()); assertThat(foundWallet.get().getBalance()).isEqualTo(5000); - assertThat(foundWallet.get().getGiftablePoint()).isEqualTo(500); } @Test @@ -114,46 +83,4 @@ void findByEmployee_EmployeeId_NotFound() { // Then assertThat(foundWallet).isEmpty(); } - - @Test - @DisplayName("Wallet 저장 및 조회 성공") - void saveAndFindWallet() { - // Given - Employee anotherEmployee = Employee.builder() - .company(testCompany) - .department(testDepartment) - .email("another@joycrew.com") - .passwordHash(passwordEncoder.encode("pass456")) - .employeeName("다른직원") - .position("팀장") - .status("ACTIVE") - .role(UserRole.MANAGER) - .build(); - entityManager.persist(anotherEmployee); - - Wallet newWallet = Wallet.builder() - .employee(anotherEmployee) - .balance(2000) - .giftablePoint(200) - .build(); - - // When - Wallet savedWallet = walletRepository.save(newWallet); - Optional found = walletRepository.findById(savedWallet.getWalletId()); - - // Then - assertThat(found).isPresent(); - assertThat(found.get().getBalance()).isEqualTo(2000); - assertThat(found.get().getEmployee().getEmployeeId()).isEqualTo(anotherEmployee.getEmployeeId()); - - anotherEmployee.setWallet(savedWallet); - entityManager.merge(anotherEmployee); - entityManager.flush(); - entityManager.clear(); - - Optional foundEmployee = employeeRepository.findById(anotherEmployee.getEmployeeId()); - assertThat(foundEmployee).isPresent(); - assertThat(foundEmployee.get().getWallet()).isNotNull(); - assertThat(foundEmployee.get().getWallet().getWalletId()).isEqualTo(savedWallet.getWalletId()); - } -} \ No newline at end of file +} diff --git a/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java index 9922b3b..6d7bafa 100644 --- a/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java +++ b/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java @@ -43,53 +43,46 @@ class AuthServiceIntegrationTest { private String testPassword = "integrationPass123!"; private String testName = "통합테스트유저"; private Company defaultCompany; + @Autowired + private AdminEmployeeService adminEmployeeService; @BeforeEach void setUp() { - defaultCompany = Company.builder() - .companyName("테스트컴퍼니") - .status("ACTIVE") - .startAt(LocalDateTime.now()) - .totalCompanyBalance(0.0) - .build(); - defaultCompany = companyRepository.save(defaultCompany); - + defaultCompany = companyRepository.save(Company.builder().companyName("테스트컴퍼니").build()); employeeRepository.findByEmail(testEmail).ifPresent(employeeRepository::delete); - // --- DTO를 사용하도록 수정된 부분 --- - EmployeeRegistrationRequest request = new EmployeeRegistrationRequest(); - request.setEmail(testEmail); - request.setInitialPassword(testPassword); - request.setName(testName); - request.setCompanyId(defaultCompany.getCompanyId()); - request.setPosition("사원"); // DTO에 필요한 기본값 설정 - request.setRole(UserRole.EMPLOYEE); // DTO에 필요한 기본값 설정 - - employeeService.registerEmployee(request); - // --- 수정 끝 --- + // [L3 Refactoring] Record 타입 DTO 생성자 사용 + EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( + testName, + testEmail, + testPassword, + defaultCompany.getCompanyId(), + null, // departmentId + "사원", + UserRole.EMPLOYEE + ); + adminEmployeeService.registerEmployee(request); } @Test @DisplayName("통합 테스트: 로그인 성공 시 JWT 토큰과 사용자 정보 반환") void login_Integration_Success() { // Given - LoginRequest request = new LoginRequest(); - request.setEmail(testEmail); - request.setPassword(testPassword); + LoginRequest request = new LoginRequest(testEmail, testPassword); // When LoginResponse response = authService.login(request); // Then assertThat(response).isNotNull(); - assertThat(response.getAccessToken()).isNotBlank(); - assertThat(response.getMessage()).isEqualTo("로그인 성공"); - assertThat(response.getEmail()).isEqualTo(testEmail); - assertThat(response.getUserId()).isEqualTo(employeeRepository.findByEmail(testEmail).get().getEmployeeId()); - assertThat(response.getName()).isEqualTo(testName); - assertThat(response.getRole()).isEqualTo(UserRole.EMPLOYEE); - - String extractedEmail = jwtUtil.getEmailFromToken(response.getAccessToken()); + assertThat(response.accessToken()).isNotBlank(); + assertThat(response.message()).isEqualTo("로그인 성공"); + assertThat(response.email()).isEqualTo(testEmail); + assertThat(response.userId()).isEqualTo(employeeRepository.findByEmail(testEmail).get().getEmployeeId()); + assertThat(response.name()).isEqualTo(testName); + assertThat(response.role()).isEqualTo(UserRole.EMPLOYEE); + + String extractedEmail = jwtUtil.getEmailFromToken(response.accessToken()); assertThat(extractedEmail).isEqualTo(testEmail); } @@ -97,9 +90,7 @@ void login_Integration_Success() { @DisplayName("통합 테스트: 로그인 실패 - 존재하지 않는 이메일") void login_Integration_Failure_EmailNotFound() { // Given - LoginRequest request = new LoginRequest(); - request.setEmail("nonexistent@joycrew.com"); - request.setPassword("anypassword"); + LoginRequest request = new LoginRequest("nonexistent@joycrew.com", "anypassword"); // When & Then assertThatThrownBy(() -> authService.login(request)) @@ -110,9 +101,7 @@ void login_Integration_Failure_EmailNotFound() { @DisplayName("통합 테스트: 로그인 실패 - 비밀번호 불일치") void login_Integration_Failure_WrongPassword() { // Given - LoginRequest request = new LoginRequest(); - request.setEmail(testEmail); - request.setPassword("wrongpassword"); + LoginRequest request = new LoginRequest(testEmail, "wrongpassword"); // When & Then assertThatThrownBy(() -> authService.login(request)) diff --git a/src/test/java/com/joycrew/backend/service/AuthServiceTest.java b/src/test/java/com/joycrew/backend/service/AuthServiceTest.java index a78e633..4d3e73a 100644 --- a/src/test/java/com/joycrew/backend/service/AuthServiceTest.java +++ b/src/test/java/com/joycrew/backend/service/AuthServiceTest.java @@ -4,8 +4,8 @@ import com.joycrew.backend.dto.LoginResponse; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.enums.UserRole; -import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.security.JwtUtil; +import com.joycrew.backend.security.UserPrincipal; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -13,32 +13,19 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.quality.Strictness; -import org.mockito.junit.jupiter.MockitoSettings; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.security.core.Authentication; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; - -import java.util.Optional; +import org.springframework.security.core.Authentication; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) -@MockitoSettings(strictness = Strictness.LENIENT) class AuthServiceTest { - @Mock - private EmployeeRepository employeeRepository; - @Mock - private PasswordEncoder passwordEncoder; @Mock private JwtUtil jwtUtil; @Mock @@ -49,34 +36,33 @@ class AuthServiceTest { private Employee testEmployee; private LoginRequest testLoginRequest; - private String encodedPassword; private String testToken = "mocked.jwt.token"; @BeforeEach void setUp() { - encodedPassword = new BCryptPasswordEncoder().encode("password123"); - testEmployee = Employee.builder() .employeeId(1L) .email("test@joycrew.com") - .passwordHash(encodedPassword) + .passwordHash("encodedPassword") .employeeName("테스트유저") .role(UserRole.EMPLOYEE) .status("ACTIVE") .build(); - testLoginRequest = new LoginRequest(); - testLoginRequest.setEmail("test@joycrew.com"); - testLoginRequest.setPassword("password123"); + // [수정] Record 타입 생성자 사용 + testLoginRequest = new LoginRequest("test@joycrew.com", "password123"); } @Test @DisplayName("로그인 성공 시 JWT 토큰과 사용자 정보 반환") void login_Success() { // Given + // [수정] 인증 성공 시 UserPrincipal을 포함한 Authentication 객체를 반환하도록 Mocking + UserPrincipal principal = new UserPrincipal(testEmployee); + Authentication successfulAuth = new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities()); when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) - .thenReturn(mock(Authentication.class)); - when(employeeRepository.findByEmail(testLoginRequest.getEmail())).thenReturn(Optional.of(testEmployee)); + .thenReturn(successfulAuth); + when(jwtUtil.generateToken(anyString())).thenReturn(testToken); // When @@ -84,53 +70,25 @@ void login_Success() { // Then assertThat(response).isNotNull(); - assertThat(response.getAccessToken()).isEqualTo(testToken); - assertThat(response.getMessage()).isEqualTo("로그인 성공"); - assertThat(response.getUserId()).isEqualTo(testEmployee.getEmployeeId()); - assertThat(response.getEmail()).isEqualTo(testEmployee.getEmail()); - assertThat(response.getRole()).isEqualTo(testEmployee.getRole()); + assertThat(response.accessToken()).isEqualTo(testToken); + assertThat(response.message()).isEqualTo("로그인 성공"); + assertThat(response.userId()).isEqualTo(testEmployee.getEmployeeId()); + assertThat(response.email()).isEqualTo(testEmployee.getEmail()); - // 메서드 호출 검증 + // [수정] EmployeeRepository 호출이 없는 것을 검증 (성능 최적화 검증) verify(authenticationManager, times(1)).authenticate(any(UsernamePasswordAuthenticationToken.class)); - verify(employeeRepository, times(1)).findByEmail(testLoginRequest.getEmail()); verify(jwtUtil, times(1)).generateToken(testEmployee.getEmail()); } @Test - @DisplayName("로그인 실패 - 이메일 없음 (UsernameNotFoundException)") - void login_Failure_EmailNotFound() { - // Given - when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) - .thenThrow(new UsernameNotFoundException("이메일 또는 비밀번호가 올바르지 않습니다.")); - - // When & Then - assertThatThrownBy(() -> authService.login(testLoginRequest)) - .isInstanceOf(UsernameNotFoundException.class) - .hasMessageContaining("이메일 또는 비밀번호가 올바르지 않습니다."); - - // 메서드 호출 검증 - verify(authenticationManager, times(1)).authenticate(any(UsernamePasswordAuthenticationToken.class)); - verify(employeeRepository, never()).findByEmail(anyString()); - verify(passwordEncoder, never()).matches(anyString(), anyString()); - verify(jwtUtil, never()).generateToken(anyString()); - } - - @Test - @DisplayName("로그인 실패 - 비밀번호 불일치 (BadCredentialsException)") + @DisplayName("로그인 실패 - 자격 증명 오류 (BadCredentialsException)") void login_Failure_WrongPassword() { // Given when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) - .thenThrow(new BadCredentialsException("이메일 또는 비밀번호가 올바르지 않습니다.")); + .thenThrow(new BadCredentialsException("Bad credentials")); // When & Then assertThatThrownBy(() -> authService.login(testLoginRequest)) - .isInstanceOf(BadCredentialsException.class) - .hasMessageContaining("이메일 또는 비밀번호가 올바르지 않습니다."); - - // 메서드 호출 검증 - verify(authenticationManager, times(1)).authenticate(any(UsernamePasswordAuthenticationToken.class)); - verify(employeeRepository, never()).findByEmail(anyString()); - verify(passwordEncoder, never()).matches(anyString(), anyString()); - verify(jwtUtil, never()).generateToken(anyString()); + .isInstanceOf(BadCredentialsException.class); } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java index ef76002..b2d295d 100644 --- a/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java +++ b/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java @@ -41,10 +41,11 @@ class EmployeeServiceIntegrationTest { private Company testCompany; private Department testDepartment; + @Autowired + private AdminEmployeeService adminEmployeeService; @BeforeEach void setUp() { - // 테스트에 필요한 기본 회사와 부서 생성 testCompany = companyRepository.save(Company.builder().companyName("테스트 회사").build()); testDepartment = departmentRepository.save(Department.builder().name("테스트 부서").company(testCompany).build()); } @@ -53,17 +54,13 @@ void setUp() { @DisplayName("[Integration] 신규 직원 등록 성공") void registerEmployee_Success() { // Given - EmployeeRegistrationRequest request = new EmployeeRegistrationRequest(); - request.setEmail("new.employee@joycrew.com"); - request.setName("신규직원"); - request.setInitialPassword("password123!"); - request.setCompanyId(testCompany.getCompanyId()); - request.setDepartmentId(testDepartment.getDepartmentId()); - request.setPosition("사원"); - request.setRole(UserRole.EMPLOYEE); + EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( + "신규직원", "new.employee@joycrew.com", "password123!", + testCompany.getCompanyId(), testDepartment.getDepartmentId(), "사원", UserRole.EMPLOYEE + ); // When - Employee savedEmployee = employeeService.registerEmployee(request); + Employee savedEmployee = adminEmployeeService.registerEmployee(request); // Then assertThat(savedEmployee.getEmployeeId()).isNotNull(); @@ -71,30 +68,6 @@ void registerEmployee_Success() { assertThat(walletRepository.findByEmployee_EmployeeId(savedEmployee.getEmployeeId())).isPresent(); } - @Test - @DisplayName("[Integration] 관리자에 의한 직원 정보 수정 성공") - void updateEmployeeDetailsByAdmin_Success() { - // Given - Employee employee = employeeRepository.save(Employee.builder() - .email("update.target@joycrew.com") - .employeeName("수정대상") - .passwordHash("password") - .company(testCompany) - .build()); - - AdminEmployeeUpdateRequest request = new AdminEmployeeUpdateRequest(); - request.setName("이름수정됨"); - request.setPosition("대리"); - - // When - employeeService.updateEmployeeDetailsByAdmin(employee.getEmployeeId(), request); - - // Then - Employee updatedEmployee = employeeRepository.findById(employee.getEmployeeId()).orElseThrow(); - assertThat(updatedEmployee.getEmployeeName()).isEqualTo("이름수정됨"); - assertThat(updatedEmployee.getPosition()).isEqualTo("대리"); - } - @Test @DisplayName("[Integration] 직원 비밀번호 변경 성공") void forcePasswordChange_Success() { @@ -106,8 +79,7 @@ void forcePasswordChange_Success() { .company(testCompany) .build()); - PasswordChangeRequest request = new PasswordChangeRequest(); - request.setNewPassword("newPassword123!"); + PasswordChangeRequest request = new PasswordChangeRequest("newPassword123!"); // When employeeService.forcePasswordChange(employee.getEmail(), request); diff --git a/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java b/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java index 267fe57..7a452e8 100644 --- a/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java +++ b/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java @@ -1,15 +1,8 @@ package com.joycrew.backend.service; -import com.joycrew.backend.dto.AdminEmployeeUpdateRequest; -import com.joycrew.backend.dto.EmployeeRegistrationRequest; import com.joycrew.backend.dto.PasswordChangeRequest; -import com.joycrew.backend.entity.Company; -import com.joycrew.backend.entity.Department; import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.entity.Wallet; -import com.joycrew.backend.entity.enums.UserRole; -import com.joycrew.backend.repository.CompanyRepository; -import com.joycrew.backend.repository.DepartmentRepository; +import com.joycrew.backend.exception.UserNotFoundException; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.WalletRepository; import org.junit.jupiter.api.DisplayName; @@ -22,9 +15,8 @@ import java.util.Optional; -import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -33,10 +25,6 @@ class EmployeeServiceTest { @Mock private EmployeeRepository employeeRepository; @Mock - private CompanyRepository companyRepository; - @Mock - private DepartmentRepository departmentRepository; - @Mock private WalletRepository walletRepository; @Mock private PasswordEncoder passwordEncoder; @@ -44,88 +32,42 @@ class EmployeeServiceTest { @InjectMocks private EmployeeService employeeService; - @Test - @DisplayName("[Service] 신규 직원 등록 성공") - void registerEmployee_Success() { - // Given - EmployeeRegistrationRequest request = new EmployeeRegistrationRequest(); - request.setEmail("new@joycrew.com"); - request.setName("신규직원"); - request.setInitialPassword("password123!"); - request.setCompanyId(1L); - request.setDepartmentId(10L); - request.setPosition("사원"); - request.setRole(UserRole.EMPLOYEE); - - when(employeeRepository.findByEmail(request.getEmail())).thenReturn(Optional.empty()); - when(companyRepository.findById(1L)).thenReturn(Optional.of(new Company())); - when(departmentRepository.findById(10L)).thenReturn(Optional.of(new Department())); - when(passwordEncoder.encode(anyString())).thenReturn("encodedPassword"); - when(employeeRepository.save(any(Employee.class))).thenAnswer(i -> i.getArgument(0)); - - // When - Employee result = employeeService.registerEmployee(request); - - // Then - assertThat(result.getEmail()).isEqualTo(request.getEmail()); - assertThat(result.getPasswordHash()).isEqualTo("encodedPassword"); - verify(walletRepository, times(1)).save(any(Wallet.class)); - } - - @Test - @DisplayName("[Service] 직원 정보 수정 (관리자) 성공") - void updateEmployeeDetailsByAdmin_Success() { - // Given - Long employeeId = 1L; - AdminEmployeeUpdateRequest request = new AdminEmployeeUpdateRequest(); - request.setName("이름변경"); - request.setPosition("대리"); - - Employee existingEmployee = Employee.builder().employeeId(employeeId).employeeName("기존이름").position("사원").build(); - when(employeeRepository.findById(employeeId)).thenReturn(Optional.of(existingEmployee)); - when(employeeRepository.save(any(Employee.class))).thenAnswer(i -> i.getArgument(0)); - - // When - Employee result = employeeService.updateEmployeeDetailsByAdmin(employeeId, request); - - // Then - assertThat(result.getEmployeeName()).isEqualTo("이름변경"); - assertThat(result.getPosition()).isEqualTo("대리"); - verify(employeeRepository, times(1)).save(existingEmployee); - } + // ... (getUserProfile 테스트는 DTO의 from 메서드를 사용하므로 큰 변경 없음) @Test - @DisplayName("[Service] 비밀번호 변경 (첫 로그인) 성공") + @DisplayName("[Service] 비밀번호 변경 성공 - Employee의 changePassword 메서드 호출 검증") void forcePasswordChange_Success() { // Given String userEmail = "test@joycrew.com"; - PasswordChangeRequest request = new PasswordChangeRequest(); - request.setNewPassword("newPassword123!"); + // [수정] Record 타입 생성자 사용 + PasswordChangeRequest request = new PasswordChangeRequest("newPassword123!"); - Employee existingEmployee = Employee.builder().email(userEmail).passwordHash("oldPassword").build(); - when(employeeRepository.findByEmail(userEmail)).thenReturn(Optional.of(existingEmployee)); - when(passwordEncoder.encode("newPassword123!")).thenReturn("newEncodedPassword"); + // [수정] 실제 Employee 객체 대신 Mock 객체를 사용하여 상호작용(interaction)을 검증 + Employee mockEmployee = mock(Employee.class); + when(employeeRepository.findByEmail(userEmail)).thenReturn(Optional.of(mockEmployee)); // When employeeService.forcePasswordChange(userEmail, request); // Then - verify(employeeRepository, times(1)).save(existingEmployee); - assertThat(existingEmployee.getPasswordHash()).isEqualTo("newEncodedPassword"); + // [수정] 서비스가 passwordEncoder를 직접 호출하는 대신, + // Employee 객체의 changePassword 메서드에 올바른 인자를 넘겨 호출했는지 검증. + // 이것이 바로 'Tell, Don't Ask' 원칙을 따르는 테스트. + verify(mockEmployee, times(1)).changePassword(request.newPassword(), passwordEncoder); + verify(employeeRepository, times(1)).save(mockEmployee); // 변경 후 저장 호출 확인 } @Test - @DisplayName("[Service] 직원 등록 실패 - 이메일 중복") - void registerEmployee_Failure_EmailDuplicate() { + @DisplayName("[Service] 비밀번호 변경 실패 - 사용자를 찾을 수 없음") + void forcePasswordChange_Failure_UserNotFound() { // Given - EmployeeRegistrationRequest request = new EmployeeRegistrationRequest(); - request.setEmail("duplicate@joycrew.com"); + String userEmail = "notfound@joycrew.com"; + PasswordChangeRequest request = new PasswordChangeRequest("newPassword123!"); - when(employeeRepository.findByEmail("duplicate@joycrew.com")).thenReturn(Optional.of(new Employee())); + when(employeeRepository.findByEmail(anyString())).thenReturn(Optional.empty()); // When & Then - assertThatThrownBy(() -> employeeService.registerEmployee(request)) - .isInstanceOf(IllegalStateException.class) - .hasMessage("이미 사용 중인 이메일입니다."); + assertThatThrownBy(() -> employeeService.forcePasswordChange(userEmail, request)) + .isInstanceOf(UserNotFoundException.class); } } From ba0d786e76b6fdf4875f6a890b7270630c3f6488 Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Sat, 26 Jul 2025 16:54:09 +0900 Subject: [PATCH 032/135] =?UTF-8?q?refactor=20:=20ci-cd=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cd.yml | 57 ++++++++++++++++------------------------ .github/workflows/ci.yml | 5 +--- 2 files changed, 24 insertions(+), 38 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 18c7b10..0081155 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -21,34 +21,30 @@ jobs: - name: Build JAR for Production run: | chmod +x gradlew - ./gradlew clean assemble -Pspring.profiles.active=prod + ./gradlew clean build - - name: Locate and Prepare JAR artifact - id: get_jar_path - run: | - JAR_FILE=$(find build/libs -name "*.jar" | head -n 1) - - if [ -z "$JAR_FILE" ]; then - echo "Error: No executable JAR file found in build/libs directory!" - exit 1 - fi - echo "jar_path=$JAR_FILE" >> $GITHUB_OUTPUT + - name: Locate JAR artifact + id: get_jar + run: echo "jar_path=$(find build/libs -name '*.jar')" >> $GITHUB_OUTPUT - - name: Add EC2 Host Key to known_hosts + - name: Create environment file run: | - mkdir -p ~/.ssh - echo "${{ secrets.SSH_HOST_KEYS }}" > ~/.ssh/known_hosts - chmod 600 ~/.ssh/known_hosts + echo "SPRING_PROFILES_ACTIVE=prod" >> .env + echo "DB_HOST=${{ secrets.DB_HOST }}" >> .env + echo "DB_USER=${{ secrets.DB_USER }}" >> .env + echo "DB_PASSWORD=${{ secrets.DB_PASSWORD }}" >> .env + echo "JWT_SECRET_KEY=${{ secrets.JWT_SECRET_KEY }}" >> .env - - name: Transfer JAR to EC2 Server + - name: Transfer files to EC2 Server uses: appleboy/scp-action@master with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_KEY }} - source: ${{ steps.get_jar_path.outputs.jar_path }} - target: ~/app/build/libs/ - strip_components: 2 + source: | + ${{ steps.get_jar.outputs.jar_path }} + .env + target: ~/app/ - name: Deploy and Restart Application on EC2 uses: appleboy/ssh-action@master @@ -56,23 +52,16 @@ jobs: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_KEY }} - script: | set -eux - export JWT_SECRET_KEY="${{ secrets.JWT_SECRET_KEY }}" - - sudo systemctl stop joycrew-backend || true - - DEPLOY_DIR="/home/${{ secrets.SSH_USER }}/app/build/libs" - JAR_NAME=$(basename "${{ steps.get_jar_path.outputs.jar_path }}") - - sudo chmod +x "$DEPLOY_DIR/$JAR_NAME" - - sudo systemctl start joycrew-backend + sudo mkdir -p /etc/joycrew + sudo mv ~/app/.env /etc/joycrew/.env + sudo chown joycrew-user:joycrew-group /etc/joycrew/.env + sudo chmod 600 /etc/joycrew/.env - sudo systemctl status joycrew-backend --no-pager || true + JAR_NAME=$(basename "${{ steps.get_jar.outputs.jar_path }}") + sudo mv ~/app/$JAR_NAME /var/www/joycrew/app.jar - - name: Clean up local SSH key file - if: always() - run: rm -f private_key.pem + sudo systemctl restart joycrew-backend + sudo systemctl status joycrew-backend --no-pager \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89ca2dd..b5883c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,12 +21,9 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew - - name: Build with Gradle + - name: Build and Test with Gradle run: ./gradlew build - - name: Run Tests - run: ./gradlew test - - name: Upload JAR artifact uses: actions/upload-artifact@v4 with: From 4c7028189a451ce3db8e9f8f87b8d56c6373c6a5 Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Sun, 27 Jul 2025 14:07:09 +0900 Subject: [PATCH 033/135] =?UTF-8?q?refactor=20:=20=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=ED=8C=A8=ED=84=B4=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/controller/AuthController.java | 1 - .../backend/controller/UserController.java | 2 -- .../backend/dto/PasswordChangeRequest.java | 2 -- .../repository/DepartmentRepository.java | 1 - .../backend/service/AdminEmployeeService.java | 2 -- .../controller/WalletControllerTest.java | 18 +++++++------ .../repository/EmployeeRepositoryTest.java | 5 ---- .../security/WithMockUserPrincipal.java | 11 ++++++++ ...ckUserPrincipalSecurityContextFactory.java | 27 +++++++++++++++++++ .../service/AuthServiceIntegrationTest.java | 7 +---- .../backend/service/AuthServiceTest.java | 3 --- .../EmployeeServiceIntegrationTest.java | 2 -- .../backend/service/EmployeeServiceTest.java | 12 --------- 13 files changed, 49 insertions(+), 44 deletions(-) create mode 100644 src/test/java/com/joycrew/backend/security/WithMockUserPrincipal.java create mode 100644 src/test/java/com/joycrew/backend/security/WithMockUserPrincipalSecurityContextFactory.java diff --git a/src/main/java/com/joycrew/backend/controller/AuthController.java b/src/main/java/com/joycrew/backend/controller/AuthController.java index 1579e0a..1c113a4 100644 --- a/src/main/java/com/joycrew/backend/controller/AuthController.java +++ b/src/main/java/com/joycrew/backend/controller/AuthController.java @@ -12,7 +12,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.Map; @Tag(name = "인증", description = "로그인 관련 API") @RestController diff --git a/src/main/java/com/joycrew/backend/controller/UserController.java b/src/main/java/com/joycrew/backend/controller/UserController.java index d557968..0c6e034 100644 --- a/src/main/java/com/joycrew/backend/controller/UserController.java +++ b/src/main/java/com/joycrew/backend/controller/UserController.java @@ -11,10 +11,8 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import java.util.Map; @Tag(name = "사용자", description = "사용자 정보 관련 API") @RestController diff --git a/src/main/java/com/joycrew/backend/dto/PasswordChangeRequest.java b/src/main/java/com/joycrew/backend/dto/PasswordChangeRequest.java index 07e73d6..829a00f 100644 --- a/src/main/java/com/joycrew/backend/dto/PasswordChangeRequest.java +++ b/src/main/java/com/joycrew/backend/dto/PasswordChangeRequest.java @@ -2,8 +2,6 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; -import lombok.Getter; -import lombok.Setter; public record PasswordChangeRequest( @NotBlank(message = "새로운 비밀번호는 필수입니다.") diff --git a/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java b/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java index ff3ba38..838e5d6 100644 --- a/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java +++ b/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java @@ -2,7 +2,6 @@ import com.joycrew.backend.entity.Department; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; import java.util.List; diff --git a/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java b/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java index 481044c..57ff182 100644 --- a/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java +++ b/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java @@ -1,12 +1,10 @@ package com.joycrew.backend.service; -import com.joycrew.backend.dto.AdminEmployeeUpdateRequest; import com.joycrew.backend.dto.EmployeeRegistrationRequest; import com.joycrew.backend.entity.Company; import com.joycrew.backend.entity.Department; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.Wallet; -import com.joycrew.backend.exception.UserNotFoundException; import com.joycrew.backend.repository.CompanyRepository; import com.joycrew.backend.repository.DepartmentRepository; import com.joycrew.backend.repository.EmployeeRepository; diff --git a/src/test/java/com/joycrew/backend/controller/WalletControllerTest.java b/src/test/java/com/joycrew/backend/controller/WalletControllerTest.java index 711214b..8b232ef 100644 --- a/src/test/java/com/joycrew/backend/controller/WalletControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/WalletControllerTest.java @@ -1,5 +1,9 @@ package com.joycrew.backend.controller; +import com.joycrew.backend.dto.PointBalanceResponse; +import com.joycrew.backend.security.EmployeeDetailsService; +import com.joycrew.backend.security.JwtUtil; +import com.joycrew.backend.security.WithMockUserPrincipal; import com.joycrew.backend.service.WalletService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -7,15 +11,11 @@ import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; -import com.joycrew.backend.dto.PointBalanceResponse; -import com.joycrew.backend.security.EmployeeDetailsService; -import com.joycrew.backend.security.JwtUtil; @WebMvcTest(controllers = WalletController.class) class WalletControllerTest { @@ -25,12 +25,14 @@ class WalletControllerTest { @MockBean private WalletService walletService; - @MockBean private JwtUtil jwtUtil; // SecurityConfig 구성에 필요 - @MockBean private EmployeeDetailsService employeeDetailsService; // SecurityConfig 구성에 필요 + @MockBean + private JwtUtil jwtUtil; + @MockBean + private EmployeeDetailsService employeeDetailsService; @Test @DisplayName("GET /api/wallet/point - 포인트 잔액 조회 성공") - @WithMockUser(username = "testuser@joycrew.com") + @WithMockUserPrincipal void getWalletPoint_Success() throws Exception { // Given PointBalanceResponse mockResponse = new PointBalanceResponse(1500, 100); @@ -51,4 +53,4 @@ void getWalletPoint_Failure_Unauthenticated() throws Exception { mockMvc.perform(get("/api/wallet/point")) .andExpect(status().isUnauthorized()); } -} \ No newline at end of file +} diff --git a/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java b/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java index 5ef9fa7..935c87c 100644 --- a/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java +++ b/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java @@ -29,14 +29,12 @@ class EmployeeRepositoryTest { @BeforeEach void setUp() { - // 1. 의존하는 엔티티 먼저 생성 및 영속화 testCompany = Company.builder().companyName("테스트회사").build(); entityManager.persist(testCompany); testDepartment = Department.builder().name("테스트부서").company(testCompany).build(); entityManager.persist(testDepartment); - // 2. 테스트 대상 주체인 Employee 생성 Employee testEmployee = Employee.builder() .company(testCompany) .department(testDepartment) @@ -48,11 +46,9 @@ void setUp() { .build(); entityManager.persist(testEmployee); - // 3. [수정] Wallet은 이제 new 키워드와 생성자를 통해서만 생성 Wallet testWallet = new Wallet(testEmployee); entityManager.persist(testWallet); - // 4. DB에 변경사항을 반영하고, 영속성 컨텍스트를 비워 순수한 DB 조회 테스트 환경 구축 entityManager.flush(); entityManager.clear(); } @@ -66,7 +62,6 @@ void findByEmail_Success() { // Then assertThat(foundEmployee).isPresent(); assertThat(foundEmployee.get().getEmail()).isEqualTo("test@joycrew.com"); - // @EntityGraph 덕분에 추가 쿼리 없이 연관 엔티티 접근 가능 assertThat(foundEmployee.get().getCompany().getCompanyName()).isEqualTo("테스트회사"); assertThat(foundEmployee.get().getDepartment().getName()).isEqualTo("테스트부서"); } diff --git a/src/test/java/com/joycrew/backend/security/WithMockUserPrincipal.java b/src/test/java/com/joycrew/backend/security/WithMockUserPrincipal.java new file mode 100644 index 0000000..8ab4f5f --- /dev/null +++ b/src/test/java/com/joycrew/backend/security/WithMockUserPrincipal.java @@ -0,0 +1,11 @@ +package com.joycrew.backend.security; + +import org.springframework.security.test.context.support.WithSecurityContext; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +@WithSecurityContext(factory = WithMockUserPrincipalSecurityContextFactory.class) +public @interface WithMockUserPrincipal { + String email() default "testuser@joycrew.com"; +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/security/WithMockUserPrincipalSecurityContextFactory.java b/src/test/java/com/joycrew/backend/security/WithMockUserPrincipalSecurityContextFactory.java new file mode 100644 index 0000000..d540943 --- /dev/null +++ b/src/test/java/com/joycrew/backend/security/WithMockUserPrincipalSecurityContextFactory.java @@ -0,0 +1,27 @@ +package com.joycrew.backend.security; + +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.enums.UserRole; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.test.context.support.WithSecurityContextFactory; + +public class WithMockUserPrincipalSecurityContextFactory implements WithSecurityContextFactory { + @Override + public SecurityContext createSecurityContext(WithMockUserPrincipal annotation) { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + Employee mockEmployee = Employee.builder() + .employeeId(1L) + .email(annotation.email()) + .employeeName("테스트유저") + .role(UserRole.EMPLOYEE) + .passwordHash("mockPassword") + .build(); + UserPrincipal principal = new UserPrincipal(mockEmployee); + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + principal, principal.getPassword(), principal.getAuthorities()); + context.setAuthentication(authentication); + return context; + } +} diff --git a/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java index 6d7bafa..8d953c5 100644 --- a/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java +++ b/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java @@ -18,8 +18,6 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; - import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -31,8 +29,6 @@ class AuthServiceIntegrationTest { @Autowired private AuthService authService; @Autowired - private EmployeeService employeeService; - @Autowired private EmployeeRepository employeeRepository; @Autowired private JwtUtil jwtUtil; @@ -51,13 +47,12 @@ void setUp() { defaultCompany = companyRepository.save(Company.builder().companyName("테스트컴퍼니").build()); employeeRepository.findByEmail(testEmail).ifPresent(employeeRepository::delete); - // [L3 Refactoring] Record 타입 DTO 생성자 사용 EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( testName, testEmail, testPassword, defaultCompany.getCompanyId(), - null, // departmentId + null, "사원", UserRole.EMPLOYEE ); diff --git a/src/test/java/com/joycrew/backend/service/AuthServiceTest.java b/src/test/java/com/joycrew/backend/service/AuthServiceTest.java index 4d3e73a..a463379 100644 --- a/src/test/java/com/joycrew/backend/service/AuthServiceTest.java +++ b/src/test/java/com/joycrew/backend/service/AuthServiceTest.java @@ -49,7 +49,6 @@ void setUp() { .status("ACTIVE") .build(); - // [수정] Record 타입 생성자 사용 testLoginRequest = new LoginRequest("test@joycrew.com", "password123"); } @@ -57,7 +56,6 @@ void setUp() { @DisplayName("로그인 성공 시 JWT 토큰과 사용자 정보 반환") void login_Success() { // Given - // [수정] 인증 성공 시 UserPrincipal을 포함한 Authentication 객체를 반환하도록 Mocking UserPrincipal principal = new UserPrincipal(testEmployee); Authentication successfulAuth = new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities()); when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) @@ -75,7 +73,6 @@ void login_Success() { assertThat(response.userId()).isEqualTo(testEmployee.getEmployeeId()); assertThat(response.email()).isEqualTo(testEmployee.getEmail()); - // [수정] EmployeeRepository 호출이 없는 것을 검증 (성능 최적화 검증) verify(authenticationManager, times(1)).authenticate(any(UsernamePasswordAuthenticationToken.class)); verify(jwtUtil, times(1)).generateToken(testEmployee.getEmail()); } diff --git a/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java index b2d295d..699168f 100644 --- a/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java +++ b/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java @@ -1,6 +1,5 @@ package com.joycrew.backend.service; -import com.joycrew.backend.dto.AdminEmployeeUpdateRequest; import com.joycrew.backend.dto.EmployeeRegistrationRequest; import com.joycrew.backend.dto.PasswordChangeRequest; import com.joycrew.backend.entity.Company; @@ -20,7 +19,6 @@ import org.springframework.transaction.annotation.Transactional; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; @SpringBootTest @Transactional diff --git a/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java b/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java index 7a452e8..bc2b1da 100644 --- a/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java +++ b/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java @@ -4,7 +4,6 @@ import com.joycrew.backend.entity.Employee; import com.joycrew.backend.exception.UserNotFoundException; import com.joycrew.backend.repository.EmployeeRepository; -import com.joycrew.backend.repository.WalletRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -25,24 +24,17 @@ class EmployeeServiceTest { @Mock private EmployeeRepository employeeRepository; @Mock - private WalletRepository walletRepository; - @Mock private PasswordEncoder passwordEncoder; @InjectMocks private EmployeeService employeeService; - // ... (getUserProfile 테스트는 DTO의 from 메서드를 사용하므로 큰 변경 없음) - @Test @DisplayName("[Service] 비밀번호 변경 성공 - Employee의 changePassword 메서드 호출 검증") void forcePasswordChange_Success() { // Given String userEmail = "test@joycrew.com"; - // [수정] Record 타입 생성자 사용 PasswordChangeRequest request = new PasswordChangeRequest("newPassword123!"); - - // [수정] 실제 Employee 객체 대신 Mock 객체를 사용하여 상호작용(interaction)을 검증 Employee mockEmployee = mock(Employee.class); when(employeeRepository.findByEmail(userEmail)).thenReturn(Optional.of(mockEmployee)); @@ -50,11 +42,7 @@ void forcePasswordChange_Success() { employeeService.forcePasswordChange(userEmail, request); // Then - // [수정] 서비스가 passwordEncoder를 직접 호출하는 대신, - // Employee 객체의 changePassword 메서드에 올바른 인자를 넘겨 호출했는지 검증. - // 이것이 바로 'Tell, Don't Ask' 원칙을 따르는 테스트. verify(mockEmployee, times(1)).changePassword(request.newPassword(), passwordEncoder); - verify(employeeRepository, times(1)).save(mockEmployee); // 변경 후 저장 호출 확인 } @Test From 198ecf5c515769fe038bb89204e05f7cc1bff62a Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Sun, 27 Jul 2025 14:16:34 +0900 Subject: [PATCH 034/135] =?UTF-8?q?test=20:=20globalexceptionhandler=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cd.yml | 1 - .../backend/exception/GlobalExceptionHandler.java | 5 +++-- .../backend/controller/AuthControllerTest.java | 12 ++++++++---- .../backend/controller/UserControllerTest.java | 14 +++++--------- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 0081155..e7483da 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -11,7 +11,6 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up JDK 17 uses: actions/setup-java@v4 with: diff --git a/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java b/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java index 2fe5abc..3ed1bed 100644 --- a/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java @@ -5,6 +5,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -32,8 +33,8 @@ public ResponseEntity handleValidationExceptions(MethodArgumentNo return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); } - @ExceptionHandler(BadCredentialsException.class) - public ResponseEntity handleAuthenticationException(BadCredentialsException ex) { + @ExceptionHandler({BadCredentialsException.class, UsernameNotFoundException.class}) + public ResponseEntity handleAuthenticationException(Exception ex) { log.warn("Authentication failed: {}", ex.getMessage()); ErrorResponse response = new ErrorResponse("AUTHENTICATION_FAILED", "이메일 또는 비밀번호가 올바르지 않습니다."); return new ResponseEntity<>(response, HttpStatus.UNAUTHORIZED); diff --git a/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java b/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java index 4e5314a..d1d7242 100644 --- a/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java @@ -4,6 +4,7 @@ import com.joycrew.backend.dto.LoginRequest; import com.joycrew.backend.dto.LoginResponse; import com.joycrew.backend.entity.enums.UserRole; +import com.joycrew.backend.exception.GlobalExceptionHandler; import com.joycrew.backend.service.AuthService; import jakarta.servlet.http.HttpServletRequest; import org.junit.jupiter.api.DisplayName; @@ -12,6 +13,7 @@ import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -26,6 +28,7 @@ @WebMvcTest(controllers = AuthController.class, excludeAutoConfiguration = {SecurityAutoConfiguration.class}) +@Import(GlobalExceptionHandler.class) class AuthControllerTest { @Autowired @@ -39,7 +42,6 @@ class AuthControllerTest { @Test @DisplayName("POST /api/auth/login - 로그인 성공") void login_Success() throws Exception { - // [L3 Refactoring] Record 타입 생성자 사용 LoginRequest request = new LoginRequest("test@joycrew.com", "password123!"); LoginResponse successResponse = new LoginResponse( "mocked.jwt.token", "로그인 성공", 1L, "테스트유저", "test@joycrew.com", UserRole.EMPLOYEE @@ -65,7 +67,8 @@ void login_Failure_WrongPassword() throws Exception { mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTHENTICATION_FAILED")); } @Test @@ -78,7 +81,8 @@ void login_Failure_EmailNotFound() throws Exception { mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTHENTICATION_FAILED")); } @Test @@ -91,4 +95,4 @@ void logout_Success() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("로그아웃 되었습니다.")); } -} \ No newline at end of file +} diff --git a/src/test/java/com/joycrew/backend/controller/UserControllerTest.java b/src/test/java/com/joycrew/backend/controller/UserControllerTest.java index b9d2b29..94ecd1a 100644 --- a/src/test/java/com/joycrew/backend/controller/UserControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/UserControllerTest.java @@ -5,8 +5,7 @@ import com.joycrew.backend.dto.UserProfileResponse; import com.joycrew.backend.entity.enums.UserRole; import com.joycrew.backend.exception.GlobalExceptionHandler; -import com.joycrew.backend.security.EmployeeDetailsService; -import com.joycrew.backend.security.JwtUtil; +import com.joycrew.backend.security.WithMockUserPrincipal; import com.joycrew.backend.service.EmployeeService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -15,7 +14,6 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; import static org.mockito.ArgumentMatchers.any; @@ -29,18 +27,16 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(controllers = UserController.class) -@Import(GlobalExceptionHandler.class) // [L3] 실제 전역 예외 핸들러를 가져와 테스트의 정확성 높임 +@Import(GlobalExceptionHandler.class) class UserControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @MockBean private EmployeeService employeeService; - @MockBean private JwtUtil jwtUtil; // SecurityConfig 구성에 필요 - @MockBean private EmployeeDetailsService employeeDetailsService; // SecurityConfig 구성에 필요 @Test @DisplayName("GET /api/user/profile - 프로필 조회 성공") - @WithMockUser(username = "testuser@joycrew.com") + @WithMockUserPrincipal void getProfile_Success() throws Exception { // Given UserProfileResponse mockResponse = new UserProfileResponse( @@ -58,7 +54,7 @@ void getProfile_Success() throws Exception { @Test @DisplayName("POST /api/user/password - 비밀번호 변경 성공") - @WithMockUser(username = "testuser@joycrew.com") + @WithMockUserPrincipal void forceChangePassword_Success() throws Exception { // Given PasswordChangeRequest request = new PasswordChangeRequest("newPassword123!"); @@ -72,4 +68,4 @@ void forceChangePassword_Success() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("비밀번호가 성공적으로 변경되었습니다.")); } -} \ No newline at end of file +} From 9d84b50149bb6f1c5a6d08d57f157d75d9664568 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Sun, 27 Jul 2025 14:24:33 +0900 Subject: [PATCH 035/135] Update cd.yml --- .github/workflows/cd.yml | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index e7483da..7d0f557 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -11,11 +11,13 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Set up JDK 17 uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '17' + cache: gradle - name: Build JAR for Production run: | @@ -23,16 +25,19 @@ jobs: ./gradlew clean build - name: Locate JAR artifact - id: get_jar - run: echo "jar_path=$(find build/libs -name '*.jar')" >> $GITHUB_OUTPUT + id: find_jar + run: | + JAR_PATH=$(find build/libs -name '*.jar') + echo "jar_path=${JAR_PATH}" >> $GITHUB_OUTPUT - - name: Create environment file + - name: Create environment file for EC2 run: | - echo "SPRING_PROFILES_ACTIVE=prod" >> .env + echo "SPRING_PROFILES_ACTIVE=prod" > .env echo "DB_HOST=${{ secrets.DB_HOST }}" >> .env echo "DB_USER=${{ secrets.DB_USER }}" >> .env echo "DB_PASSWORD=${{ secrets.DB_PASSWORD }}" >> .env echo "JWT_SECRET_KEY=${{ secrets.JWT_SECRET_KEY }}" >> .env + echo "SERVER_PORT=8080" >> .env - name: Transfer files to EC2 Server uses: appleboy/scp-action@master @@ -41,7 +46,7 @@ jobs: username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_KEY }} source: | - ${{ steps.get_jar.outputs.jar_path }} + ${{ steps.find_jar.outputs.jar_path }} .env target: ~/app/ @@ -56,11 +61,12 @@ jobs: sudo mkdir -p /etc/joycrew sudo mv ~/app/.env /etc/joycrew/.env - sudo chown joycrew-user:joycrew-group /etc/joycrew/.env + sudo chown ${{ secrets.SSH_USER }}:${{ secrets.SSH_USER }} /etc/joycrew/.env sudo chmod 600 /etc/joycrew/.env - JAR_NAME=$(basename "${{ steps.get_jar.outputs.jar_path }}") + JAR_NAME=$(basename "${{ steps.find_jar.outputs.jar_path }}") sudo mv ~/app/$JAR_NAME /var/www/joycrew/app.jar sudo systemctl restart joycrew-backend - sudo systemctl status joycrew-backend --no-pager \ No newline at end of file + sleep 10 + sudo systemctl status joycrew-backend --no-pager From b4a4360e8665fb5236c90bb9877bbec6c5c3055c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Sun, 27 Jul 2025 14:26:11 +0900 Subject: [PATCH 036/135] Update cd.yml --- .github/workflows/cd.yml | 58 ++++++++++++++++++++++++---------------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index e7483da..18c7b10 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -11,6 +11,7 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Set up JDK 17 uses: actions/setup-java@v4 with: @@ -20,30 +21,34 @@ jobs: - name: Build JAR for Production run: | chmod +x gradlew - ./gradlew clean build + ./gradlew clean assemble -Pspring.profiles.active=prod - - name: Locate JAR artifact - id: get_jar - run: echo "jar_path=$(find build/libs -name '*.jar')" >> $GITHUB_OUTPUT + - name: Locate and Prepare JAR artifact + id: get_jar_path + run: | + JAR_FILE=$(find build/libs -name "*.jar" | head -n 1) + + if [ -z "$JAR_FILE" ]; then + echo "Error: No executable JAR file found in build/libs directory!" + exit 1 + fi + echo "jar_path=$JAR_FILE" >> $GITHUB_OUTPUT - - name: Create environment file + - name: Add EC2 Host Key to known_hosts run: | - echo "SPRING_PROFILES_ACTIVE=prod" >> .env - echo "DB_HOST=${{ secrets.DB_HOST }}" >> .env - echo "DB_USER=${{ secrets.DB_USER }}" >> .env - echo "DB_PASSWORD=${{ secrets.DB_PASSWORD }}" >> .env - echo "JWT_SECRET_KEY=${{ secrets.JWT_SECRET_KEY }}" >> .env + mkdir -p ~/.ssh + echo "${{ secrets.SSH_HOST_KEYS }}" > ~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts - - name: Transfer files to EC2 Server + - name: Transfer JAR to EC2 Server uses: appleboy/scp-action@master with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_KEY }} - source: | - ${{ steps.get_jar.outputs.jar_path }} - .env - target: ~/app/ + source: ${{ steps.get_jar_path.outputs.jar_path }} + target: ~/app/build/libs/ + strip_components: 2 - name: Deploy and Restart Application on EC2 uses: appleboy/ssh-action@master @@ -51,16 +56,23 @@ jobs: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_KEY }} + script: | set -eux - sudo mkdir -p /etc/joycrew - sudo mv ~/app/.env /etc/joycrew/.env - sudo chown joycrew-user:joycrew-group /etc/joycrew/.env - sudo chmod 600 /etc/joycrew/.env + export JWT_SECRET_KEY="${{ secrets.JWT_SECRET_KEY }}" + + sudo systemctl stop joycrew-backend || true + + DEPLOY_DIR="/home/${{ secrets.SSH_USER }}/app/build/libs" + JAR_NAME=$(basename "${{ steps.get_jar_path.outputs.jar_path }}") + + sudo chmod +x "$DEPLOY_DIR/$JAR_NAME" + + sudo systemctl start joycrew-backend - JAR_NAME=$(basename "${{ steps.get_jar.outputs.jar_path }}") - sudo mv ~/app/$JAR_NAME /var/www/joycrew/app.jar + sudo systemctl status joycrew-backend --no-pager || true - sudo systemctl restart joycrew-backend - sudo systemctl status joycrew-backend --no-pager \ No newline at end of file + - name: Clean up local SSH key file + if: always() + run: rm -f private_key.pem From 3d4303d7619c29482dfea7af580c61db33fc19ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Sun, 27 Jul 2025 14:27:09 +0900 Subject: [PATCH 037/135] Update cd.yml --- .github/workflows/cd.yml | 60 ++++++++++++++++++++++------------------ 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 7d0f557..18c7b10 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -17,38 +17,38 @@ jobs: with: distribution: 'temurin' java-version: '17' - cache: gradle - name: Build JAR for Production run: | chmod +x gradlew - ./gradlew clean build + ./gradlew clean assemble -Pspring.profiles.active=prod - - name: Locate JAR artifact - id: find_jar + - name: Locate and Prepare JAR artifact + id: get_jar_path run: | - JAR_PATH=$(find build/libs -name '*.jar') - echo "jar_path=${JAR_PATH}" >> $GITHUB_OUTPUT + JAR_FILE=$(find build/libs -name "*.jar" | head -n 1) + + if [ -z "$JAR_FILE" ]; then + echo "Error: No executable JAR file found in build/libs directory!" + exit 1 + fi + echo "jar_path=$JAR_FILE" >> $GITHUB_OUTPUT - - name: Create environment file for EC2 + - name: Add EC2 Host Key to known_hosts run: | - echo "SPRING_PROFILES_ACTIVE=prod" > .env - echo "DB_HOST=${{ secrets.DB_HOST }}" >> .env - echo "DB_USER=${{ secrets.DB_USER }}" >> .env - echo "DB_PASSWORD=${{ secrets.DB_PASSWORD }}" >> .env - echo "JWT_SECRET_KEY=${{ secrets.JWT_SECRET_KEY }}" >> .env - echo "SERVER_PORT=8080" >> .env + mkdir -p ~/.ssh + echo "${{ secrets.SSH_HOST_KEYS }}" > ~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts - - name: Transfer files to EC2 Server + - name: Transfer JAR to EC2 Server uses: appleboy/scp-action@master with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_KEY }} - source: | - ${{ steps.find_jar.outputs.jar_path }} - .env - target: ~/app/ + source: ${{ steps.get_jar_path.outputs.jar_path }} + target: ~/app/build/libs/ + strip_components: 2 - name: Deploy and Restart Application on EC2 uses: appleboy/ssh-action@master @@ -56,17 +56,23 @@ jobs: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_KEY }} + script: | set -eux - sudo mkdir -p /etc/joycrew - sudo mv ~/app/.env /etc/joycrew/.env - sudo chown ${{ secrets.SSH_USER }}:${{ secrets.SSH_USER }} /etc/joycrew/.env - sudo chmod 600 /etc/joycrew/.env + export JWT_SECRET_KEY="${{ secrets.JWT_SECRET_KEY }}" + + sudo systemctl stop joycrew-backend || true + + DEPLOY_DIR="/home/${{ secrets.SSH_USER }}/app/build/libs" + JAR_NAME=$(basename "${{ steps.get_jar_path.outputs.jar_path }}") + + sudo chmod +x "$DEPLOY_DIR/$JAR_NAME" + + sudo systemctl start joycrew-backend - JAR_NAME=$(basename "${{ steps.find_jar.outputs.jar_path }}") - sudo mv ~/app/$JAR_NAME /var/www/joycrew/app.jar + sudo systemctl status joycrew-backend --no-pager || true - sudo systemctl restart joycrew-backend - sleep 10 - sudo systemctl status joycrew-backend --no-pager + - name: Clean up local SSH key file + if: always() + run: rm -f private_key.pem From 14edc3bb3a109fc6c5d111a13a6d243c62fe499f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Sun, 27 Jul 2025 21:07:32 +0900 Subject: [PATCH 038/135] Update build.gradle --- build.gradle | 57 +++++++++++----------------------------------------- 1 file changed, 12 insertions(+), 45 deletions(-) diff --git a/build.gradle b/build.gradle index 7a0323a..e601e79 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.5.3' - id 'io.spring.dependency-management' version '1.1.7' + id 'org.springframework.boot' version '3.3.1' + id 'io.spring.dependency-management' version '1.1.5' } group = 'com.joycrew' @@ -23,62 +23,29 @@ repositories { mavenCentral() } -sourceSets { - main { - java { - srcDirs = ['src/main/java'] - } - resources { - srcDirs = ['src/main/resources'] - } - } - test { - java { - srcDirs = ['src/test/java'] - } - resources { - srcDirs = ['src/test/resources'] - } - } -} - dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' - implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' - implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' - implementation 'org.springframework.security:spring-security-crypto' - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' - implementation 'org.springframework.boot:spring-boot-starter-test' - implementation 'org.springframework.security:spring-security-test' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' - compileOnly 'org.projectlombok:lombok' - developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.mysql:mysql-connector-j' runtimeOnly 'com.h2database:h2' + compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' -} + developmentOnly 'org.springframework.boot:spring-boot-devtools' -jar { - enabled = false + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' } tasks.named('test') { useJUnitPlatform() - - filter { - includeTestsMatching "com.joycrew.backend.service.*Test" - includeTestsMatching "com.joycrew.backend.repository.*Test" - includeTestsMatching "com.joycrew.backend.controller.*Test" - } - - testLogging { - events "passed", "skipped", "failed", "standardOut", "standardError" - showStandardStreams = true - } -} \ No newline at end of file +} From b23896aa644eeeb3e59581cb7c06f10e284e353a Mon Sep 17 00:00:00 2001 From: yeoEun Date: Sun, 27 Jul 2025 21:25:37 +0900 Subject: [PATCH 039/135] =?UTF-8?q?feat:=20=EB=8B=A8=EC=9D=BC=20=EC=A7=81?= =?UTF-8?q?=EC=9B=90=20=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 -- .../backend/controller/AuthController.java | 12 ++++ .../controller/EmployeeController.java | 48 ++++++++++++++++ .../backend/dto/CreateEmployeeRequest.java | 36 ++++++++++++ .../dto/CreateEmployeeSuccessResponse.java | 9 +++ .../backend/service/EmployeeAdminService.java | 57 +++++++++++++++++++ src/main/resources/application-dev.yml | 8 ++- src/main/resources/application.yml | 2 +- 8 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/joycrew/backend/controller/EmployeeController.java create mode 100644 src/main/java/com/joycrew/backend/dto/CreateEmployeeRequest.java create mode 100644 src/main/java/com/joycrew/backend/dto/CreateEmployeeSuccessResponse.java create mode 100644 src/main/java/com/joycrew/backend/service/EmployeeAdminService.java diff --git a/build.gradle b/build.gradle index e601e79..7be6c96 100644 --- a/build.gradle +++ b/build.gradle @@ -28,16 +28,12 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' - implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' - runtimeOnly 'com.mysql:mysql-connector-j' runtimeOnly 'com.h2database:h2' - compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' diff --git a/src/main/java/com/joycrew/backend/controller/AuthController.java b/src/main/java/com/joycrew/backend/controller/AuthController.java index 1c113a4..d3b5caf 100644 --- a/src/main/java/com/joycrew/backend/controller/AuthController.java +++ b/src/main/java/com/joycrew/backend/controller/AuthController.java @@ -10,7 +10,10 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Bean; import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.bind.annotation.*; @Tag(name = "인증", description = "로그인 관련 API") @@ -36,4 +39,13 @@ public ResponseEntity logout(HttpServletRequest request) { authService.logout(request); return ResponseEntity.ok(new SuccessResponse("로그아웃 되었습니다.")); } + + @Bean + public CommandLineRunner showPasswordHash(PasswordEncoder passwordEncoder) { + return args -> { + String rawPassword = "1234"; + String encoded = passwordEncoder.encode(rawPassword); + System.out.println("비밀번호 1234 해시값: " + encoded); + }; + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/controller/EmployeeController.java b/src/main/java/com/joycrew/backend/controller/EmployeeController.java new file mode 100644 index 0000000..8ead413 --- /dev/null +++ b/src/main/java/com/joycrew/backend/controller/EmployeeController.java @@ -0,0 +1,48 @@ +package com.joycrew.backend.controller; + +import com.joycrew.backend.dto.CreateEmployeeRequest; +import com.joycrew.backend.dto.CreateEmployeeSuccessResponse; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.service.EmployeeAdminService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "직원 관리", description = "HR 관리자의 단일 직원 등록 API") +@RestController +@RequestMapping("/api/employees") +@RequiredArgsConstructor +public class EmployeeController { + + private final EmployeeAdminService employeeAdminService; + + @Operation( + summary = "직원 생성", + description = "HR 관리자가 단일 직원을 등록합니다.", + security = @SecurityRequirement(name = "Authorization"), + responses = { + @ApiResponse( + responseCode = "200", + description = "직원 생성 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = CreateEmployeeSuccessResponse.class) + ) + ) + } + ) + @PostMapping + public ResponseEntity createEmployee( + @Valid @RequestBody CreateEmployeeRequest request + ) { + Employee created = employeeAdminService.createEmployee(request); + return ResponseEntity.ok(new CreateEmployeeSuccessResponse("직원 생성 완료 (ID: " + created.getEmployeeId() + ")")); + } +} diff --git a/src/main/java/com/joycrew/backend/dto/CreateEmployeeRequest.java b/src/main/java/com/joycrew/backend/dto/CreateEmployeeRequest.java new file mode 100644 index 0000000..af14568 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/CreateEmployeeRequest.java @@ -0,0 +1,36 @@ +package com.joycrew.backend.dto; + +import com.joycrew.backend.entity.enums.UserRole; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "직원 생성 요청 DTO") +public record CreateEmployeeRequest( + @Schema(example = "1", description = "회사 ID") Long companyId, + @Schema(example = "1", description = "부서 ID") Long departmentId, + + @NotBlank + @Schema(example = "김여은", description = "직원 이름") + String employeeName, + + @NotBlank + @Email + @Schema(example = "kye02@example.com", description = "직장 이메일") + String email, + + @NotBlank + @Schema(example = "1234", description = "비밀번호 (raw)") + String rawPassword, + + @Schema(example = "사원", description = "직급") String position, + @Schema(example = "https://example.com/profile.png", description = "프로필 이미지 URL") String profileImageUrl, + @Schema(example = "kye02@naver.com", description = "개인 이메일") String personalEmail, + @Schema(example = "010-1234-5678", description = "휴대폰 번호") String phoneNumber, + @Schema(example = "서울시 강남구", description = "배송 주소") String shippingAddress, + @Schema(example = "true", description = "이메일 알림 수신 여부") Boolean emailNotificationEnabled, + @Schema(example = "true", description = "앱 알림 수신 여부") Boolean appNotificationEnabled, + @Schema(example = "ko", description = "언어") String language, + @Schema(example = "Asia/Seoul", description = "시간대") String timezone, + @Schema(example = "EMPLOYEE", description = "역할") UserRole role +) {} diff --git a/src/main/java/com/joycrew/backend/dto/CreateEmployeeSuccessResponse.java b/src/main/java/com/joycrew/backend/dto/CreateEmployeeSuccessResponse.java new file mode 100644 index 0000000..27ddf29 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/CreateEmployeeSuccessResponse.java @@ -0,0 +1,9 @@ +package com.joycrew.backend.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "직원 생성 성공 응답 DTO") +public record CreateEmployeeSuccessResponse( + @Schema(example = "직원 생성 완료 (ID: 2)", description = "응답 메시지") + String message +) {} diff --git a/src/main/java/com/joycrew/backend/service/EmployeeAdminService.java b/src/main/java/com/joycrew/backend/service/EmployeeAdminService.java new file mode 100644 index 0000000..3dbce08 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/EmployeeAdminService.java @@ -0,0 +1,57 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.CreateEmployeeRequest; +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.entity.Department; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.enums.UserRole; +import com.joycrew.backend.repository.CompanyRepository; +import com.joycrew.backend.repository.DepartmentRepository; +import com.joycrew.backend.repository.EmployeeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class EmployeeAdminService { + + private final EmployeeRepository employeeRepository; + private final CompanyRepository companyRepository; + private final DepartmentRepository departmentRepository; + private final PasswordEncoder passwordEncoder; + + public Employee createEmployee(CreateEmployeeRequest request) { + Company company = companyRepository.findById(request.companyId()) + .orElseThrow(() -> new IllegalArgumentException("회사 ID가 유효하지 않습니다.")); + + Department department = null; + if (request.departmentId() != null) { + department = departmentRepository.findById(request.departmentId()) + .orElseThrow(() -> new IllegalArgumentException("부서 ID가 유효하지 않습니다.")); + } + + Employee employee = Employee.builder() + .company(company) + .department(department) + .employeeName(request.employeeName()) + .email(request.email()) + .passwordHash(passwordEncoder.encode(request.rawPassword())) + .position(request.position()) + .profileImageUrl(request.profileImageUrl()) + .personalEmail(request.personalEmail()) + .phoneNumber(request.phoneNumber()) + .shippingAddress(request.shippingAddress()) + .emailNotificationEnabled(request.emailNotificationEnabled()) + .appNotificationEnabled(request.appNotificationEnabled()) + .language(request.language()) + .timezone(request.timezone()) + .role(request.role() != null ? request.role() : UserRole.EMPLOYEE) + .status("ACTIVE") + .build(); + + return employeeRepository.save(employee); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index ecd01ed..d36ab5d 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -19,4 +19,10 @@ logging: level: org.hibernate.SQL: DEBUG org.hibernate.type.descriptor.sql: TRACE - com.joycrew.backend: DEBUG \ No newline at end of file + com.joycrew.backend: DEBUG + +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3c54c6d..7838626 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,5 +1,5 @@ server: - port: 8080 + port: 8082 spring: application: From 5349562919b769a437026623124e4e37dfaf1055 Mon Sep 17 00:00:00 2001 From: yeoEun Date: Mon, 28 Jul 2025 13:29:25 +0900 Subject: [PATCH 040/135] fix: AuthControllerTest-TestConfig --- .../joycrew/backend/controller/AuthController.java | 1 + .../backend/controller/AuthControllerTest.java | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/src/main/java/com/joycrew/backend/controller/AuthController.java b/src/main/java/com/joycrew/backend/controller/AuthController.java index d3b5caf..b984c50 100644 --- a/src/main/java/com/joycrew/backend/controller/AuthController.java +++ b/src/main/java/com/joycrew/backend/controller/AuthController.java @@ -12,6 +12,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Profile; import org.springframework.http.ResponseEntity; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.bind.annotation.*; diff --git a/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java b/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java index d1d7242..8418750 100644 --- a/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java @@ -11,12 +11,16 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.NoOpPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.web.servlet.MockMvc; import static org.mockito.ArgumentMatchers.any; @@ -39,6 +43,14 @@ class AuthControllerTest { @MockBean private AuthService authService; + @TestConfiguration + static class TestConfig { + @Bean + public PasswordEncoder passwordEncoder() { + return NoOpPasswordEncoder.getInstance(); // 테스트용 + } + } + @Test @DisplayName("POST /api/auth/login - 로그인 성공") void login_Success() throws Exception { From ec97d812e639864d2da3b5db35869a9952366b64 Mon Sep 17 00:00:00 2001 From: yeoEun Date: Mon, 28 Jul 2025 15:13:58 +0900 Subject: [PATCH 041/135] feat: employee-query --- ...ller.java => AdminEmployeeController.java} | 28 ++--- .../controller/EmployeeQueryController.java | 53 ++++++++++ .../backend/dto/CreateEmployeeRequest.java | 36 ------- .../backend/dto/EmployeeQueryResponse.java | 32 ++++++ ... EmployeeRegistrationSuccessResponse.java} | 2 +- .../entity/enums/EmployeeQueryType.java | 7 ++ .../repository/EmployeeQueryRepository.java | 9 ++ .../backend/service/EmployeeAdminService.java | 57 ---------- .../backend/service/EmployeeQueryService.java | 52 +++++++++ .../AdminEmployeeControllerTest.java | 70 ++++++++++++ .../EmployeeQueryControllerTest.java | 100 ++++++++++++++++++ 11 files changed, 339 insertions(+), 107 deletions(-) rename src/main/java/com/joycrew/backend/controller/{EmployeeController.java => AdminEmployeeController.java} (59%) create mode 100644 src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java delete mode 100644 src/main/java/com/joycrew/backend/dto/CreateEmployeeRequest.java create mode 100644 src/main/java/com/joycrew/backend/dto/EmployeeQueryResponse.java rename src/main/java/com/joycrew/backend/dto/{CreateEmployeeSuccessResponse.java => EmployeeRegistrationSuccessResponse.java} (83%) create mode 100644 src/main/java/com/joycrew/backend/entity/enums/EmployeeQueryType.java create mode 100644 src/main/java/com/joycrew/backend/repository/EmployeeQueryRepository.java delete mode 100644 src/main/java/com/joycrew/backend/service/EmployeeAdminService.java create mode 100644 src/main/java/com/joycrew/backend/service/EmployeeQueryService.java create mode 100644 src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java create mode 100644 src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java diff --git a/src/main/java/com/joycrew/backend/controller/EmployeeController.java b/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java similarity index 59% rename from src/main/java/com/joycrew/backend/controller/EmployeeController.java rename to src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java index 8ead413..6fbaffd 100644 --- a/src/main/java/com/joycrew/backend/controller/EmployeeController.java +++ b/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java @@ -1,9 +1,9 @@ package com.joycrew.backend.controller; -import com.joycrew.backend.dto.CreateEmployeeRequest; -import com.joycrew.backend.dto.CreateEmployeeSuccessResponse; +import com.joycrew.backend.dto.EmployeeRegistrationRequest; +import com.joycrew.backend.dto.EmployeeRegistrationSuccessResponse; import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.service.EmployeeAdminService; +import com.joycrew.backend.service.AdminEmployeeService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; @@ -17,32 +17,34 @@ @Tag(name = "직원 관리", description = "HR 관리자의 단일 직원 등록 API") @RestController -@RequestMapping("/api/employees") +@RequestMapping("/api/admin/employees") @RequiredArgsConstructor -public class EmployeeController { +public class AdminEmployeeController { - private final EmployeeAdminService employeeAdminService; + private final AdminEmployeeService adminEmployeeService; @Operation( - summary = "직원 생성", + summary = "직원 등록", description = "HR 관리자가 단일 직원을 등록합니다.", security = @SecurityRequirement(name = "Authorization"), responses = { @ApiResponse( responseCode = "200", - description = "직원 생성 성공", + description = "직원 등록 성공", content = @Content( mediaType = "application/json", - schema = @Schema(implementation = CreateEmployeeSuccessResponse.class) + schema = @Schema(implementation = EmployeeRegistrationSuccessResponse.class) ) ) } ) @PostMapping - public ResponseEntity createEmployee( - @Valid @RequestBody CreateEmployeeRequest request + public ResponseEntity registerEmployee( + @Valid @RequestBody EmployeeRegistrationRequest request ) { - Employee created = employeeAdminService.createEmployee(request); - return ResponseEntity.ok(new CreateEmployeeSuccessResponse("직원 생성 완료 (ID: " + created.getEmployeeId() + ")")); + Employee created = adminEmployeeService.registerEmployee(request); + return ResponseEntity.ok( + new EmployeeRegistrationSuccessResponse("직원 생성 완료 (ID: " + created.getEmployeeId() + ")") + ); } } diff --git a/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java b/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java new file mode 100644 index 0000000..2ec5e3a --- /dev/null +++ b/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java @@ -0,0 +1,53 @@ +package com.joycrew.backend.controller; + +import com.joycrew.backend.dto.EmployeeQueryResponse; +import com.joycrew.backend.entity.enums.EmployeeQueryType; +import com.joycrew.backend.service.EmployeeQueryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/employees") +public class EmployeeQueryController { + + private final EmployeeQueryService employeeQueryService; + + @Operation( + summary = "직원 목록 조회 및 검색", + description = "검색 유형(type: NAME, EMAIL, DEPARTMENT)과 키워드를 기준으로 직원 목록을 조회합니다.", + parameters = { + @Parameter(name = "type", description = "검색 기준 (NAME, EMAIL, DEPARTMENT)", example = "NAME"), + @Parameter(name = "keyword", description = "검색 키워드", example = "김"), + @Parameter(name = "page", description = "페이지 번호 (0부터 시작)", example = "0"), + @Parameter(name = "size", description = "페이지당 개수", example = "10") + }, + responses = { + @ApiResponse( + responseCode = "200", + description = "직원 목록 조회 성공", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = EmployeeQueryResponse.class)) + ) + ) + } + ) + @GetMapping + public List searchEmployees( + @RequestParam(defaultValue = "NAME") EmployeeQueryType type, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return employeeQueryService.getEmployees(type, keyword, page, size); + } +} diff --git a/src/main/java/com/joycrew/backend/dto/CreateEmployeeRequest.java b/src/main/java/com/joycrew/backend/dto/CreateEmployeeRequest.java deleted file mode 100644 index af14568..0000000 --- a/src/main/java/com/joycrew/backend/dto/CreateEmployeeRequest.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.joycrew.backend.dto; - -import com.joycrew.backend.entity.enums.UserRole; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; - -@Schema(description = "직원 생성 요청 DTO") -public record CreateEmployeeRequest( - @Schema(example = "1", description = "회사 ID") Long companyId, - @Schema(example = "1", description = "부서 ID") Long departmentId, - - @NotBlank - @Schema(example = "김여은", description = "직원 이름") - String employeeName, - - @NotBlank - @Email - @Schema(example = "kye02@example.com", description = "직장 이메일") - String email, - - @NotBlank - @Schema(example = "1234", description = "비밀번호 (raw)") - String rawPassword, - - @Schema(example = "사원", description = "직급") String position, - @Schema(example = "https://example.com/profile.png", description = "프로필 이미지 URL") String profileImageUrl, - @Schema(example = "kye02@naver.com", description = "개인 이메일") String personalEmail, - @Schema(example = "010-1234-5678", description = "휴대폰 번호") String phoneNumber, - @Schema(example = "서울시 강남구", description = "배송 주소") String shippingAddress, - @Schema(example = "true", description = "이메일 알림 수신 여부") Boolean emailNotificationEnabled, - @Schema(example = "true", description = "앱 알림 수신 여부") Boolean appNotificationEnabled, - @Schema(example = "ko", description = "언어") String language, - @Schema(example = "Asia/Seoul", description = "시간대") String timezone, - @Schema(example = "EMPLOYEE", description = "역할") UserRole role -) {} diff --git a/src/main/java/com/joycrew/backend/dto/EmployeeQueryResponse.java b/src/main/java/com/joycrew/backend/dto/EmployeeQueryResponse.java new file mode 100644 index 0000000..b3f9c5b --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/EmployeeQueryResponse.java @@ -0,0 +1,32 @@ +package com.joycrew.backend.dto; + +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.enums.UserRole; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema(description = "직원 검색 응답 DTO") +public record EmployeeQueryResponse( + @Schema(example = "1") Long employeeId, + @Schema(example = "김여은") String employeeName, + @Schema(example = "kye02@example.com") String email, + @Schema(example = "사원") String position, + @Schema(example = "ACTIVE") String status, + @Schema(example = "EMPLOYEE") UserRole role, + @Schema(example = "인사팀") String departmentName, + @Schema(example = "조이크루") String companyName +) { + public static EmployeeQueryResponse from(Employee e) { + return EmployeeQueryResponse.builder() + .employeeId(e.getEmployeeId()) + .employeeName(e.getEmployeeName()) + .email(e.getEmail()) + .position(e.getPosition()) + .status(e.getStatus()) + .role(e.getRole()) + .departmentName(e.getDepartment() != null ? e.getDepartment().getName() : null) + .companyName(e.getCompany().getCompanyName()) + .build(); + } +} diff --git a/src/main/java/com/joycrew/backend/dto/CreateEmployeeSuccessResponse.java b/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationSuccessResponse.java similarity index 83% rename from src/main/java/com/joycrew/backend/dto/CreateEmployeeSuccessResponse.java rename to src/main/java/com/joycrew/backend/dto/EmployeeRegistrationSuccessResponse.java index 27ddf29..eaca103 100644 --- a/src/main/java/com/joycrew/backend/dto/CreateEmployeeSuccessResponse.java +++ b/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationSuccessResponse.java @@ -3,7 +3,7 @@ import io.swagger.v3.oas.annotations.media.Schema; @Schema(description = "직원 생성 성공 응답 DTO") -public record CreateEmployeeSuccessResponse( +public record EmployeeRegistrationSuccessResponse( @Schema(example = "직원 생성 완료 (ID: 2)", description = "응답 메시지") String message ) {} diff --git a/src/main/java/com/joycrew/backend/entity/enums/EmployeeQueryType.java b/src/main/java/com/joycrew/backend/entity/enums/EmployeeQueryType.java new file mode 100644 index 0000000..5915f12 --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/enums/EmployeeQueryType.java @@ -0,0 +1,7 @@ +package com.joycrew.backend.entity.enums; + +public enum EmployeeQueryType { + NAME, + EMAIL, + DEPARTMENT +} //검색 타입 diff --git a/src/main/java/com/joycrew/backend/repository/EmployeeQueryRepository.java b/src/main/java/com/joycrew/backend/repository/EmployeeQueryRepository.java new file mode 100644 index 0000000..3d6b5e2 --- /dev/null +++ b/src/main/java/com/joycrew/backend/repository/EmployeeQueryRepository.java @@ -0,0 +1,9 @@ +package com.joycrew.backend.repository; + +import com.joycrew.backend.entity.Employee; + +import java.util.List; + +public interface EmployeeQueryRepository { + List searchByKeyword(String keyword, int offset, int limit); +} diff --git a/src/main/java/com/joycrew/backend/service/EmployeeAdminService.java b/src/main/java/com/joycrew/backend/service/EmployeeAdminService.java deleted file mode 100644 index 3dbce08..0000000 --- a/src/main/java/com/joycrew/backend/service/EmployeeAdminService.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.joycrew.backend.service; - -import com.joycrew.backend.dto.CreateEmployeeRequest; -import com.joycrew.backend.entity.Company; -import com.joycrew.backend.entity.Department; -import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.entity.enums.UserRole; -import com.joycrew.backend.repository.CompanyRepository; -import com.joycrew.backend.repository.DepartmentRepository; -import com.joycrew.backend.repository.EmployeeRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -@Transactional -public class EmployeeAdminService { - - private final EmployeeRepository employeeRepository; - private final CompanyRepository companyRepository; - private final DepartmentRepository departmentRepository; - private final PasswordEncoder passwordEncoder; - - public Employee createEmployee(CreateEmployeeRequest request) { - Company company = companyRepository.findById(request.companyId()) - .orElseThrow(() -> new IllegalArgumentException("회사 ID가 유효하지 않습니다.")); - - Department department = null; - if (request.departmentId() != null) { - department = departmentRepository.findById(request.departmentId()) - .orElseThrow(() -> new IllegalArgumentException("부서 ID가 유효하지 않습니다.")); - } - - Employee employee = Employee.builder() - .company(company) - .department(department) - .employeeName(request.employeeName()) - .email(request.email()) - .passwordHash(passwordEncoder.encode(request.rawPassword())) - .position(request.position()) - .profileImageUrl(request.profileImageUrl()) - .personalEmail(request.personalEmail()) - .phoneNumber(request.phoneNumber()) - .shippingAddress(request.shippingAddress()) - .emailNotificationEnabled(request.emailNotificationEnabled()) - .appNotificationEnabled(request.appNotificationEnabled()) - .language(request.language()) - .timezone(request.timezone()) - .role(request.role() != null ? request.role() : UserRole.EMPLOYEE) - .status("ACTIVE") - .build(); - - return employeeRepository.save(employee); - } -} diff --git a/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java b/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java new file mode 100644 index 0000000..4914b04 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java @@ -0,0 +1,52 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.EmployeeQueryResponse; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.enums.EmployeeQueryType; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.TypedQuery; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class EmployeeQueryService { + + @PersistenceContext + private final EntityManager em; + + public List getEmployees(EmployeeQueryType type, String keyword, int page, int size) { + int offset = page * size; + + StringBuilder jpql = new StringBuilder("SELECT e FROM Employee e " + + "JOIN FETCH e.company c " + + "LEFT JOIN FETCH e.department d "); + if (StringUtils.hasText(keyword)) { + jpql.append("WHERE "); + switch (type) { + case EMAIL -> jpql.append("LOWER(e.email) LIKE :keyword "); + case DEPARTMENT -> jpql.append("LOWER(d.name) LIKE :keyword "); + default -> jpql.append("LOWER(e.employeeName) LIKE :keyword "); + } + } + + jpql.append("ORDER BY e.createdAt DESC"); + + TypedQuery query = em.createQuery(jpql.toString(), Employee.class); + + if (StringUtils.hasText(keyword)) { + query.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); + } + + query.setFirstResult(offset); + query.setMaxResults(size); + + return query.getResultList().stream() + .map(EmployeeQueryResponse::from) + .toList(); + } +} diff --git a/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java b/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java new file mode 100644 index 0000000..0d3c79d --- /dev/null +++ b/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java @@ -0,0 +1,70 @@ +package com.joycrew.backend.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.joycrew.backend.dto.EmployeeRegistrationRequest; +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.entity.Department; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.enums.UserRole; +import com.joycrew.backend.service.AdminEmployeeService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(controllers = AdminEmployeeController.class, + excludeAutoConfiguration = {SecurityAutoConfiguration.class}) +class AdminEmployeeControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + + @MockBean private AdminEmployeeService adminEmployeeService; + + @Test + @WithMockUser(roles = "HR_ADMIN") + @DisplayName("POST /api/admin/employees - 직원 등록 성공") + void registerEmployee_success() throws Exception { + // Given - 요청 DTO (EmployeeRegistrationRequest에 맞춤) + EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( + "김여은", // name + "kye02@example.com", // email + "password123!", // initialPassword (8자 이상) + 1L, // companyId + 1L, // departmentId + "사원", // position + UserRole.EMPLOYEE // role + ); + + // Given - 서비스가 반환할 Employee mock 객체 + Employee mockEmployee = Employee.builder() + .employeeId(1L) + .employeeName("김여은") + .email("kye02@example.com") + .company(Company.builder().companyId(1L).companyName("조이크루").build()) + .department(Department.builder().departmentId(1L).name("인사팀").build()) + .position("사원") + .role(UserRole.EMPLOYEE) + .build(); + + when(adminEmployeeService.registerEmployee(any(EmployeeRegistrationRequest.class))) + .thenReturn(mockEmployee); + + // When & Then - 요청 수행 및 응답 검증 + mockMvc.perform(post("/api/admin/employees") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("직원 생성 완료 (ID: 1)")); + } +} diff --git a/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java b/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java new file mode 100644 index 0000000..deeee42 --- /dev/null +++ b/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java @@ -0,0 +1,100 @@ +package com.joycrew.backend.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.joycrew.backend.dto.EmployeeQueryResponse; +import com.joycrew.backend.entity.enums.EmployeeQueryType; +import com.joycrew.backend.entity.enums.UserRole; +import com.joycrew.backend.service.EmployeeQueryService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(controllers = EmployeeQueryController.class) +class EmployeeQueryControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private EmployeeQueryService employeeQueryService; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @WithMockUser + @DisplayName("이름 기준 검색 - 성공") + void searchByName_success() throws Exception { + EmployeeQueryResponse mockResponse = EmployeeQueryResponse.builder() + .employeeId(1L) + .employeeName("김여은") + .email("kye02@example.com") + .position("사원") + .status("ACTIVE") + .role(UserRole.EMPLOYEE) + .departmentName("인사팀") + .companyName("조이크루") + .build(); + + when(employeeQueryService.getEmployees( + any(EmployeeQueryType.class), + any(String.class), + any(Integer.class), + any(Integer.class) + )).thenReturn(List.of(mockResponse)); + + mockMvc.perform(get("/api/employees") + .param("type", "NAME") + .param("keyword", "김") + .param("page", "0") + .param("size", "10") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].employeeName").value("김여은")) + .andExpect(jsonPath("$[0].email").value("kye02@example.com")) + .andExpect(jsonPath("$[0].departmentName").value("인사팀")) + .andDo(result -> { + // 실제 응답 구조 확인용 로그 + String content = result.getResponse().getContentAsString(); + System.out.println(">>> 응답 JSON: " + content); + }); + } + + + @Test + @WithMockUser + @DisplayName("키워드 없이 전체 조회") + void searchWithoutKeyword_success() throws Exception { + when(employeeQueryService.getEmployees( + any(EmployeeQueryType.class), + any(String.class), + any(Integer.class), + any(Integer.class) + )).thenReturn(List.of()); + + mockMvc.perform(get("/api/employees") + .param("type", "NAME") + .param("keyword", "") // 👈 명시적으로 keyword 전달 + .param("page", "0") + .param("size", "10") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(result -> { + String content = result.getResponse().getContentAsString(); + System.out.println(">>> 전체 조회 응답 JSON: " + content); + }); + } + +} From fefcb62ff5b2f1b34dcc3b60abfede5f11fcbb0b Mon Sep 17 00:00:00 2001 From: yeoEun Date: Mon, 28 Jul 2025 16:13:19 +0900 Subject: [PATCH 042/135] feat: CSV-registration --- .../controller/AdminEmployeeController.java | 46 +++++++++++++ .../dto/EmployeeRegistrationRequest.java | 32 ++++----- .../backend/repository/CompanyRepository.java | 3 + .../repository/DepartmentRepository.java | 3 + .../backend/service/AdminEmployeeService.java | 68 +++++++++++++++++-- .../AdminEmployeeControllerTest.java | 38 +++++++++-- .../service/AuthServiceIntegrationTest.java | 2 +- .../EmployeeServiceIntegrationTest.java | 2 +- 8 files changed, 166 insertions(+), 28 deletions(-) diff --git a/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java b/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java index 6fbaffd..2c863a3 100644 --- a/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java +++ b/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java @@ -5,15 +5,19 @@ import com.joycrew.backend.entity.Employee; import com.joycrew.backend.service.AdminEmployeeService; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; @Tag(name = "직원 관리", description = "HR 관리자의 단일 직원 등록 API") @RestController @@ -47,4 +51,46 @@ public ResponseEntity registerEmployee( new EmployeeRegistrationSuccessResponse("직원 생성 완료 (ID: " + created.getEmployeeId() + ")") ); } + + @Operation( + summary = "직원 일괄 등록 (CSV)", + description = """ + HR 관리자가 CSV 파일을 업로드하여 여러 직원을 등록합니다. + CSV는 다음의 헤더를 포함해야 합니다: + name,email,initialPassword,companyName,departmentName,position,role + """, + security = @SecurityRequirement(name = "Authorization"), + responses = { + @ApiResponse( + responseCode = "200", + description = "직원 일괄 등록 완료", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "성공 응답 예시", + value = "\"CSV 업로드 및 직원 등록이 완료되었습니다.\"" + ) + ) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청 형식 또는 CSV 파싱 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "실패 응답 예시", + value = "\"CSV 파일 읽기 실패\"" + ) + ) + ) + } + ) + @PostMapping(value = "/bulk", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity registerEmployeesFromCsv( + @Parameter(description = "CSV 파일 업로드", required = true) + @RequestParam("file") MultipartFile file + ) { + adminEmployeeService.registerEmployeesFromCsv(file); + return ResponseEntity.ok("CSV 업로드 및 직원 등록이 완료되었습니다."); + } } diff --git a/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java b/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java index 781f88b..0b62f7b 100644 --- a/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java +++ b/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java @@ -7,25 +7,25 @@ import jakarta.validation.constraints.Size; public record EmployeeRegistrationRequest ( - @NotBlank(message = "이름은 필수입니다.") - String name, + @NotBlank(message = "이름은 필수입니다.") + String name, - @NotBlank(message = "이메일은 필수입니다.") - @Email(message = "유효한 이메일 형식이 아닙니다.") - String email, + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "유효한 이메일 형식이 아닙니다.") + String email, - @NotBlank(message = "초기 비밀번호는 필수입니다.") - @Size(min = 8, message = "비밀번호는 8자 이상이어야 합니다.") - String initialPassword, + @NotBlank(message = "초기 비밀번호는 필수입니다.") + @Size(min = 8, message = "비밀번호는 8자 이상이어야 합니다.") + String initialPassword, - @NotNull(message = "회사 ID는 필수입니다.") - Long companyId, + @NotBlank(message = "회사명은 필수입니다.") + String companyName, - Long departmentId, + String departmentName, // nullable 가능 - @NotBlank(message = "직책은 필수입니다.") - String position, + @NotBlank(message = "직책은 필수입니다.") + String position, - @NotNull(message = "역할은 필수입니다.") - UserRole role -){} + @NotNull(message = "역할은 필수입니다.") + UserRole role +) {} diff --git a/src/main/java/com/joycrew/backend/repository/CompanyRepository.java b/src/main/java/com/joycrew/backend/repository/CompanyRepository.java index 0a8799a..d9c6bec 100644 --- a/src/main/java/com/joycrew/backend/repository/CompanyRepository.java +++ b/src/main/java/com/joycrew/backend/repository/CompanyRepository.java @@ -3,5 +3,8 @@ import com.joycrew.backend.entity.Company; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface CompanyRepository extends JpaRepository { + Optional findByCompanyName(String name); } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java b/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java index 838e5d6..6ed591f 100644 --- a/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java +++ b/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java @@ -1,10 +1,13 @@ package com.joycrew.backend.repository; +import com.joycrew.backend.entity.Company; import com.joycrew.backend.entity.Department; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; +import java.util.Optional; public interface DepartmentRepository extends JpaRepository { List findAllByCompanyCompanyId(Long companyId); + Optional findByCompanyAndName(Company company, String name); } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java b/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java index 57ff182..a9e0db6 100644 --- a/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java +++ b/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java @@ -5,19 +5,31 @@ import com.joycrew.backend.entity.Department; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.UserRole; import com.joycrew.backend.repository.CompanyRepository; import com.joycrew.backend.repository.DepartmentRepository; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.WalletRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +@Slf4j @Service @RequiredArgsConstructor @Transactional public class AdminEmployeeService { + private final EmployeeRepository employeeRepository; private final CompanyRepository companyRepository; private final DepartmentRepository departmentRepository; @@ -28,13 +40,16 @@ public Employee registerEmployee(EmployeeRegistrationRequest request) { if (employeeRepository.findByEmail(request.email()).isPresent()) { throw new IllegalStateException("이미 사용 중인 이메일입니다."); } - Company company = companyRepository.findById(request.companyId()) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회사 ID입니다.")); + + Company company = companyRepository.findByCompanyName(request.companyName()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회사명입니다.")); + Department department = null; - if (request.departmentId() != null) { - department = departmentRepository.findById(request.departmentId()) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 부서 ID입니다.")); + if (request.departmentName() != null && !request.departmentName().isBlank()) { + department = departmentRepository.findByCompanyAndName(company, request.departmentName()) + .orElseThrow(() -> new IllegalArgumentException("해당 회사에 존재하지 않는 부서명입니다.")); } + Employee newEmployee = Employee.builder() .employeeName(request.name()) .email(request.email()) @@ -45,8 +60,49 @@ public Employee registerEmployee(EmployeeRegistrationRequest request) { .role(request.role()) .status("ACTIVE") .build(); + Employee savedEmployee = employeeRepository.save(newEmployee); walletRepository.save(new Wallet(savedEmployee)); return savedEmployee; } -} \ No newline at end of file + + public void registerEmployeesFromCsv(MultipartFile file) { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) { + + String line; + boolean isFirstLine = true; + + while ((line = reader.readLine()) != null) { + if (isFirstLine) { + isFirstLine = false; + continue; + } + + String[] tokens = line.split(","); + if (tokens.length < 7) { + log.warn("누락된 필드가 있는 행 건너뜀: {}", line); + continue; + } + + try { + EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( + tokens[0].trim(), // name + tokens[1].trim(), // email + tokens[2].trim(), // password + tokens[3].trim(), // companyName + tokens[4].trim().isBlank() ? null : tokens[4].trim(), // departmentName (nullable) + tokens[5].trim(), // position + UserRole.valueOf(tokens[6].trim().toUpperCase()) // role + ); + registerEmployee(request); + } catch (Exception e) { + log.warn("직원 등록 실패 - 입력값: [{}], 사유: {}", line, e.getMessage()); + } + } + + } catch (IOException e) { + throw new RuntimeException("CSV 파일 읽기 실패", e); + } + } +} diff --git a/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java b/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java index 0d3c79d..dc8cb62 100644 --- a/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java @@ -14,11 +14,15 @@ import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; +import java.nio.charset.StandardCharsets; + import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -35,13 +39,13 @@ class AdminEmployeeControllerTest { @WithMockUser(roles = "HR_ADMIN") @DisplayName("POST /api/admin/employees - 직원 등록 성공") void registerEmployee_success() throws Exception { - // Given - 요청 DTO (EmployeeRegistrationRequest에 맞춤) + // Given - 요청 DTO (회사명/부서명 기반) EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( "김여은", // name "kye02@example.com", // email - "password123!", // initialPassword (8자 이상) - 1L, // companyId - 1L, // departmentId + "password123!", // initialPassword + "조이크루", // companyName + "인사팀", // departmentName "사원", // position UserRole.EMPLOYEE // role ); @@ -67,4 +71,30 @@ void registerEmployee_success() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("직원 생성 완료 (ID: 1)")); } + + @Test + @WithMockUser(roles = "HR_ADMIN") + @DisplayName("POST /api/admin/employees/bulk - 직원 일괄 등록 성공") + void registerEmployeesFromCsv_success() throws Exception { + // Given: 예제 CSV 내용 + String csvContent = """ + name,email,initialPassword,companyName,departmentName,position,role + 김여은,kye02@example.com,password123,조이크루,인사팀,사원,EMPLOYEE + """; + + MockMultipartFile file = new MockMultipartFile( + "file", + "employees.csv", + "text/csv", + csvContent.getBytes(StandardCharsets.UTF_8) + ); + + // When & Then + mockMvc.perform(multipart("/api/admin/employees/bulk") + .file(file) + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isOk()) + .andExpect(content().string("CSV 업로드 및 직원 등록이 완료되었습니다.")); + } + } diff --git a/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java index 8d953c5..1c81fbc 100644 --- a/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java +++ b/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java @@ -51,7 +51,7 @@ void setUp() { testName, testEmail, testPassword, - defaultCompany.getCompanyId(), + defaultCompany.getCompanyName(), null, "사원", UserRole.EMPLOYEE diff --git a/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java index 699168f..4d46f94 100644 --- a/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java +++ b/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java @@ -54,7 +54,7 @@ void registerEmployee_Success() { // Given EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( "신규직원", "new.employee@joycrew.com", "password123!", - testCompany.getCompanyId(), testDepartment.getDepartmentId(), "사원", UserRole.EMPLOYEE + testCompany.getCompanyName(), testDepartment.getName(), "사원", UserRole.EMPLOYEE ); // When From 92a0f23534dd29a69b0a3e79a0c9a05c92f23f1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Tue, 29 Jul 2025 14:27:20 +0900 Subject: [PATCH 043/135] Update application-prod.yml --- src/main/resources/application-prod.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index ea9917a..8db1fb2 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -18,4 +18,11 @@ logging: file: name: /var/log/joycrew/app.log max-size: 10MB - max-history: 7 \ No newline at end of file + max-history: 7 + +# [중요] 최종 출시 시점에는 삭제 필요 +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html From 371a5350d30b8e95b6a055831e758df44e923786 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Tue, 29 Jul 2025 14:37:12 +0900 Subject: [PATCH 044/135] Update build.gradle --- build.gradle | 62 +++++++++++++++++++++++++++++----------------------- 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/build.gradle b/build.gradle index 7be6c96..40fc968 100644 --- a/build.gradle +++ b/build.gradle @@ -1,47 +1,55 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.3.1' - id 'io.spring.dependency-management' version '1.1.5' + id 'java' + id 'org.springframework.boot' version '3.3.1' + id 'io.spring.dependency-management' version '1.1.5' } group = 'com.joycrew' version = '0.0.1-SNAPSHOT' java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } } configurations { - compileOnly { - extendsFrom annotationProcessor - } + compileOnly { + extendsFrom annotationProcessor + } } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' - implementation 'io.jsonwebtoken:jjwt-api:0.11.5' - runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' - runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' - runtimeOnly 'com.mysql:mysql-connector-j' - runtimeOnly 'com.h2database:h2' - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - developmentOnly 'org.springframework.boot:spring-boot-devtools' - - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.security:spring-security-test' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + runtimeOnly 'com.mysql:mysql-connector-j' + runtimeOnly 'com.h2database:h2' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' +} + +bootJar { + enabled = true +} + +jar { + enabled = false } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() } From b90b8dd8d6348a796cba06b751c3be9b255dab47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Wed, 30 Jul 2025 12:39:34 +0900 Subject: [PATCH 045/135] Update application-prod.yml --- src/main/resources/application-prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 8db1fb2..bafd1e4 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -6,7 +6,7 @@ spring: password: ${DB_PASSWORD} jpa: hibernate: - ddl-auto: validate + ddl-auto: update show-sql: false jwt: secret: ${JWT_SECRET_KEY} From 8fe77ca3d559226aa84d0d85ad63b9a726f2ec90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Wed, 30 Jul 2025 13:00:05 +0900 Subject: [PATCH 046/135] Update cd.yml --- .github/workflows/cd.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 18c7b10..b826279 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -69,6 +69,8 @@ jobs: sudo chmod +x "$DEPLOY_DIR/$JAR_NAME" + sudo /usr/bin/java -jar "$DEPLOY_DIR/$JAR_NAME" --spring.profiles.active=prod > /dev/null 2>&1 & + sudo systemctl start joycrew-backend sudo systemctl status joycrew-backend --no-pager || true From d438c7ec53ebcd4fad57933e4dc02e63575b71a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Wed, 30 Jul 2025 13:17:34 +0900 Subject: [PATCH 047/135] Update cd.yml --- .github/workflows/cd.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 18c7b10..a0169e2 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -27,9 +27,8 @@ jobs: id: get_jar_path run: | JAR_FILE=$(find build/libs -name "*.jar" | head -n 1) - if [ -z "$JAR_FILE" ]; then - echo "Error: No executable JAR file found in build/libs directory!" + echo "Error: No executable JAR file found!" exit 1 fi echo "jar_path=$JAR_FILE" >> $GITHUB_OUTPUT @@ -59,9 +58,7 @@ jobs: script: | set -eux - - export JWT_SECRET_KEY="${{ secrets.JWT_SECRET_KEY }}" - + sudo systemctl stop joycrew-backend || true DEPLOY_DIR="/home/${{ secrets.SSH_USER }}/app/build/libs" From 01eb42ad35183839ad515c952d16aff92230ce4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Wed, 30 Jul 2025 14:11:59 +0900 Subject: [PATCH 048/135] Update cd.yml --- .github/workflows/cd.yml | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index a0169e2..f3299df 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -27,6 +27,7 @@ jobs: id: get_jar_path run: | JAR_FILE=$(find build/libs -name "*.jar" | head -n 1) + if [ -z "$JAR_FILE" ]; then echo "Error: No executable JAR file found!" exit 1 @@ -58,18 +59,21 @@ jobs: script: | set -eux + + export JWT_SECRET_KEY="${{ secrets.JWT_SECRET_KEY }}" - sudo systemctl stop joycrew-backend || true + echo "Stopping existing JoyCrew backend process..." + pkill -f 'java -jar' || true DEPLOY_DIR="/home/${{ secrets.SSH_USER }}/app/build/libs" JAR_NAME=$(basename "${{ steps.get_jar_path.outputs.jar_path }}") sudo chmod +x "$DEPLOY_DIR/$JAR_NAME" - sudo systemctl start joycrew-backend + echo "Starting JoyCrew backend application directly with prod profile..." + nohup /usr/bin/java -jar "$DEPLOY_DIR/$JAR_NAME" --spring.profiles.active=prod > /dev/null 2>&1 & - sudo systemctl status joycrew-backend --no-pager || true + sleep 10 - - name: Clean up local SSH key file - if: always() - run: rm -f private_key.pem + echo "Checking application process status..." + ps aux | grep "$JAR_NAME" | grep -v grep || true From 72a887bf7d75ae3cc48a4efe034656f4acce64c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Wed, 30 Jul 2025 14:22:32 +0900 Subject: [PATCH 049/135] Update cd.yml --- .github/workflows/cd.yml | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index f3299df..9a9bf63 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -1,4 +1,4 @@ -name: Java Spring Boot CD +name: Deploy to EC2 on: push: @@ -27,7 +27,6 @@ jobs: id: get_jar_path run: | JAR_FILE=$(find build/libs -name "*.jar" | head -n 1) - if [ -z "$JAR_FILE" ]; then echo "Error: No executable JAR file found!" exit 1 @@ -50,30 +49,36 @@ jobs: target: ~/app/build/libs/ strip_components: 2 - - name: Deploy and Restart Application on EC2 + - name: Deploy and Start Application on EC2 uses: appleboy/ssh-action@master with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_KEY }} - script: | set -eux - + + # JWT_SECRET_KEY 환경 변수 설정 (Spring Boot가 읽음) export JWT_SECRET_KEY="${{ secrets.JWT_SECRET_KEY }}" - echo "Stopping existing JoyCrew backend process..." + # 기존 Spring Boot 프로세스 종료 (systemd 사용 안함) pkill -f 'java -jar' || true DEPLOY_DIR="/home/${{ secrets.SSH_USER }}/app/build/libs" JAR_NAME=$(basename "${{ steps.get_jar_path.outputs.jar_path }}") - sudo chmod +x "$DEPLOY_DIR/$JAR_NAME" + # JAR 파일에 실행 권한 부여 + chmod +x "$DEPLOY_DIR/$JAR_NAME" - echo "Starting JoyCrew backend application directly with prod profile..." - nohup /usr/bin/java -jar "$DEPLOY_DIR/$JAR_NAME" --spring.profiles.active=prod > /dev/null 2>&1 & + # Spring Boot 애플리케이션을 백그라운드에서 직접 실행 (prod 프로파일 활성화) + nohup java -jar "$DEPLOY_DIR/$JAR_NAME" --spring.profiles.active=prod > /dev/null 2>&1 & + # 애플리케이션이 완전히 시작될 때까지 잠시 대기 sleep 10 - echo "Checking application process status..." + # 프로세스 실행 확인 (systemd 사용 안함) ps aux | grep "$JAR_NAME" | grep -v grep || true + + - name: Clean up local SSH key file + if: always() + run: rm -f private_key.pem From be0ea3e421b92678ccedcb343c953203f399ac56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Wed, 30 Jul 2025 14:56:30 +0900 Subject: [PATCH 050/135] Update cd.yml --- .github/workflows/cd.yml | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 9a9bf63..37660b6 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -1,4 +1,4 @@ -name: Deploy to EC2 +name: Java Spring Boot CD on: push: @@ -49,35 +49,31 @@ jobs: target: ~/app/build/libs/ strip_components: 2 - - name: Deploy and Start Application on EC2 + - name: Deploy and Restart Application on EC2 uses: appleboy/ssh-action@master with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_KEY }} + script: | set -eux - # JWT_SECRET_KEY 환경 변수 설정 (Spring Boot가 읽음) - export JWT_SECRET_KEY="${{ secrets.JWT_SECRET_KEY }}" - - # 기존 Spring Boot 프로세스 종료 (systemd 사용 안함) - pkill -f 'java -jar' || true + # systemd 서비스를 통해 Spring Boot 애플리케이션을 중지하고 재시작 + # JWT_SECRET_KEY 환경 변수는 EC2의 systemd 서비스 파일에 직접 설정되어야 합니다. + echo "Stopping existing JoyCrew backend service..." + sudo systemctl stop joycrew-backend || true # systemd를 통해 서비스 중지 + # JAR 파일에 실행 권한 부여 (scp로 전송 후 권한이 유지되지 않을 수 있으므로) DEPLOY_DIR="/home/${{ secrets.SSH_USER }}/app/build/libs" JAR_NAME=$(basename "${{ steps.get_jar_path.outputs.jar_path }}") + sudo chmod +x "$DEPLOY_DIR/$JAR_NAME" - # JAR 파일에 실행 권한 부여 - chmod +x "$DEPLOY_DIR/$JAR_NAME" - - # Spring Boot 애플리케이션을 백그라운드에서 직접 실행 (prod 프로파일 활성화) - nohup java -jar "$DEPLOY_DIR/$JAR_NAME" --spring.profiles.active=prod > /dev/null 2>&1 & - - # 애플리케이션이 완전히 시작될 때까지 잠시 대기 - sleep 10 + echo "Starting JoyCrew backend service..." + sudo systemctl start joycrew-backend # systemd를 통해 서비스 시작 - # 프로세스 실행 확인 (systemd 사용 안함) - ps aux | grep "$JAR_NAME" | grep -v grep || true + echo "Checking service status..." + sudo systemctl status joycrew-backend --no-pager || true # 서비스 상태 확인 - name: Clean up local SSH key file if: always() From 5141b6b637a08b1b5137376103c6ffad0e7c5502 Mon Sep 17 00:00:00 2001 From: yeoEun Date: Wed, 30 Jul 2025 16:07:52 +0900 Subject: [PATCH 051/135] feat: fix-employeequery-form-profileurl --- .../controller/EmployeeQueryController.java | 13 ++- .../backend/dto/EmployeeQueryResponse.java | 21 ++-- .../backend/dto/UserProfileResponse.java | 4 +- .../entity/enums/EmployeeQueryType.java | 7 -- .../backend/service/EmployeeQueryService.java | 13 +-- .../EmployeeQueryControllerTest.java | 98 +++++++++---------- .../controller/UserControllerTest.java | 13 ++- 7 files changed, 76 insertions(+), 93 deletions(-) delete mode 100644 src/main/java/com/joycrew/backend/entity/enums/EmployeeQueryType.java diff --git a/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java b/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java index 2ec5e3a..d3111ad 100644 --- a/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java +++ b/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java @@ -1,7 +1,6 @@ package com.joycrew.backend.controller; import com.joycrew.backend.dto.EmployeeQueryResponse; -import com.joycrew.backend.entity.enums.EmployeeQueryType; import com.joycrew.backend.service.EmployeeQueryService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -9,6 +8,7 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @@ -16,16 +16,16 @@ @RestController @RequiredArgsConstructor -@RequestMapping("/api/employees") +@RequestMapping("/api/employee/query") +@Tag(name = "직원 조회", description = "직원 목록 검색 API") public class EmployeeQueryController { private final EmployeeQueryService employeeQueryService; @Operation( - summary = "직원 목록 조회 및 검색", - description = "검색 유형(type: NAME, EMAIL, DEPARTMENT)과 키워드를 기준으로 직원 목록을 조회합니다.", + summary = "직원 목록 검색", + description = "이름, 이메일, 부서명을 기준으로 통합 검색을 수행합니다.", parameters = { - @Parameter(name = "type", description = "검색 기준 (NAME, EMAIL, DEPARTMENT)", example = "NAME"), @Parameter(name = "keyword", description = "검색 키워드", example = "김"), @Parameter(name = "page", description = "페이지 번호 (0부터 시작)", example = "0"), @Parameter(name = "size", description = "페이지당 개수", example = "10") @@ -43,11 +43,10 @@ public class EmployeeQueryController { ) @GetMapping public List searchEmployees( - @RequestParam(defaultValue = "NAME") EmployeeQueryType type, @RequestParam(required = false) String keyword, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size ) { - return employeeQueryService.getEmployees(type, keyword, page, size); + return employeeQueryService.getEmployees(keyword, page, size); } } diff --git a/src/main/java/com/joycrew/backend/dto/EmployeeQueryResponse.java b/src/main/java/com/joycrew/backend/dto/EmployeeQueryResponse.java index b3f9c5b..163dc06 100644 --- a/src/main/java/com/joycrew/backend/dto/EmployeeQueryResponse.java +++ b/src/main/java/com/joycrew/backend/dto/EmployeeQueryResponse.java @@ -1,32 +1,23 @@ package com.joycrew.backend.dto; import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.entity.enums.UserRole; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; @Builder @Schema(description = "직원 검색 응답 DTO") public record EmployeeQueryResponse( - @Schema(example = "1") Long employeeId, - @Schema(example = "김여은") String employeeName, - @Schema(example = "kye02@example.com") String email, - @Schema(example = "사원") String position, - @Schema(example = "ACTIVE") String status, - @Schema(example = "EMPLOYEE") UserRole role, - @Schema(example = "인사팀") String departmentName, - @Schema(example = "조이크루") String companyName + @Schema(description = "프로필 이미지 URL", example = "https://cdn.joycrew.com/profile/user123.jpg") String profileImageUrl, + @Schema(description = "직원 이름", example = "김여은") String employeeName, + @Schema(description = "부서명", example = "인사팀") String departmentName, + @Schema(description = "직책", example = "사원") String position ) { public static EmployeeQueryResponse from(Employee e) { return EmployeeQueryResponse.builder() - .employeeId(e.getEmployeeId()) + .profileImageUrl(e.getProfileImageUrl()) .employeeName(e.getEmployeeName()) - .email(e.getEmail()) - .position(e.getPosition()) - .status(e.getStatus()) - .role(e.getRole()) .departmentName(e.getDepartment() != null ? e.getDepartment().getName() : null) - .companyName(e.getCompany().getCompanyName()) + .position(e.getPosition()) .build(); } } diff --git a/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java b/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java index 1099a0d..a0b09f0 100644 --- a/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java +++ b/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java @@ -10,6 +10,7 @@ public record UserProfileResponse( @Schema(description = "사용자 고유 ID") Long employeeId, @Schema(description = "사용자 이름") String name, @Schema(description = "이메일 주소") String email, + @Schema(description = "프로필 이미지 URL") String profileImageUrl, @Schema(description = "현재 총 포인트 잔액") Integer totalBalance, @Schema(description = "현재 선물 가능한 포인트 잔액") Integer giftableBalance, @Schema(description = "사용자 역할") UserRole role, @@ -22,6 +23,7 @@ public static UserProfileResponse from(Employee employee, Wallet wallet) { employee.getEmployeeId(), employee.getEmployeeName(), employee.getEmail(), + employee.getProfileImageUrl(), // ✅ 프로필 이미지 추가 wallet.getBalance(), wallet.getGiftablePoint(), employee.getRole(), @@ -29,4 +31,4 @@ public static UserProfileResponse from(Employee employee, Wallet wallet) { employee.getPosition() ); } -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/entity/enums/EmployeeQueryType.java b/src/main/java/com/joycrew/backend/entity/enums/EmployeeQueryType.java deleted file mode 100644 index 5915f12..0000000 --- a/src/main/java/com/joycrew/backend/entity/enums/EmployeeQueryType.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.joycrew.backend.entity.enums; - -public enum EmployeeQueryType { - NAME, - EMAIL, - DEPARTMENT -} //검색 타입 diff --git a/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java b/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java index 4914b04..a4e0f80 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java @@ -2,7 +2,6 @@ import com.joycrew.backend.dto.EmployeeQueryResponse; import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.entity.enums.EmployeeQueryType; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import jakarta.persistence.TypedQuery; @@ -19,19 +18,17 @@ public class EmployeeQueryService { @PersistenceContext private final EntityManager em; - public List getEmployees(EmployeeQueryType type, String keyword, int page, int size) { + public List getEmployees(String keyword, int page, int size) { int offset = page * size; StringBuilder jpql = new StringBuilder("SELECT e FROM Employee e " + "JOIN FETCH e.company c " + "LEFT JOIN FETCH e.department d "); + if (StringUtils.hasText(keyword)) { - jpql.append("WHERE "); - switch (type) { - case EMAIL -> jpql.append("LOWER(e.email) LIKE :keyword "); - case DEPARTMENT -> jpql.append("LOWER(d.name) LIKE :keyword "); - default -> jpql.append("LOWER(e.employeeName) LIKE :keyword "); - } + jpql.append("WHERE LOWER(e.employeeName) LIKE :keyword ") + .append("OR LOWER(e.email) LIKE :keyword ") + .append("OR LOWER(d.name) LIKE :keyword "); } jpql.append("ORDER BY e.createdAt DESC"); diff --git a/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java b/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java index deeee42..ad33d07 100644 --- a/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java @@ -2,8 +2,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.joycrew.backend.dto.EmployeeQueryResponse; -import com.joycrew.backend.entity.enums.EmployeeQueryType; -import com.joycrew.backend.entity.enums.UserRole; import com.joycrew.backend.service.EmployeeQueryService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -16,85 +14,79 @@ import java.util.List; -import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +@WithMockUser(username = "testuser", roles = {"EMPLOYEE"}) @WebMvcTest(controllers = EmployeeQueryController.class) class EmployeeQueryControllerTest { - @Autowired - private MockMvc mockMvc; + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; - @MockBean - private EmployeeQueryService employeeQueryService; - - @Autowired - private ObjectMapper objectMapper; + @MockBean private EmployeeQueryService employeeQueryService; @Test - @WithMockUser - @DisplayName("이름 기준 검색 - 성공") - void searchByName_success() throws Exception { - EmployeeQueryResponse mockResponse = EmployeeQueryResponse.builder() - .employeeId(1L) + @DisplayName("GET /api/employee/query - 직원 목록 검색 성공") + void searchEmployees_success() throws Exception { + // Given + EmployeeQueryResponse mockEmployee = EmployeeQueryResponse.builder() + .profileImageUrl("https://cdn.joycrew.com/profile/user1.jpg") .employeeName("김여은") - .email("kye02@example.com") - .position("사원") - .status("ACTIVE") - .role(UserRole.EMPLOYEE) .departmentName("인사팀") - .companyName("조이크루") + .position("사원") .build(); - when(employeeQueryService.getEmployees( - any(EmployeeQueryType.class), - any(String.class), - any(Integer.class), - any(Integer.class) - )).thenReturn(List.of(mockResponse)); + when(employeeQueryService.getEmployees(anyString(), anyInt(), anyInt())) + .thenReturn(List.of(mockEmployee)); - mockMvc.perform(get("/api/employees") - .param("type", "NAME") + // When & Then + mockMvc.perform(get("/api/employee/query") .param("keyword", "김") .param("page", "0") .param("size", "10") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].employeeName").value("김여은")) - .andExpect(jsonPath("$[0].email").value("kye02@example.com")) .andExpect(jsonPath("$[0].departmentName").value("인사팀")) - .andDo(result -> { - // 실제 응답 구조 확인용 로그 - String content = result.getResponse().getContentAsString(); - System.out.println(">>> 응답 JSON: " + content); - }); + .andExpect(jsonPath("$[0].position").value("사원")) + .andExpect(jsonPath("$[0].profileImageUrl").value("https://cdn.joycrew.com/profile/user1.jpg")); } - @Test - @WithMockUser - @DisplayName("키워드 없이 전체 조회") - void searchWithoutKeyword_success() throws Exception { - when(employeeQueryService.getEmployees( - any(EmployeeQueryType.class), - any(String.class), - any(Integer.class), - any(Integer.class) - )).thenReturn(List.of()); + @DisplayName("GET /api/employee/query - 검색어 없이도 정상 조회") + void searchEmployees_noKeyword() throws Exception { + // Given + EmployeeQueryResponse mockEmployee = EmployeeQueryResponse.builder() + .profileImageUrl(null) + .employeeName("홍길동") + .departmentName(null) + .position("주임") + .build(); + + when(employeeQueryService.getEmployees(isNull(), anyInt(), anyInt())) + .thenReturn(List.of(mockEmployee)); - mockMvc.perform(get("/api/employees") - .param("type", "NAME") - .param("keyword", "") // 👈 명시적으로 keyword 전달 + // When & Then + mockMvc.perform(get("/api/employee/query") .param("page", "0") - .param("size", "10") - .contentType(MediaType.APPLICATION_JSON)) + .param("size", "10")) .andExpect(status().isOk()) - .andDo(result -> { - String content = result.getResponse().getContentAsString(); - System.out.println(">>> 전체 조회 응답 JSON: " + content); - }); + .andExpect(jsonPath("$[0].employeeName").value("홍길동")) + .andExpect(jsonPath("$[0].position").value("주임")); + } + + @Test + void debug_print_response() throws Exception { + mockMvc.perform(get("/api/employee/query") + .param("keyword", "김") + .param("page", "0") + .param("size", "10")) + .andDo(print()) // 👈 응답을 콘솔에 출력 + .andExpect(status().isOk()); } } diff --git a/src/test/java/com/joycrew/backend/controller/UserControllerTest.java b/src/test/java/com/joycrew/backend/controller/UserControllerTest.java index 94ecd1a..c8e6c4b 100644 --- a/src/test/java/com/joycrew/backend/controller/UserControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/UserControllerTest.java @@ -40,8 +40,15 @@ class UserControllerTest { void getProfile_Success() throws Exception { // Given UserProfileResponse mockResponse = new UserProfileResponse( - 1L, "테스트유저", "testuser@joycrew.com", 1500, 100, - UserRole.EMPLOYEE, "개발팀", "사원" + 1L, + "테스트유저", + "testuser@joycrew.com", + "https://cdn.joycrew.com/profile/testuser.jpg", + 1500, + 100, + UserRole.EMPLOYEE, + "개발팀", + "사원" ); when(employeeService.getUserProfile("testuser@joycrew.com")).thenReturn(mockResponse); @@ -49,9 +56,11 @@ void getProfile_Success() throws Exception { mockMvc.perform(get("/api/user/profile")) .andExpect(status().isOk()) .andExpect(jsonPath("$.name").value("테스트유저")) + .andExpect(jsonPath("$.profileImageUrl").value("https://cdn.joycrew.com/profile/testuser.jpg")) .andExpect(jsonPath("$.totalBalance").value(1500)); } + @Test @DisplayName("POST /api/user/password - 비밀번호 변경 성공") @WithMockUserPrincipal From 5e571c92eb65ca0b0157810b0d04b804b34b839a Mon Sep 17 00:00:00 2001 From: yeoEun Date: Wed, 30 Jul 2025 16:15:09 +0900 Subject: [PATCH 052/135] fix: employeeName ASC --- .../java/com/joycrew/backend/service/EmployeeQueryService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java b/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java index a4e0f80..dec355b 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java @@ -31,7 +31,8 @@ public List getEmployees(String keyword, int page, int si .append("OR LOWER(d.name) LIKE :keyword "); } - jpql.append("ORDER BY e.createdAt DESC"); + // ✅ 이름 오름차순 정렬로 고정 + jpql.append("ORDER BY e.employeeName ASC"); TypedQuery query = em.createQuery(jpql.toString(), Employee.class); From 882d48502cbc0c6b3daa65ed87ac42ccbc29c257 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Fri, 1 Aug 2025 22:18:08 +0900 Subject: [PATCH 053/135] Update SecurityConfig.java --- src/main/java/com/joycrew/backend/config/SecurityConfig.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/joycrew/backend/config/SecurityConfig.java b/src/main/java/com/joycrew/backend/config/SecurityConfig.java index 1ea4f93..b016013 100644 --- a/src/main/java/com/joycrew/backend/config/SecurityConfig.java +++ b/src/main/java/com/joycrew/backend/config/SecurityConfig.java @@ -88,6 +88,7 @@ public PasswordEncoder passwordEncoder() { public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("http://localhost:3000"); + config.addAllowedOriginPattern("http://localhost:5173"); config.addAllowedOriginPattern("https://joycrew.co.kr"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); @@ -105,4 +106,4 @@ public AuthenticationManager authenticationManager(PasswordEncoder passwordEncod authenticationProvider.setPasswordEncoder(passwordEncoder); return new ProviderManager(authenticationProvider); } -} \ No newline at end of file +} From 0dfddfaa95fd9197a1612a0492fc0be3019cb73c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Sat, 2 Aug 2025 18:26:05 +0900 Subject: [PATCH 054/135] Create HealthCheckController.java --- .../backend/controller/HealthCheckController.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/main/java/com/joycrew/backend/controller/HealthCheckController.java diff --git a/src/main/java/com/joycrew/backend/controller/HealthCheckController.java b/src/main/java/com/joycrew/backend/controller/HealthCheckController.java new file mode 100644 index 0000000..2362dcb --- /dev/null +++ b/src/main/java/com/joycrew/backend/controller/HealthCheckController.java @@ -0,0 +1,14 @@ +package com.joycrew.backend.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.http.ResponseEntity; + +@RestController +public class HealthCheckController { + + @GetMapping("/") + public ResponseEntity healthCheck() { + return ResponseEntity.ok("JoyCrew Backend is running!"); + } +} From c65641008727c749471e8058beeb22387b5f6d97 Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Sat, 2 Aug 2025 18:29:50 +0900 Subject: [PATCH 055/135] =?UTF-8?q?feat=20:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20enum=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/config/SecurityConfig.java | 5 ++-- .../dto/AdminEmployeeUpdateRequest.java | 4 +-- .../dto/EmployeeRegistrationRequest.java | 4 +-- .../joycrew/backend/dto/LoginResponse.java | 4 +-- .../backend/dto/UserProfileResponse.java | 4 +-- .../com/joycrew/backend/entity/Employee.java | 6 ++-- .../backend/entity/enums/UserRole.java | 8 ----- .../backend/service/AdminEmployeeService.java | 30 ++++++++++++++----- .../config/TestUserDetailsService.java | 1 - .../AdminEmployeeControllerTest.java | 1 - .../controller/AuthControllerTest.java | 1 - .../controller/UserControllerTest.java | 1 - .../repository/EmployeeRepositoryTest.java | 1 - .../repository/WalletRepositoryTest.java | 1 - ...ckUserPrincipalSecurityContextFactory.java | 1 - .../service/AuthServiceIntegrationTest.java | 1 - .../backend/service/AuthServiceTest.java | 1 - .../EmployeeServiceIntegrationTest.java | 1 - 18 files changed, 36 insertions(+), 39 deletions(-) delete mode 100644 src/main/java/com/joycrew/backend/entity/enums/UserRole.java diff --git a/src/main/java/com/joycrew/backend/config/SecurityConfig.java b/src/main/java/com/joycrew/backend/config/SecurityConfig.java index 1ea4f93..8b12c0d 100644 --- a/src/main/java/com/joycrew/backend/config/SecurityConfig.java +++ b/src/main/java/com/joycrew/backend/config/SecurityConfig.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.joycrew.backend.dto.ErrorResponse; +import com.joycrew.backend.entity.enums.AdminLevel; import com.joycrew.backend.security.JwtAuthenticationFilter; import com.joycrew.backend.security.JwtUtil; import com.joycrew.backend.security.EmployeeDetailsService; @@ -23,8 +24,6 @@ import org.springframework.security.authentication.ProviderManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; -import static com.joycrew.backend.entity.enums.UserRole.HR_ADMIN; - @Configuration @EnableWebSecurity @@ -50,7 +49,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/swagger-ui/**", "/swagger-ui.html" ).permitAll() - .requestMatchers("/api/admin/**").hasRole(HR_ADMIN.name()) + .requestMatchers("/api/admin/**").hasRole(AdminLevel.SUPER_ADMIN.name()) .anyRequest().authenticated() ) .exceptionHandling(exceptions -> exceptions diff --git a/src/main/java/com/joycrew/backend/dto/AdminEmployeeUpdateRequest.java b/src/main/java/com/joycrew/backend/dto/AdminEmployeeUpdateRequest.java index 659c348..bf19f75 100644 --- a/src/main/java/com/joycrew/backend/dto/AdminEmployeeUpdateRequest.java +++ b/src/main/java/com/joycrew/backend/dto/AdminEmployeeUpdateRequest.java @@ -1,11 +1,11 @@ package com.joycrew.backend.dto; -import com.joycrew.backend.entity.enums.UserRole; +import com.joycrew.backend.entity.enums.AdminLevel; public record AdminEmployeeUpdateRequest( String name, Long departmentId, String position, - UserRole role, + AdminLevel level, String status ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java b/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java index 0b62f7b..6f77b34 100644 --- a/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java +++ b/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java @@ -1,6 +1,6 @@ package com.joycrew.backend.dto; -import com.joycrew.backend.entity.enums.UserRole; +import com.joycrew.backend.entity.enums.AdminLevel; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -27,5 +27,5 @@ public record EmployeeRegistrationRequest ( String position, @NotNull(message = "역할은 필수입니다.") - UserRole role + AdminLevel level ) {} diff --git a/src/main/java/com/joycrew/backend/dto/LoginResponse.java b/src/main/java/com/joycrew/backend/dto/LoginResponse.java index f5fa572..f9a0d3d 100644 --- a/src/main/java/com/joycrew/backend/dto/LoginResponse.java +++ b/src/main/java/com/joycrew/backend/dto/LoginResponse.java @@ -1,6 +1,6 @@ package com.joycrew.backend.dto; -import com.joycrew.backend.entity.enums.UserRole; +import com.joycrew.backend.entity.enums.AdminLevel; import io.swagger.v3.oas.annotations.media.Schema; @Schema(description = "로그인 응답 DTO") @@ -16,7 +16,7 @@ public record LoginResponse( @Schema(description = "사용자 이메일") String email, @Schema(description = "사용자 역할") - UserRole role + AdminLevel role ) { public static LoginResponse fail(String message) { return new LoginResponse(null, message, null, null, null, null); } diff --git a/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java b/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java index a0b09f0..426af68 100644 --- a/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java +++ b/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java @@ -2,7 +2,7 @@ import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.Wallet; -import com.joycrew.backend.entity.enums.UserRole; +import com.joycrew.backend.entity.enums.AdminLevel; import io.swagger.v3.oas.annotations.media.Schema; @Schema(description = "사용자 프로필 응답 DTO") @@ -13,7 +13,7 @@ public record UserProfileResponse( @Schema(description = "프로필 이미지 URL") String profileImageUrl, @Schema(description = "현재 총 포인트 잔액") Integer totalBalance, @Schema(description = "현재 선물 가능한 포인트 잔액") Integer giftableBalance, - @Schema(description = "사용자 역할") UserRole role, + @Schema(description = "사용자 권한")AdminLevel level, @Schema(description = "소속 부서") String department, @Schema(description = "직책") String position ) { diff --git a/src/main/java/com/joycrew/backend/entity/Employee.java b/src/main/java/com/joycrew/backend/entity/Employee.java index 44bd6e9..2714133 100644 --- a/src/main/java/com/joycrew/backend/entity/Employee.java +++ b/src/main/java/com/joycrew/backend/entity/Employee.java @@ -1,6 +1,6 @@ package com.joycrew.backend.entity; -import com.joycrew.backend.entity.enums.UserRole; +import com.joycrew.backend.entity.enums.AdminLevel; import jakarta.persistence.*; import lombok.*; import org.springframework.security.core.GrantedAuthority; @@ -44,7 +44,7 @@ public class Employee implements UserDetails { @Column(nullable = false) private String status; @Column(nullable = false) - private UserRole role; + private AdminLevel role; @Column(length = 2048) private String profileImageUrl; @@ -89,7 +89,7 @@ public void assignToDepartment(Department newDepartment) { protected void onCreate() { this.createdAt = this.updatedAt = LocalDateTime.now(); if (this.status == null) this.status = "ACTIVE"; - if (this.role == null) this.role = UserRole.EMPLOYEE; + if (this.role == null) this.role = AdminLevel.EMPLOYEE; if (this.emailNotificationEnabled == null) this.emailNotificationEnabled = true; if (this.appNotificationEnabled == null) this.appNotificationEnabled = true; } diff --git a/src/main/java/com/joycrew/backend/entity/enums/UserRole.java b/src/main/java/com/joycrew/backend/entity/enums/UserRole.java deleted file mode 100644 index 538f934..0000000 --- a/src/main/java/com/joycrew/backend/entity/enums/UserRole.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.joycrew.backend.entity.enums; - -public enum UserRole { - EMPLOYEE, - MANAGER, - HR_ADMIN, - SUPER_ADMIN -} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java b/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java index a9e0db6..46fc94c 100644 --- a/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java +++ b/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java @@ -5,7 +5,7 @@ import com.joycrew.backend.entity.Department; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.Wallet; -import com.joycrew.backend.entity.enums.UserRole; +import com.joycrew.backend.entity.enums.AdminLevel; import com.joycrew.backend.repository.CompanyRepository; import com.joycrew.backend.repository.DepartmentRepository; import com.joycrew.backend.repository.EmployeeRepository; @@ -21,8 +21,6 @@ import java.io.IOException; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.List; @Slf4j @Service @@ -57,7 +55,7 @@ public Employee registerEmployee(EmployeeRegistrationRequest request) { .company(company) .department(department) .position(request.position()) - .role(request.role()) + .role(request.level()) .status("ACTIVE") .build(); @@ -80,12 +78,14 @@ public void registerEmployeesFromCsv(MultipartFile file) { } String[] tokens = line.split(","); - if (tokens.length < 7) { + if (tokens.length < 6) { // 최소 6개 필드 필요 (level은 optional) log.warn("누락된 필드가 있는 행 건너뜀: {}", line); continue; } try { + AdminLevel adminLevel = parseAdminLevel(tokens.length > 6 ? tokens[6].trim() : "EMPLOYEE"); + EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( tokens[0].trim(), // name tokens[1].trim(), // email @@ -93,7 +93,7 @@ public void registerEmployeesFromCsv(MultipartFile file) { tokens[3].trim(), // companyName tokens[4].trim().isBlank() ? null : tokens[4].trim(), // departmentName (nullable) tokens[5].trim(), // position - UserRole.valueOf(tokens[6].trim().toUpperCase()) // role + adminLevel // level ); registerEmployee(request); } catch (Exception e) { @@ -105,4 +105,20 @@ public void registerEmployeesFromCsv(MultipartFile file) { throw new RuntimeException("CSV 파일 읽기 실패", e); } } -} + + /** + * 문자열을 AdminLevel enum으로 변환 + */ + private AdminLevel parseAdminLevel(String level) { + if (level == null || level.isBlank()) { + return AdminLevel.EMPLOYEE; // 기본값 + } + + try { + return AdminLevel.valueOf(level.toUpperCase()); + } catch (IllegalArgumentException e) { + log.warn("유효하지 않은 권한 레벨: {}. 기본값 EMPLOYEE로 설정합니다.", level); + return AdminLevel.EMPLOYEE; + } + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java b/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java index 10e4f55..81dc419 100644 --- a/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java +++ b/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java @@ -1,7 +1,6 @@ package com.joycrew.backend.config; import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.entity.enums.UserRole; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; diff --git a/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java b/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java index dc8cb62..c8e5f75 100644 --- a/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java @@ -5,7 +5,6 @@ import com.joycrew.backend.entity.Company; import com.joycrew.backend.entity.Department; import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.entity.enums.UserRole; import com.joycrew.backend.service.AdminEmployeeService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java b/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java index 8418750..9b35e01 100644 --- a/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java @@ -3,7 +3,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.joycrew.backend.dto.LoginRequest; import com.joycrew.backend.dto.LoginResponse; -import com.joycrew.backend.entity.enums.UserRole; import com.joycrew.backend.exception.GlobalExceptionHandler; import com.joycrew.backend.service.AuthService; import jakarta.servlet.http.HttpServletRequest; diff --git a/src/test/java/com/joycrew/backend/controller/UserControllerTest.java b/src/test/java/com/joycrew/backend/controller/UserControllerTest.java index c8e6c4b..ae8d7e0 100644 --- a/src/test/java/com/joycrew/backend/controller/UserControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/UserControllerTest.java @@ -3,7 +3,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.joycrew.backend.dto.PasswordChangeRequest; import com.joycrew.backend.dto.UserProfileResponse; -import com.joycrew.backend.entity.enums.UserRole; import com.joycrew.backend.exception.GlobalExceptionHandler; import com.joycrew.backend.security.WithMockUserPrincipal; import com.joycrew.backend.service.EmployeeService; diff --git a/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java b/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java index 935c87c..7b823b8 100644 --- a/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java +++ b/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java @@ -4,7 +4,6 @@ import com.joycrew.backend.entity.Department; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.Wallet; -import com.joycrew.backend.entity.enums.UserRole; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/joycrew/backend/repository/WalletRepositoryTest.java b/src/test/java/com/joycrew/backend/repository/WalletRepositoryTest.java index 7209195..edf0fae 100644 --- a/src/test/java/com/joycrew/backend/repository/WalletRepositoryTest.java +++ b/src/test/java/com/joycrew/backend/repository/WalletRepositoryTest.java @@ -3,7 +3,6 @@ import com.joycrew.backend.entity.Company; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.Wallet; -import com.joycrew.backend.entity.enums.UserRole; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/joycrew/backend/security/WithMockUserPrincipalSecurityContextFactory.java b/src/test/java/com/joycrew/backend/security/WithMockUserPrincipalSecurityContextFactory.java index d540943..1253d8f 100644 --- a/src/test/java/com/joycrew/backend/security/WithMockUserPrincipalSecurityContextFactory.java +++ b/src/test/java/com/joycrew/backend/security/WithMockUserPrincipalSecurityContextFactory.java @@ -1,7 +1,6 @@ package com.joycrew.backend.security; import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.entity.enums.UserRole; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; diff --git a/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java index 1c81fbc..20eb26b 100644 --- a/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java +++ b/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java @@ -5,7 +5,6 @@ import com.joycrew.backend.dto.LoginRequest; import com.joycrew.backend.dto.LoginResponse; import com.joycrew.backend.entity.Company; -import com.joycrew.backend.entity.enums.UserRole; import com.joycrew.backend.repository.CompanyRepository; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.security.JwtUtil; diff --git a/src/test/java/com/joycrew/backend/service/AuthServiceTest.java b/src/test/java/com/joycrew/backend/service/AuthServiceTest.java index a463379..3cc3239 100644 --- a/src/test/java/com/joycrew/backend/service/AuthServiceTest.java +++ b/src/test/java/com/joycrew/backend/service/AuthServiceTest.java @@ -3,7 +3,6 @@ import com.joycrew.backend.dto.LoginRequest; import com.joycrew.backend.dto.LoginResponse; import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.entity.enums.UserRole; import com.joycrew.backend.security.JwtUtil; import com.joycrew.backend.security.UserPrincipal; import org.junit.jupiter.api.BeforeEach; diff --git a/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java index 4d46f94..d6912ee 100644 --- a/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java +++ b/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java @@ -5,7 +5,6 @@ import com.joycrew.backend.entity.Company; import com.joycrew.backend.entity.Department; import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.entity.enums.UserRole; import com.joycrew.backend.repository.CompanyRepository; import com.joycrew.backend.repository.DepartmentRepository; import com.joycrew.backend.repository.EmployeeRepository; From 14e59d0fde0ef53d9ee069d24c10e1daaeb34777 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Sat, 2 Aug 2025 18:57:27 +0900 Subject: [PATCH 056/135] Update SecurityConfig.java --- src/main/java/com/joycrew/backend/config/SecurityConfig.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/joycrew/backend/config/SecurityConfig.java b/src/main/java/com/joycrew/backend/config/SecurityConfig.java index b016013..d394dbe 100644 --- a/src/main/java/com/joycrew/backend/config/SecurityConfig.java +++ b/src/main/java/com/joycrew/backend/config/SecurityConfig.java @@ -44,6 +44,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .frameOptions(frameOptions -> frameOptions.disable())) .authorizeHttpRequests(auth -> auth .requestMatchers( + "/", "/h2-console/**", "/api/auth/login", "/v3/api-docs/**", From 29bb2661b6026f851ce5ef5de8c9218ef9aa3ce1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Sat, 2 Aug 2025 20:23:25 +0900 Subject: [PATCH 057/135] Update application.yml --- src/main/resources/application.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7838626..975f23a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,5 +1,12 @@ server: port: 8082 + tomcat: + accesslog: + enabled: true + directory: /var/log/joycrew + prefix: access_log + suffix: .log + pattern: common spring: application: @@ -15,4 +22,4 @@ logging: pattern: console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" level: - com.joycrew.backend: INFO \ No newline at end of file + com.joycrew.backend: INFO From d16e60bb43c157dfbda446cccae0e7b3be7512bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Sat, 2 Aug 2025 20:23:38 +0900 Subject: [PATCH 058/135] Update application.yml --- src/main/resources/application.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7838626..975f23a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,5 +1,12 @@ server: port: 8082 + tomcat: + accesslog: + enabled: true + directory: /var/log/joycrew + prefix: access_log + suffix: .log + pattern: common spring: application: @@ -15,4 +22,4 @@ logging: pattern: console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" level: - com.joycrew.backend: INFO \ No newline at end of file + com.joycrew.backend: INFO From a2ce8629876e81fe58d21334c631d18303f16857 Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Sun, 3 Aug 2025 12:15:00 +0900 Subject: [PATCH 059/135] =?UTF-8?q?fix=20:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=EC=97=90=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=8F=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/joycrew/backend/dto/LoginResponse.java | 13 +++++++++---- .../joycrew/backend/dto/RecognitionRequest.java | 17 ++++++++++++++--- .../com/joycrew/backend/entity/enums/Tag.java | 12 ++++++++++++ .../joycrew/backend/service/AuthService.java | 12 +++++++++++- 4 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/joycrew/backend/entity/enums/Tag.java diff --git a/src/main/java/com/joycrew/backend/dto/LoginResponse.java b/src/main/java/com/joycrew/backend/dto/LoginResponse.java index f9a0d3d..2afd119 100644 --- a/src/main/java/com/joycrew/backend/dto/LoginResponse.java +++ b/src/main/java/com/joycrew/backend/dto/LoginResponse.java @@ -16,8 +16,13 @@ public record LoginResponse( @Schema(description = "사용자 이메일") String email, @Schema(description = "사용자 역할") - AdminLevel role -) { public static LoginResponse fail(String message) { - return new LoginResponse(null, message, null, null, null, null); -} + AdminLevel role, + @Schema(description = "보유 포인트") + Integer totalPoint, + @Schema(description = "프로필 이미지 URL") + String profileImageUrl +) { + public static LoginResponse fail(String message) { + return new LoginResponse(null, message, null, null, null, null, null, null); + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/RecognitionRequest.java b/src/main/java/com/joycrew/backend/dto/RecognitionRequest.java index ac5ad9e..78cfc86 100644 --- a/src/main/java/com/joycrew/backend/dto/RecognitionRequest.java +++ b/src/main/java/com/joycrew/backend/dto/RecognitionRequest.java @@ -1,11 +1,22 @@ package com.joycrew.backend.dto; +import com.joycrew.backend.entity.enums.Tag; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; +import java.util.List; public record RecognitionRequest( - @NotNull(message = "받는 사람 ID는 필수입니다.") Long receiverId, - @NotNull(message = "포인트는 필수입니다.") @Min(value = 1, message = "포인트는 1 이상이어야 합니다.") int points, - @Size(max = 255, message = "메시지는 255자를 초과할 수 없습니다.") String message + @NotNull(message = "받는 사람 ID는 필수입니다.") + Long receiverId, + + @NotNull(message = "포인트는 필수입니다.") + @Min(value = 1, message = "포인트는 1 이상이어야 합니다.") + int points, + + @Size(max = 255, message = "메시지는 255자를 초과할 수 없습니다.") + String message, + + @NotNull(message = "태그는 필수입니다.") + List tags ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/enums/Tag.java b/src/main/java/com/joycrew/backend/entity/enums/Tag.java new file mode 100644 index 0000000..fdef3a7 --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/enums/Tag.java @@ -0,0 +1,12 @@ +package com.joycrew.backend.entity.enums; + +public enum Tag { + CUSTOMERS, + FLEXIBILITY, + GOALS, + EXTRAORDINARY, + TEAMWORK, + INNOVATION, + SIMPLICITY, + DELIVER_RESULTS +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/AuthService.java b/src/main/java/com/joycrew/backend/service/AuthService.java index 881694d..1210dba 100644 --- a/src/main/java/com/joycrew/backend/service/AuthService.java +++ b/src/main/java/com/joycrew/backend/service/AuthService.java @@ -3,6 +3,8 @@ import com.joycrew.backend.dto.LoginRequest; import com.joycrew.backend.dto.LoginResponse; import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.repository.WalletRepository; import com.joycrew.backend.security.JwtUtil; import com.joycrew.backend.security.UserPrincipal; import jakarta.servlet.http.HttpServletRequest; @@ -25,6 +27,7 @@ public class AuthService { private final JwtUtil jwtUtil; private final AuthenticationManager authenticationManager; + private final WalletRepository walletRepository; @Transactional public LoginResponse login(LoginRequest request) { @@ -38,6 +41,11 @@ public LoginResponse login(LoginRequest request) { UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal(); Employee employee = userPrincipal.getEmployee(); + // 지갑 정보를 조회하여 보유 포인트를 가져옵니다. 지갑이 없으면 0을 반환합니다. + Integer totalPoint = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) + .map(Wallet::getBalance) + .orElse(0); + employee.updateLastLogin(); String accessToken = jwtUtil.generateToken(employee.getEmail()); @@ -48,7 +56,9 @@ public LoginResponse login(LoginRequest request) { employee.getEmployeeId(), employee.getEmployeeName(), employee.getEmail(), - employee.getRole() + employee.getRole(), + totalPoint, + employee.getProfileImageUrl() ); } catch (UsernameNotFoundException | BadCredentialsException e) { From fb33e92a0efa3549f76f148e313596bfa3205bd2 Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Sun, 3 Aug 2025 12:17:41 +0900 Subject: [PATCH 060/135] =?UTF-8?q?fix=20:=20=ED=8F=AC=EC=9D=B8=ED=8A=B8?= =?UTF-8?q?=20=EC=84=A0=EB=AC=BC=20API=EC=97=90=20=ED=83=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../joycrew/backend/entity/RewardPointTransaction.java | 9 +++++++++ .../com/joycrew/backend/service/RecognitionService.java | 1 + 2 files changed, 10 insertions(+) diff --git a/src/main/java/com/joycrew/backend/entity/RewardPointTransaction.java b/src/main/java/com/joycrew/backend/entity/RewardPointTransaction.java index 50fc2ab..72edac4 100644 --- a/src/main/java/com/joycrew/backend/entity/RewardPointTransaction.java +++ b/src/main/java/com/joycrew/backend/entity/RewardPointTransaction.java @@ -1,10 +1,12 @@ package com.joycrew.backend.entity; +import com.joycrew.backend.entity.enums.Tag; import com.joycrew.backend.entity.enums.TransactionType; import jakarta.persistence.*; import lombok.*; import java.time.LocalDateTime; +import java.util.List; @Entity @Table(name = "reward_point_transaction") @@ -36,6 +38,13 @@ public class RewardPointTransaction { @Enumerated(EnumType.STRING) private TransactionType type; + // 태그 저장을 위해 추가된 필드 + @ElementCollection(targetClass = Tag.class, fetch = FetchType.EAGER) + @CollectionTable(name = "transaction_tags", joinColumns = @JoinColumn(name = "transaction_id")) + @Enumerated(EnumType.STRING) + @Column(name = "tag", nullable = false) + private List tags; + @Column(nullable = false) private LocalDateTime transactionDate; diff --git a/src/main/java/com/joycrew/backend/service/RecognitionService.java b/src/main/java/com/joycrew/backend/service/RecognitionService.java index b99602c..2292216 100644 --- a/src/main/java/com/joycrew/backend/service/RecognitionService.java +++ b/src/main/java/com/joycrew/backend/service/RecognitionService.java @@ -44,6 +44,7 @@ public void sendRecognition(String senderEmail, RecognitionRequest request) { .pointAmount(request.points()) .message(request.message()) .type(TransactionType.AWARD_P2P) + .tags(request.tags()) .build(); transactionRepository.save(transaction); From a0393647b12065ddd043d5ed5c0351e40477b904 Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Sun, 3 Aug 2025 12:23:34 +0900 Subject: [PATCH 061/135] =?UTF-8?q?fix=20:=20=EC=A7=81=EC=9B=90=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=EC=97=90=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=95=20=EC=A0=95=EB=B3=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/EmployeeQueryController.java | 23 ++++--- .../backend/dto/PagedEmployeeResponse.java | 19 ++++++ .../backend/service/EmployeeQueryService.java | 62 +++++++++++++------ 3 files changed, 76 insertions(+), 28 deletions(-) create mode 100644 src/main/java/com/joycrew/backend/dto/PagedEmployeeResponse.java diff --git a/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java b/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java index d3111ad..c206fb8 100644 --- a/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java +++ b/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java @@ -1,19 +1,20 @@ package com.joycrew.backend.controller; import com.joycrew.backend.dto.EmployeeQueryResponse; +import com.joycrew.backend.dto.PagedEmployeeResponse; +import com.joycrew.backend.security.UserPrincipal; import com.joycrew.backend.service.EmployeeQueryService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import java.util.List; - @RestController @RequiredArgsConstructor @RequestMapping("/api/employee/query") @@ -24,11 +25,11 @@ public class EmployeeQueryController { @Operation( summary = "직원 목록 검색", - description = "이름, 이메일, 부서명을 기준으로 통합 검색을 수행합니다.", + description = "이름, 이메일, 부서명을 기준으로 통합 검색을 수행합니다. 검색 결과에서는 본인이 제외됩니다.", parameters = { @Parameter(name = "keyword", description = "검색 키워드", example = "김"), @Parameter(name = "page", description = "페이지 번호 (0부터 시작)", example = "0"), - @Parameter(name = "size", description = "페이지당 개수", example = "10") + @Parameter(name = "size", description = "페이지당 개수", example = "20") }, responses = { @ApiResponse( @@ -36,17 +37,19 @@ public class EmployeeQueryController { description = "직원 목록 조회 성공", content = @Content( mediaType = "application/json", - array = @ArraySchema(schema = @Schema(implementation = EmployeeQueryResponse.class)) + schema = @Schema(implementation = PagedEmployeeResponse.class) ) ) } ) @GetMapping - public List searchEmployees( + public ResponseEntity searchEmployees( @RequestParam(required = false) String keyword, @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size + @RequestParam(defaultValue = "20") int size, + @AuthenticationPrincipal UserPrincipal principal ) { - return employeeQueryService.getEmployees(keyword, page, size); + PagedEmployeeResponse response = employeeQueryService.getEmployees(keyword, page, size, principal.getEmployee().getEmployeeId()); + return ResponseEntity.ok(response); } -} +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/PagedEmployeeResponse.java b/src/main/java/com/joycrew/backend/dto/PagedEmployeeResponse.java new file mode 100644 index 0000000..9ec31f7 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/PagedEmployeeResponse.java @@ -0,0 +1,19 @@ +package com.joycrew.backend.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "페이징 처리된 직원 목록 응답 DTO") +public record PagedEmployeeResponse( + @Schema(description = "직원 정보 목록") + List employees, + + @Schema(description = "현재 페이지 번호 (0부터 시작)", example = "0") + int currentPage, + + @Schema(description = "전체 페이지 수", example = "10") + int totalPages, + + @Schema(description = "마지막 페이지 여부", example = "false") + boolean isLastPage +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java b/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java index dec355b..4fc3135 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java @@ -1,50 +1,76 @@ package com.joycrew.backend.service; import com.joycrew.backend.dto.EmployeeQueryResponse; +import com.joycrew.backend.dto.PagedEmployeeResponse; import com.joycrew.backend.entity.Employee; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import jakarta.persistence.TypedQuery; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import java.util.List; @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class EmployeeQueryService { @PersistenceContext private final EntityManager em; - public List getEmployees(String keyword, int page, int size) { - int offset = page * size; + public PagedEmployeeResponse getEmployees(String keyword, int page, int size, Long currentUserId) { + // WHERE 절과 파라미터 구성을 위한 기본 StringBuilder + StringBuilder whereClause = new StringBuilder(); + boolean hasKeyword = StringUtils.hasText(keyword); - StringBuilder jpql = new StringBuilder("SELECT e FROM Employee e " + - "JOIN FETCH e.company c " + - "LEFT JOIN FETCH e.department d "); - - if (StringUtils.hasText(keyword)) { - jpql.append("WHERE LOWER(e.employeeName) LIKE :keyword ") + if (hasKeyword) { + whereClause.append("WHERE (LOWER(e.employeeName) LIKE :keyword ") .append("OR LOWER(e.email) LIKE :keyword ") - .append("OR LOWER(d.name) LIKE :keyword "); + .append("OR LOWER(d.name) LIKE :keyword) "); } - // ✅ 이름 오름차순 정렬로 고정 - jpql.append("ORDER BY e.employeeName ASC"); + // 본인 제외 조건 추가 + whereClause.append(hasKeyword ? "AND " : "WHERE "); + whereClause.append("e.id != :currentUserId "); - TypedQuery query = em.createQuery(jpql.toString(), Employee.class); + // 1. 전체 카운트를 가져오는 쿼리 실행 + String countJpql = "SELECT COUNT(e) FROM Employee e LEFT JOIN e.department d " + whereClause; + TypedQuery countQuery = em.createQuery(countJpql, Long.class); + countQuery.setParameter("currentUserId", currentUserId); + if (hasKeyword) { + countQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); + } + long totalCount = countQuery.getSingleResult(); + int totalPages = (int) Math.ceil((double) totalCount / size); - if (StringUtils.hasText(keyword)) { - query.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); + // 2. 실제 데이터를 가져오는 쿼리 실행 + String dataJpql = "SELECT e FROM Employee e " + + "JOIN FETCH e.company c " + + "LEFT JOIN FETCH e.department d " + + whereClause + + "ORDER BY e.employeeName ASC"; + TypedQuery dataQuery = em.createQuery(dataJpql, Employee.class); + dataQuery.setParameter("currentUserId", currentUserId); + if (hasKeyword) { + dataQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); } - query.setFirstResult(offset); - query.setMaxResults(size); + dataQuery.setFirstResult(page * size); + dataQuery.setMaxResults(size); - return query.getResultList().stream() + List employees = dataQuery.getResultList().stream() .map(EmployeeQueryResponse::from) .toList(); + + // 3. PagedEmployeeResponse 로 감싸서 반환 + return new PagedEmployeeResponse( + employees, + page, + totalPages, + page >= totalPages - 1 + ); } -} +} \ No newline at end of file From fbe0a35cc3b029a1e95dee9af2f9302700211e7f Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Sun, 3 Aug 2025 12:52:59 +0900 Subject: [PATCH 062/135] =?UTF-8?q?fix=20:=20=EC=9D=B4=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=EC=9D=84=20=ED=86=B5=ED=95=9C=20=EB=B9=84?= =?UTF-8?q?=EB=B0=80=EB=B2=88=ED=98=B8=20=EC=9E=AC=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +-- build.gradle | 1 + .../joycrew/backend/config/AsyncConfig.java | 24 +++++++++++++ .../backend/config/SecurityConfig.java | 2 ++ .../backend/controller/AuthController.java | 32 +++++++++-------- .../dto/PasswordResetConfirmRequest.java | 18 ++++++++++ .../backend/dto/PasswordResetRequest.java | 13 +++++++ .../com/joycrew/backend/security/JwtUtil.java | 17 +++++++-- .../joycrew/backend/service/AuthService.java | 35 +++++++++++++++++-- .../joycrew/backend/service/EmailService.java | 35 +++++++++++++++++++ src/main/resources/application-dev.yml | 28 --------------- src/main/resources/application-prod.yml | 13 ++++++- 12 files changed, 171 insertions(+), 51 deletions(-) create mode 100644 src/main/java/com/joycrew/backend/config/AsyncConfig.java create mode 100644 src/main/java/com/joycrew/backend/dto/PasswordResetConfirmRequest.java create mode 100644 src/main/java/com/joycrew/backend/dto/PasswordResetRequest.java create mode 100644 src/main/java/com/joycrew/backend/service/EmailService.java delete mode 100644 src/main/resources/application-dev.yml diff --git a/.gitignore b/.gitignore index 920cd49..13176da 100644 --- a/.gitignore +++ b/.gitignore @@ -57,9 +57,7 @@ Thumbs.db .env.development.local .env.test.local .env.local -config/application-local.yml -config/application-dev.yml -config/application-prod.yml +*.application-dev.yml # Spring Boot / Maven (if also used) /target/ diff --git a/build.gradle b/build.gradle index 40fc968..7389323 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' + implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' diff --git a/src/main/java/com/joycrew/backend/config/AsyncConfig.java b/src/main/java/com/joycrew/backend/config/AsyncConfig.java new file mode 100644 index 0000000..c479487 --- /dev/null +++ b/src/main/java/com/joycrew/backend/config/AsyncConfig.java @@ -0,0 +1,24 @@ +package com.joycrew.backend.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean(name = "taskExecutor") + public Executor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(10); + executor.setQueueCapacity(25); + executor.setThreadNamePrefix("Async-"); + executor.initialize(); + return executor; + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/config/SecurityConfig.java b/src/main/java/com/joycrew/backend/config/SecurityConfig.java index 8b12c0d..b941c20 100644 --- a/src/main/java/com/joycrew/backend/config/SecurityConfig.java +++ b/src/main/java/com/joycrew/backend/config/SecurityConfig.java @@ -45,6 +45,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers( "/h2-console/**", "/api/auth/login", + "/api/auth/password-reset/request", // 접근 허용 추가 + "/api/auth/password-reset/confirm", // 접근 허용 추가 "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html" diff --git a/src/main/java/com/joycrew/backend/controller/AuthController.java b/src/main/java/com/joycrew/backend/controller/AuthController.java index b984c50..0015c5d 100644 --- a/src/main/java/com/joycrew/backend/controller/AuthController.java +++ b/src/main/java/com/joycrew/backend/controller/AuthController.java @@ -1,8 +1,6 @@ package com.joycrew.backend.controller; -import com.joycrew.backend.dto.LoginRequest; -import com.joycrew.backend.dto.LoginResponse; -import com.joycrew.backend.dto.SuccessResponse; +import com.joycrew.backend.dto.*; import com.joycrew.backend.service.AuthService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -10,14 +8,10 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.boot.CommandLineRunner; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Profile; import org.springframework.http.ResponseEntity; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.bind.annotation.*; -@Tag(name = "인증", description = "로그인 관련 API") +@Tag(name = "인증", description = "로그인 및 비밀번호 재설정 관련 API") @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor @@ -41,12 +35,20 @@ public ResponseEntity logout(HttpServletRequest request) { return ResponseEntity.ok(new SuccessResponse("로그아웃 되었습니다.")); } - @Bean - public CommandLineRunner showPasswordHash(PasswordEncoder passwordEncoder) { - return args -> { - String rawPassword = "1234"; - String encoded = passwordEncoder.encode(rawPassword); - System.out.println("비밀번호 1234 해시값: " + encoded); - }; + @Operation(summary = "비밀번호 재설정 요청 (이메일 발송)", description = "사용자 이메일로 비밀번호를 재설정할 수 있는 매직 링크를 보냅니다.") + @ApiResponse(responseCode = "200", description = "요청이 성공적으로 처리되었습니다. (이메일 존재 여부와 상관없이 동일한 응답)") + @PostMapping("/password-reset/request") + public ResponseEntity requestPasswordReset(@RequestBody @Valid PasswordResetRequest request) { + authService.requestPasswordReset(request.email()); + return ResponseEntity.ok(new SuccessResponse("비밀번호 재설정 이메일이 요청되었습니다. 이메일을 확인해주세요.")); + } + + @Operation(summary = "비밀번호 재설정 확인", description = "이메일로 받은 토큰과 새로운 비밀번호로 비밀번호를 최종 변경합니다.") + @ApiResponse(responseCode = "200", description = "비밀번호가 성공적으로 변경되었습니다.") + @ApiResponse(responseCode = "400", description = "토큰이 유효하지 않거나 만료되었습니다.") + @PostMapping("/password-reset/confirm") + public ResponseEntity confirmPasswordReset(@RequestBody @Valid PasswordResetConfirmRequest request) { + authService.confirmPasswordReset(request.token(), request.newPassword()); + return ResponseEntity.ok(new SuccessResponse("비밀번호가 성공적으로 변경되었습니다.")); } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/PasswordResetConfirmRequest.java b/src/main/java/com/joycrew/backend/dto/PasswordResetConfirmRequest.java new file mode 100644 index 0000000..41cb84c --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/PasswordResetConfirmRequest.java @@ -0,0 +1,18 @@ +package com.joycrew.backend.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record PasswordResetConfirmRequest( + @Schema(description = "이메일로 받은 비밀번호 재설정 토큰") + @NotBlank(message = "토큰은 필수입니다.") + String token, + + @Schema(description = "새로운 비밀번호") + @NotBlank(message = "새로운 비밀번호는 필수입니다.") + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&])[A-Za-z\\d@$!%*#?&]{8,20}$", + message = "비밀번호는 영문, 숫자, 특수문자를 포함하여 8자 이상 20자 이하여야 합니다.") + String newPassword +) { +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/PasswordResetRequest.java b/src/main/java/com/joycrew/backend/dto/PasswordResetRequest.java new file mode 100644 index 0000000..a0f7c86 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/PasswordResetRequest.java @@ -0,0 +1,13 @@ +package com.joycrew.backend.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record PasswordResetRequest( + @Schema(description = "비밀번호를 재설정할 계정의 이메일", example = "user@example.com") + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "유효한 이메일 형식이 아닙니다.") + String email +) { +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/security/JwtUtil.java b/src/main/java/com/joycrew/backend/security/JwtUtil.java index 6ed88b9..216a7a4 100644 --- a/src/main/java/com/joycrew/backend/security/JwtUtil.java +++ b/src/main/java/com/joycrew/backend/security/JwtUtil.java @@ -24,11 +24,24 @@ private SecretKey getSigningKey() { return Keys.hmacShaKeyFor(keyBytes); } + /** + * 기본 만료 시간(1시간)으로 JWT 토큰을 생성합니다. (로그인용) + */ public String generateToken(String email) { + return generateToken(email, expirationTime); + } + + /** + * 사용자 정의 만료 시간으로 JWT 토큰을 생성합니다. (비밀번호 재설정 등) + */ + public String generateToken(String email, long customExpirationMs) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + customExpirationMs); + return Jwts.builder() .setSubject(email) - .setIssuedAt(new Date()) - .setExpiration(new Date(System.currentTimeMillis() + expirationTime)) + .setIssuedAt(now) + .setExpiration(expiryDate) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } diff --git a/src/main/java/com/joycrew/backend/service/AuthService.java b/src/main/java/com/joycrew/backend/service/AuthService.java index 1210dba..9f2a390 100644 --- a/src/main/java/com/joycrew/backend/service/AuthService.java +++ b/src/main/java/com/joycrew/backend/service/AuthService.java @@ -4,9 +4,12 @@ import com.joycrew.backend.dto.LoginResponse; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.exception.UserNotFoundException; +import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.WalletRepository; import com.joycrew.backend.security.JwtUtil; import com.joycrew.backend.security.UserPrincipal; +import io.jsonwebtoken.JwtException; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; @@ -16,6 +19,7 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -24,10 +28,14 @@ public class AuthService { private static final Logger log = LoggerFactory.getLogger(AuthService.class); + private static final long PASSWORD_RESET_EXPIRATION_MS = 15 * 60 * 1000; // 15분 private final JwtUtil jwtUtil; private final AuthenticationManager authenticationManager; private final WalletRepository walletRepository; + private final EmployeeRepository employeeRepository; + private final PasswordEncoder passwordEncoder; + private final EmailService emailService; @Transactional public LoginResponse login(LoginRequest request) { @@ -41,7 +49,6 @@ public LoginResponse login(LoginRequest request) { UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal(); Employee employee = userPrincipal.getEmployee(); - // 지갑 정보를 조회하여 보유 포인트를 가져옵니다. 지갑이 없으면 0을 반환합니다. Integer totalPoint = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) .map(Wallet::getBalance) .orElse(0); @@ -72,7 +79,31 @@ public void logout(HttpServletRequest request) { if (authHeader != null && authHeader.startsWith("Bearer ")) { String jwt = authHeader.substring(7); log.info("Logout request received. Token blacklisting can be implemented here."); - // TODO: Redis 등을 이용한 토큰 블랙리스트 처리 로직 추가 } } + + @Transactional(readOnly = true) + public void requestPasswordReset(String email) { + employeeRepository.findByEmail(email).ifPresent(employee -> { + String token = jwtUtil.generateToken(email, PASSWORD_RESET_EXPIRATION_MS); + emailService.sendPasswordResetEmail(email, token); + log.info("비밀번호 재설정 요청 처리: {}", email); + }); + } + + @Transactional + public void confirmPasswordReset(String token, String newPassword) { + String email; + try { + email = jwtUtil.getEmailFromToken(token); + } catch (JwtException e) { + throw new BadCredentialsException("유효하지 않거나 만료된 토큰입니다.", e); + } + + Employee employee = employeeRepository.findByEmail(email) + .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다.")); + + employee.changePassword(newPassword, passwordEncoder); + log.info("비밀번호 재설정 완료: {}", email); + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/EmailService.java b/src/main/java/com/joycrew/backend/service/EmailService.java new file mode 100644 index 0000000..70f6276 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/EmailService.java @@ -0,0 +1,35 @@ +package com.joycrew.backend.service; + +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class EmailService { + + private static final Logger log = LoggerFactory.getLogger(EmailService.class); + private final JavaMailSender mailSender; + + @Async("taskExecutor") // 비동기 실행을 위해 Async 어노테이션 추가 + public void sendPasswordResetEmail(String toEmail, String token) { + // TODO: 프론트엔드 URL은 실제 환경에 맞게 수정해야 합니다. + String frontendUrl = "https://joycrew.co.kr/reset-password?token=" + token; + + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(toEmail); + message.setSubject("[JoyCrew] 비밀번호 재설정 안내"); + message.setText("비밀번호를 재설정하려면 아래 링크를 클릭하세요. (링크는 15분간 유효합니다)\n\n" + frontendUrl); + + try { + mailSender.send(message); + log.info("비밀번호 재설정 이메일 발송 완료: {}", toEmail); + } catch (Exception e) { + log.error("비밀번호 재설정 이메일 발송 실패: {}", toEmail, e); + } + } +} \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml deleted file mode 100644 index d36ab5d..0000000 --- a/src/main/resources/application-dev.yml +++ /dev/null @@ -1,28 +0,0 @@ -spring: - datasource: - url: jdbc:h2:mem:testdb - driver-class-name: org.h2.Driver - username: sa - password: - h2: - console: - enabled: true - jpa: - hibernate: - ddl-auto: create - show-sql: false - -jwt: - secret: dev-test-secret-key-for-joycrew-backend-at-least-256-bits-long! - -logging: - level: - org.hibernate.SQL: DEBUG - org.hibernate.type.descriptor.sql: TRACE - com.joycrew.backend: DEBUG - -springdoc: - api-docs: - path: /v3/api-docs - swagger-ui: - path: /swagger-ui.html diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index bafd1e4..bdf4a04 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -8,6 +8,17 @@ spring: hibernate: ddl-auto: update show-sql: false + mail: + host: smtp.gmail.com + port: 587 + username: ${GMAIL_USERNAME} + password: ${GMAIL_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true jwt: secret: ${JWT_SECRET_KEY} expiration-ms: 3600000 @@ -25,4 +36,4 @@ springdoc: api-docs: path: /v3/api-docs swagger-ui: - path: /swagger-ui.html + path: /swagger-ui.html \ No newline at end of file From b82a9ff2766955248354451f4d537fa0d74835fb Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Sun, 3 Aug 2025 12:55:30 +0900 Subject: [PATCH 063/135] =?UTF-8?q?hotfix=20:=20=EC=A7=81=EC=9B=90=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EA=B2=80=EC=83=89=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EC=97=90=20=EA=B0=81=20=EC=A7=81=EC=9B=90=EC=9D=98=20=EA=B3=A0?= =?UTF-8?q?=EC=9C=A0=20ID=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/dto/EmployeeQueryResponse.java | 41 ++++++++++++------- src/main/resources/application-dev.yml | 39 ++++++++++++++++++ 2 files changed, 65 insertions(+), 15 deletions(-) create mode 100644 src/main/resources/application-dev.yml diff --git a/src/main/java/com/joycrew/backend/dto/EmployeeQueryResponse.java b/src/main/java/com/joycrew/backend/dto/EmployeeQueryResponse.java index 163dc06..561fd40 100644 --- a/src/main/java/com/joycrew/backend/dto/EmployeeQueryResponse.java +++ b/src/main/java/com/joycrew/backend/dto/EmployeeQueryResponse.java @@ -2,22 +2,33 @@ import com.joycrew.backend.entity.Employee; import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Builder; -@Builder -@Schema(description = "직원 검색 응답 DTO") +@Schema(description = "직원 검색 결과 응답 DTO") public record EmployeeQueryResponse( - @Schema(description = "프로필 이미지 URL", example = "https://cdn.joycrew.com/profile/user123.jpg") String profileImageUrl, - @Schema(description = "직원 이름", example = "김여은") String employeeName, - @Schema(description = "부서명", example = "인사팀") String departmentName, - @Schema(description = "직책", example = "사원") String position + @Schema(description = "직원 고유 ID", example = "1") + Long employeeId, + + @Schema(description = "프로필 이미지 URL", example = "https://cdn.joycrew.com/profile/user123.jpg") + String profileImageUrl, + + @Schema(description = "직원 이름", example = "김조이") + String employeeName, + + @Schema(description = "부서명", example = "개발팀") + String departmentName, + + @Schema(description = "직책", example = "백엔드 개발자") + String position ) { - public static EmployeeQueryResponse from(Employee e) { - return EmployeeQueryResponse.builder() - .profileImageUrl(e.getProfileImageUrl()) - .employeeName(e.getEmployeeName()) - .departmentName(e.getDepartment() != null ? e.getDepartment().getName() : null) - .position(e.getPosition()) - .build(); + public static EmployeeQueryResponse from(Employee employee) { + String departmentName = (employee.getDepartment() != null) ? employee.getDepartment().getName() : null; + + return new EmployeeQueryResponse( + employee.getEmployeeId(), + employee.getProfileImageUrl(), + employee.getEmployeeName(), + departmentName, + employee.getPosition() + ); } -} +} \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..46f2c7d --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,39 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb + driver-class-name: org.h2.Driver + username: sa + password: + h2: + console: + enabled: true + jpa: + hibernate: + ddl-auto: create + show-sql: false + mail: + host: smtp.gmail.com + port: 587 + username: joycrew.team@gmail.com + password: mock-gmail-app-password + properties: + mail: + smtp: + auth: true + starttls: + enable: true + +jwt: + secret: dev-test-secret-key-for-joycrew-backend-at-least-256-bits-long! + +logging: + level: + org.hibernate.SQL: DEBUG + org.hibernate.type.descriptor.sql: TRACE + com.joycrew.backend: DEBUG + +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html \ No newline at end of file From d2956db127101475fc089e8dbce3f88aba729688 Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Sun, 3 Aug 2025 12:57:11 +0900 Subject: [PATCH 064/135] hotfix : remove dev.yml --- .gitignore | 2 +- src/main/resources/application-dev.yml | 39 -------------------------- 2 files changed, 1 insertion(+), 40 deletions(-) delete mode 100644 src/main/resources/application-dev.yml diff --git a/.gitignore b/.gitignore index 13176da..3507da3 100644 --- a/.gitignore +++ b/.gitignore @@ -57,7 +57,7 @@ Thumbs.db .env.development.local .env.test.local .env.local -*.application-dev.yml +src/main/resources/application-dev.yml # Spring Boot / Maven (if also used) /target/ diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml deleted file mode 100644 index 46f2c7d..0000000 --- a/src/main/resources/application-dev.yml +++ /dev/null @@ -1,39 +0,0 @@ -spring: - datasource: - url: jdbc:h2:mem:testdb - driver-class-name: org.h2.Driver - username: sa - password: - h2: - console: - enabled: true - jpa: - hibernate: - ddl-auto: create - show-sql: false - mail: - host: smtp.gmail.com - port: 587 - username: joycrew.team@gmail.com - password: mock-gmail-app-password - properties: - mail: - smtp: - auth: true - starttls: - enable: true - -jwt: - secret: dev-test-secret-key-for-joycrew-backend-at-least-256-bits-long! - -logging: - level: - org.hibernate.SQL: DEBUG - org.hibernate.type.descriptor.sql: TRACE - com.joycrew.backend: DEBUG - -springdoc: - api-docs: - path: /v3/api-docs - swagger-ui: - path: /swagger-ui.html \ No newline at end of file From a1c07eb1af3547fea9804c106903cdcfb4f4597f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Sun, 3 Aug 2025 13:20:07 +0900 Subject: [PATCH 065/135] Update SecurityConfig.java --- src/main/java/com/joycrew/backend/config/SecurityConfig.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/joycrew/backend/config/SecurityConfig.java b/src/main/java/com/joycrew/backend/config/SecurityConfig.java index b941c20..818fb44 100644 --- a/src/main/java/com/joycrew/backend/config/SecurityConfig.java +++ b/src/main/java/com/joycrew/backend/config/SecurityConfig.java @@ -43,6 +43,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .frameOptions(frameOptions -> frameOptions.disable())) .authorizeHttpRequests(auth -> auth .requestMatchers( + "/", "/h2-console/**", "/api/auth/login", "/api/auth/password-reset/request", // 접근 허용 추가 @@ -106,4 +107,4 @@ public AuthenticationManager authenticationManager(PasswordEncoder passwordEncod authenticationProvider.setPasswordEncoder(passwordEncoder); return new ProviderManager(authenticationProvider); } -} \ No newline at end of file +} From 3ce80fa06c036d0f5d5242f817b1769f8c7ab386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Sun, 3 Aug 2025 17:46:50 +0900 Subject: [PATCH 066/135] Update SecurityConfig.java --- src/main/java/com/joycrew/backend/config/SecurityConfig.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/joycrew/backend/config/SecurityConfig.java b/src/main/java/com/joycrew/backend/config/SecurityConfig.java index 7841588..c4ba712 100644 --- a/src/main/java/com/joycrew/backend/config/SecurityConfig.java +++ b/src/main/java/com/joycrew/backend/config/SecurityConfig.java @@ -91,6 +91,7 @@ public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("http://localhost:3000"); config.addAllowedOriginPattern("http://localhost:5173"); + config.addAllowedOriginPattern("https://www.joycrew.co.kr"); config.addAllowedOriginPattern("https://joycrew.co.kr"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); From 3690f4e442c4f388f39d79e0dec696d60e1ce07e Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Mon, 4 Aug 2025 16:51:18 +0900 Subject: [PATCH 067/135] =?UTF-8?q?test=20:=20test=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/EmployeeQueryController.java | 1 - .../backend/dto/UserProfileResponse.java | 2 +- .../com/joycrew/backend/entity/Employee.java | 1 - .../com/joycrew/backend/security/JwtUtil.java | 6 - .../backend/service/AdminEmployeeService.java | 21 +-- .../joycrew/backend/service/AuthService.java | 2 +- .../joycrew/backend/service/EmailService.java | 2 +- .../backend/service/EmployeeQueryService.java | 5 - .../config/TestUserDetailsService.java | 5 +- .../AdminEmployeeControllerTest.java | 24 +-- .../controller/AuthControllerTest.java | 53 ++---- .../EmployeeQueryControllerTest.java | 69 ++++---- .../controller/RecognitionControllerTest.java | 87 ++++++++++ .../controller/UserControllerTest.java | 3 +- .../repository/EmployeeRepositoryTest.java | 77 --------- .../repository/WalletRepositoryTest.java | 85 --------- .../security/WithMockUserPrincipal.java | 2 + ...ckUserPrincipalSecurityContextFactory.java | 12 +- .../service/AdminEmployeeServiceTest.java | 161 ++++++++++++++++++ .../service/AuthServiceIntegrationTest.java | 5 +- .../backend/service/AuthServiceTest.java | 99 +++++++++-- .../backend/service/EmailServiceTest.java | 46 +++++ .../service/EmployeeQueryServiceTest.java | 66 +++++++ .../EmployeeServiceIntegrationTest.java | 3 +- .../backend/service/EmployeeServiceTest.java | 48 +++++- .../service/RecognitionServiceTest.java | 103 +++++++++++ .../backend/service/WalletServiceTest.java | 65 +++++++ 27 files changed, 752 insertions(+), 301 deletions(-) create mode 100644 src/test/java/com/joycrew/backend/controller/RecognitionControllerTest.java delete mode 100644 src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java delete mode 100644 src/test/java/com/joycrew/backend/repository/WalletRepositoryTest.java create mode 100644 src/test/java/com/joycrew/backend/service/AdminEmployeeServiceTest.java create mode 100644 src/test/java/com/joycrew/backend/service/EmailServiceTest.java create mode 100644 src/test/java/com/joycrew/backend/service/EmployeeQueryServiceTest.java create mode 100644 src/test/java/com/joycrew/backend/service/RecognitionServiceTest.java create mode 100644 src/test/java/com/joycrew/backend/service/WalletServiceTest.java diff --git a/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java b/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java index c206fb8..43579ed 100644 --- a/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java +++ b/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java @@ -1,6 +1,5 @@ package com.joycrew.backend.controller; -import com.joycrew.backend.dto.EmployeeQueryResponse; import com.joycrew.backend.dto.PagedEmployeeResponse; import com.joycrew.backend.security.UserPrincipal; import com.joycrew.backend.service.EmployeeQueryService; diff --git a/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java b/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java index 426af68..6ac8240 100644 --- a/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java +++ b/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java @@ -23,7 +23,7 @@ public static UserProfileResponse from(Employee employee, Wallet wallet) { employee.getEmployeeId(), employee.getEmployeeName(), employee.getEmail(), - employee.getProfileImageUrl(), // ✅ 프로필 이미지 추가 + employee.getProfileImageUrl(), wallet.getBalance(), wallet.getGiftablePoint(), employee.getRole(), diff --git a/src/main/java/com/joycrew/backend/entity/Employee.java b/src/main/java/com/joycrew/backend/entity/Employee.java index 2714133..db31c74 100644 --- a/src/main/java/com/joycrew/backend/entity/Employee.java +++ b/src/main/java/com/joycrew/backend/entity/Employee.java @@ -99,7 +99,6 @@ protected void onUpdate() { this.updatedAt = LocalDateTime.now(); } - // UserDetails 구현 메서드들... @Override public Collection getAuthorities() { return Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + this.role)); diff --git a/src/main/java/com/joycrew/backend/security/JwtUtil.java b/src/main/java/com/joycrew/backend/security/JwtUtil.java index 216a7a4..388e87d 100644 --- a/src/main/java/com/joycrew/backend/security/JwtUtil.java +++ b/src/main/java/com/joycrew/backend/security/JwtUtil.java @@ -24,16 +24,10 @@ private SecretKey getSigningKey() { return Keys.hmacShaKeyFor(keyBytes); } - /** - * 기본 만료 시간(1시간)으로 JWT 토큰을 생성합니다. (로그인용) - */ public String generateToken(String email) { return generateToken(email, expirationTime); } - /** - * 사용자 정의 만료 시간으로 JWT 토큰을 생성합니다. (비밀번호 재설정 등) - */ public String generateToken(String email, long customExpirationMs) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + customExpirationMs); diff --git a/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java b/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java index 46fc94c..f1b6a88 100644 --- a/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java +++ b/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java @@ -78,7 +78,7 @@ public void registerEmployeesFromCsv(MultipartFile file) { } String[] tokens = line.split(","); - if (tokens.length < 6) { // 최소 6개 필드 필요 (level은 optional) + if (tokens.length < 6) { log.warn("누락된 필드가 있는 행 건너뜀: {}", line); continue; } @@ -87,13 +87,13 @@ public void registerEmployeesFromCsv(MultipartFile file) { AdminLevel adminLevel = parseAdminLevel(tokens.length > 6 ? tokens[6].trim() : "EMPLOYEE"); EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( - tokens[0].trim(), // name - tokens[1].trim(), // email - tokens[2].trim(), // password - tokens[3].trim(), // companyName - tokens[4].trim().isBlank() ? null : tokens[4].trim(), // departmentName (nullable) - tokens[5].trim(), // position - adminLevel // level + tokens[0].trim(), + tokens[1].trim(), + tokens[2].trim(), + tokens[3].trim(), + tokens[4].trim().isBlank() ? null : tokens[4].trim(), + tokens[5].trim(), + adminLevel ); registerEmployee(request); } catch (Exception e) { @@ -106,12 +106,9 @@ public void registerEmployeesFromCsv(MultipartFile file) { } } - /** - * 문자열을 AdminLevel enum으로 변환 - */ private AdminLevel parseAdminLevel(String level) { if (level == null || level.isBlank()) { - return AdminLevel.EMPLOYEE; // 기본값 + return AdminLevel.EMPLOYEE; } try { diff --git a/src/main/java/com/joycrew/backend/service/AuthService.java b/src/main/java/com/joycrew/backend/service/AuthService.java index 9f2a390..4563103 100644 --- a/src/main/java/com/joycrew/backend/service/AuthService.java +++ b/src/main/java/com/joycrew/backend/service/AuthService.java @@ -28,7 +28,7 @@ public class AuthService { private static final Logger log = LoggerFactory.getLogger(AuthService.class); - private static final long PASSWORD_RESET_EXPIRATION_MS = 15 * 60 * 1000; // 15분 + private static final long PASSWORD_RESET_EXPIRATION_MS = 15 * 60 * 1000; private final JwtUtil jwtUtil; private final AuthenticationManager authenticationManager; diff --git a/src/main/java/com/joycrew/backend/service/EmailService.java b/src/main/java/com/joycrew/backend/service/EmailService.java index 70f6276..608c1f8 100644 --- a/src/main/java/com/joycrew/backend/service/EmailService.java +++ b/src/main/java/com/joycrew/backend/service/EmailService.java @@ -15,7 +15,7 @@ public class EmailService { private static final Logger log = LoggerFactory.getLogger(EmailService.class); private final JavaMailSender mailSender; - @Async("taskExecutor") // 비동기 실행을 위해 Async 어노테이션 추가 + @Async("taskExecutor") public void sendPasswordResetEmail(String toEmail, String token) { // TODO: 프론트엔드 URL은 실제 환경에 맞게 수정해야 합니다. String frontendUrl = "https://joycrew.co.kr/reset-password?token=" + token; diff --git a/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java b/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java index 4fc3135..2fb15c2 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java @@ -22,7 +22,6 @@ public class EmployeeQueryService { private final EntityManager em; public PagedEmployeeResponse getEmployees(String keyword, int page, int size, Long currentUserId) { - // WHERE 절과 파라미터 구성을 위한 기본 StringBuilder StringBuilder whereClause = new StringBuilder(); boolean hasKeyword = StringUtils.hasText(keyword); @@ -32,11 +31,9 @@ public PagedEmployeeResponse getEmployees(String keyword, int page, int size, Lo .append("OR LOWER(d.name) LIKE :keyword) "); } - // 본인 제외 조건 추가 whereClause.append(hasKeyword ? "AND " : "WHERE "); whereClause.append("e.id != :currentUserId "); - // 1. 전체 카운트를 가져오는 쿼리 실행 String countJpql = "SELECT COUNT(e) FROM Employee e LEFT JOIN e.department d " + whereClause; TypedQuery countQuery = em.createQuery(countJpql, Long.class); countQuery.setParameter("currentUserId", currentUserId); @@ -46,7 +43,6 @@ public PagedEmployeeResponse getEmployees(String keyword, int page, int size, Lo long totalCount = countQuery.getSingleResult(); int totalPages = (int) Math.ceil((double) totalCount / size); - // 2. 실제 데이터를 가져오는 쿼리 실행 String dataJpql = "SELECT e FROM Employee e " + "JOIN FETCH e.company c " + "LEFT JOIN FETCH e.department d " + @@ -65,7 +61,6 @@ public PagedEmployeeResponse getEmployees(String keyword, int page, int size, Lo .map(EmployeeQueryResponse::from) .toList(); - // 3. PagedEmployeeResponse 로 감싸서 반환 return new PagedEmployeeResponse( employees, page, diff --git a/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java b/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java index 81dc419..fcc3668 100644 --- a/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java +++ b/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java @@ -1,6 +1,7 @@ package com.joycrew.backend.config; import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.enums.AdminLevel; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -19,7 +20,7 @@ public TestUserDetailsService() { .employeeId(1L) .email("testuser@joycrew.com") .employeeName("테스트유저") - .role(UserRole.EMPLOYEE) + .role(AdminLevel.EMPLOYEE) .status("ACTIVE") .passwordHash("{noop}password") .build()); @@ -28,7 +29,7 @@ public TestUserDetailsService() { .employeeId(99L) .email("nowallet@joycrew.com") .employeeName("지갑없음") - .role(UserRole.EMPLOYEE) + .role(AdminLevel.EMPLOYEE) .status("ACTIVE") .passwordHash("{noop}password") .build()); diff --git a/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java b/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java index c8e5f75..9e388ab 100644 --- a/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java @@ -5,6 +5,7 @@ import com.joycrew.backend.entity.Company; import com.joycrew.backend.entity.Department; import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.enums.AdminLevel; import com.joycrew.backend.service.AdminEmployeeService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -38,18 +39,17 @@ class AdminEmployeeControllerTest { @WithMockUser(roles = "HR_ADMIN") @DisplayName("POST /api/admin/employees - 직원 등록 성공") void registerEmployee_success() throws Exception { - // Given - 요청 DTO (회사명/부서명 기반) EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( - "김여은", // name - "kye02@example.com", // email - "password123!", // initialPassword - "조이크루", // companyName - "인사팀", // departmentName - "사원", // position - UserRole.EMPLOYEE // role + "김여은", + "kye02@example.com", + "password123!", + "조이크루", + "인사팀", + "사원", + AdminLevel.EMPLOYEE ); - // Given - 서비스가 반환할 Employee mock 객체 + // Given Employee mockEmployee = Employee.builder() .employeeId(1L) .employeeName("김여은") @@ -57,13 +57,13 @@ void registerEmployee_success() throws Exception { .company(Company.builder().companyId(1L).companyName("조이크루").build()) .department(Department.builder().departmentId(1L).name("인사팀").build()) .position("사원") - .role(UserRole.EMPLOYEE) + .role(AdminLevel.EMPLOYEE) .build(); when(adminEmployeeService.registerEmployee(any(EmployeeRegistrationRequest.class))) .thenReturn(mockEmployee); - // When & Then - 요청 수행 및 응답 검증 + // When & Then mockMvc.perform(post("/api/admin/employees") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) @@ -75,7 +75,7 @@ void registerEmployee_success() throws Exception { @WithMockUser(roles = "HR_ADMIN") @DisplayName("POST /api/admin/employees/bulk - 직원 일괄 등록 성공") void registerEmployeesFromCsv_success() throws Exception { - // Given: 예제 CSV 내용 + // Given String csvContent = """ name,email,initialPassword,companyName,departmentName,position,role 김여은,kye02@example.com,password123,조이크루,인사팀,사원,EMPLOYEE diff --git a/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java b/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java index 9b35e01..174446a 100644 --- a/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java @@ -1,25 +1,20 @@ package com.joycrew.backend.controller; import com.fasterxml.jackson.databind.ObjectMapper; -import com.joycrew.backend.dto.LoginRequest; -import com.joycrew.backend.dto.LoginResponse; +import com.joycrew.backend.dto.*; +import com.joycrew.backend.entity.enums.AdminLevel; import com.joycrew.backend.exception.GlobalExceptionHandler; import com.joycrew.backend.service.AuthService; import jakarta.servlet.http.HttpServletRequest; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; -import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.security.crypto.password.NoOpPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.web.servlet.MockMvc; import static org.mockito.ArgumentMatchers.any; @@ -30,7 +25,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(controllers = AuthController.class, - excludeAutoConfiguration = {SecurityAutoConfiguration.class}) + excludeAutoConfiguration = SecurityAutoConfiguration.class) @Import(GlobalExceptionHandler.class) class AuthControllerTest { @@ -42,25 +37,25 @@ class AuthControllerTest { @MockBean private AuthService authService; - @TestConfiguration - static class TestConfig { - @Bean - public PasswordEncoder passwordEncoder() { - return NoOpPasswordEncoder.getInstance(); // 테스트용 - } - } - @Test @DisplayName("POST /api/auth/login - 로그인 성공") void login_Success() throws Exception { LoginRequest request = new LoginRequest("test@joycrew.com", "password123!"); LoginResponse successResponse = new LoginResponse( - "mocked.jwt.token", "로그인 성공", 1L, "테스트유저", "test@joycrew.com", UserRole.EMPLOYEE + "mocked.jwt.token", + "로그인 성공", + 1L, + "테스트유저", + "test@joycrew.com", + AdminLevel.EMPLOYEE, + 1000, + "http://example.com/profile.jpg" ); when(authService.login(any(LoginRequest.class))).thenReturn(successResponse); mockMvc.perform(post("/api/auth/login") + // .with(csrf())는 Security 제외 시 필요 없음 .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) @@ -69,8 +64,8 @@ void login_Success() throws Exception { } @Test - @DisplayName("POST /api/auth/login - 로그인 실패 (잘못된 비밀번호)") - void login_Failure_WrongPassword() throws Exception { + @DisplayName("POST /api/auth/login - 로그인 실패 (자격 증명 오류)") + void login_Failure_AuthenticationError() throws Exception { LoginRequest request = new LoginRequest("test@joycrew.com", "wrongpassword"); when(authService.login(any(LoginRequest.class))) .thenThrow(new BadCredentialsException("이메일 또는 비밀번호가 올바르지 않습니다.")); @@ -78,21 +73,7 @@ void login_Failure_WrongPassword() throws Exception { mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.code").value("AUTHENTICATION_FAILED")); - } - - @Test - @DisplayName("POST /api/auth/login - 로그인 실패 (이메일 없음)") - void login_Failure_EmailNotFound() throws Exception { - LoginRequest request = new LoginRequest("nonexistent@joycrew.com", "anypassword"); - when(authService.login(any(LoginRequest.class))) - .thenThrow(new UsernameNotFoundException("이메일 또는 비밀번호가 올바르지 않습니다.")); - - mockMvc.perform(post("/api/auth/login") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isUnauthorized()) + .andExpect(status().isUnauthorized()) // GlobalExceptionHandler에 따라 401 예상 .andExpect(jsonPath("$.code").value("AUTHENTICATION_FAILED")); } @@ -106,4 +87,4 @@ void logout_Success() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("로그아웃 되었습니다.")); } -} +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java b/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java index ad33d07..c337301 100644 --- a/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java @@ -2,6 +2,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.joycrew.backend.dto.EmployeeQueryResponse; +import com.joycrew.backend.dto.PagedEmployeeResponse; +import com.joycrew.backend.security.EmployeeDetailsService; +import com.joycrew.backend.security.JwtUtil; +import com.joycrew.backend.security.WithMockUserPrincipal; import com.joycrew.backend.service.EmployeeQueryService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -9,7 +13,6 @@ import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; import java.util.List; @@ -17,10 +20,8 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; -@WithMockUser(username = "testuser", roles = {"EMPLOYEE"}) @WebMvcTest(controllers = EmployeeQueryController.class) class EmployeeQueryControllerTest { @@ -28,20 +29,25 @@ class EmployeeQueryControllerTest { @Autowired private ObjectMapper objectMapper; @MockBean private EmployeeQueryService employeeQueryService; + @MockBean private JwtUtil jwtUtil; + @MockBean private EmployeeDetailsService employeeDetailsService; @Test @DisplayName("GET /api/employee/query - 직원 목록 검색 성공") + @WithMockUserPrincipal void searchEmployees_success() throws Exception { // Given - EmployeeQueryResponse mockEmployee = EmployeeQueryResponse.builder() - .profileImageUrl("https://cdn.joycrew.com/profile/user1.jpg") - .employeeName("김여은") - .departmentName("인사팀") - .position("사원") - .build(); + EmployeeQueryResponse mockEmployee = new EmployeeQueryResponse( + 2L, + "https://cdn.joycrew.com/profile/user1.jpg", + "김여은", + "인사팀", + "사원" + ); + PagedEmployeeResponse mockResponse = new PagedEmployeeResponse(List.of(mockEmployee), 0, 1, true); - when(employeeQueryService.getEmployees(anyString(), anyInt(), anyInt())) - .thenReturn(List.of(mockEmployee)); + when(employeeQueryService.getEmployees(anyString(), anyInt(), anyInt(), anyLong())) + .thenReturn(mockResponse); // When & Then mockMvc.perform(get("/api/employee/query") @@ -50,43 +56,32 @@ void searchEmployees_success() throws Exception { .param("size", "10") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].employeeName").value("김여은")) - .andExpect(jsonPath("$[0].departmentName").value("인사팀")) - .andExpect(jsonPath("$[0].position").value("사원")) - .andExpect(jsonPath("$[0].profileImageUrl").value("https://cdn.joycrew.com/profile/user1.jpg")); + .andExpect(jsonPath("$.employees[0].employeeName").value("김여은")) + .andExpect(jsonPath("$.employees[0].departmentName").value("인사팀")) + .andExpect(jsonPath("$.currentPage").value(0)) + .andExpect(jsonPath("$.totalPages").value(1)) + .andExpect(jsonPath("$.isLastPage").value(true)); // 수정: lastPage -> isLastPage } @Test @DisplayName("GET /api/employee/query - 검색어 없이도 정상 조회") + @WithMockUserPrincipal void searchEmployees_noKeyword() throws Exception { // Given - EmployeeQueryResponse mockEmployee = EmployeeQueryResponse.builder() - .profileImageUrl(null) - .employeeName("홍길동") - .departmentName(null) - .position("주임") - .build(); + EmployeeQueryResponse mockEmployee = new EmployeeQueryResponse( + 3L, null, "홍길동", null, "주임" + ); + PagedEmployeeResponse mockResponse = new PagedEmployeeResponse(List.of(mockEmployee), 0, 1, true); - when(employeeQueryService.getEmployees(isNull(), anyInt(), anyInt())) - .thenReturn(List.of(mockEmployee)); + when(employeeQueryService.getEmployees(isNull(), anyInt(), anyInt(), anyLong())) + .thenReturn(mockResponse); // When & Then mockMvc.perform(get("/api/employee/query") .param("page", "0") .param("size", "10")) .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].employeeName").value("홍길동")) - .andExpect(jsonPath("$[0].position").value("주임")); + .andExpect(jsonPath("$.employees[0].employeeName").value("홍길동")) + .andExpect(jsonPath("$.employees[0].position").value("주임")); } - - @Test - void debug_print_response() throws Exception { - mockMvc.perform(get("/api/employee/query") - .param("keyword", "김") - .param("page", "0") - .param("size", "10")) - .andDo(print()) // 👈 응답을 콘솔에 출력 - .andExpect(status().isOk()); - } - -} +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/RecognitionControllerTest.java b/src/test/java/com/joycrew/backend/controller/RecognitionControllerTest.java new file mode 100644 index 0000000..70855a4 --- /dev/null +++ b/src/test/java/com/joycrew/backend/controller/RecognitionControllerTest.java @@ -0,0 +1,87 @@ +package com.joycrew.backend.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.joycrew.backend.dto.RecognitionRequest; +import com.joycrew.backend.entity.enums.Tag; +import com.joycrew.backend.exception.GlobalExceptionHandler; +import com.joycrew.backend.security.EmployeeDetailsService; +import com.joycrew.backend.security.JwtUtil; +import com.joycrew.backend.security.WithMockUserPrincipal; +import com.joycrew.backend.service.RecognitionService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = RecognitionController.class) +@Import(GlobalExceptionHandler.class) +class RecognitionControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + + @MockBean private RecognitionService recognitionService; + @MockBean private JwtUtil jwtUtil; + @MockBean private EmployeeDetailsService employeeDetailsService; + + @Test + @DisplayName("POST /api/recognitions - 포인트 선물 성공") + @WithMockUserPrincipal // 모의 인증 객체 주입 + void sendPoints_Success() throws Exception { + // Given + RecognitionRequest request = new RecognitionRequest(2L, 100, "Great collaboration!", List.of(Tag.TEAMWORK)); + doNothing().when(recognitionService).sendRecognition(anyString(), any(RecognitionRequest.class)); + + // When & Then + mockMvc.perform(post("/api/recognitions") + .with(csrf()) // CSRF 토큰 추가 + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("포인트를 성공적으로 보냈습니다.")); + } + + @Test + @DisplayName("POST /api/recognitions - 실패 (Request Body 유효성 검사 오류)") + @WithMockUserPrincipal + void sendPoints_Failure_InvalidRequest() throws Exception { + // Given: 포인트(points)가 1 미만인 잘못된 요청 + RecognitionRequest invalidRequest = new RecognitionRequest(2L, 0, "Invalid points", List.of(Tag.GOALS)); + + // When & Then + mockMvc.perform(post("/api/recognitions") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest))) + .andExpect(status().isBadRequest()) // 400 Bad Request 예상 + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + } + + @Test + @DisplayName("POST /api/recognitions - 실패 (인증되지 않은 사용자)") + void sendPoints_Failure_Unauthenticated() throws Exception { + // Given + RecognitionRequest request = new RecognitionRequest(2L, 100, "Great job!", List.of(Tag.CUSTOMERS)); + + // When & Then + mockMvc.perform(post("/api/recognitions") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); // 401 Unauthorized 예상 + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/UserControllerTest.java b/src/test/java/com/joycrew/backend/controller/UserControllerTest.java index ae8d7e0..63c882c 100644 --- a/src/test/java/com/joycrew/backend/controller/UserControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/UserControllerTest.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.joycrew.backend.dto.PasswordChangeRequest; import com.joycrew.backend.dto.UserProfileResponse; +import com.joycrew.backend.entity.enums.AdminLevel; import com.joycrew.backend.exception.GlobalExceptionHandler; import com.joycrew.backend.security.WithMockUserPrincipal; import com.joycrew.backend.service.EmployeeService; @@ -45,7 +46,7 @@ void getProfile_Success() throws Exception { "https://cdn.joycrew.com/profile/testuser.jpg", 1500, 100, - UserRole.EMPLOYEE, + AdminLevel.EMPLOYEE, "개발팀", "사원" ); diff --git a/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java b/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java deleted file mode 100644 index 7b823b8..0000000 --- a/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.joycrew.backend.repository; - -import com.joycrew.backend.entity.Company; -import com.joycrew.backend.entity.Department; -import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.entity.Wallet; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; - -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; - -@DataJpaTest -class EmployeeRepositoryTest { - - @Autowired - private TestEntityManager entityManager; - @Autowired - private EmployeeRepository employeeRepository; - - private Company testCompany; - private Department testDepartment; - - @BeforeEach - void setUp() { - testCompany = Company.builder().companyName("테스트회사").build(); - entityManager.persist(testCompany); - - testDepartment = Department.builder().name("테스트부서").company(testCompany).build(); - entityManager.persist(testDepartment); - - Employee testEmployee = Employee.builder() - .company(testCompany) - .department(testDepartment) - .email("test@joycrew.com") - .passwordHash("encodedPassword") - .employeeName("김테스트") - .position("사원") - .role(UserRole.EMPLOYEE) - .build(); - entityManager.persist(testEmployee); - - Wallet testWallet = new Wallet(testEmployee); - entityManager.persist(testWallet); - - entityManager.flush(); - entityManager.clear(); - } - - @Test - @DisplayName("이메일로 직원 조회 성공 (@EntityGraph 적용 확인)") - void findByEmail_Success() { - // When - Optional foundEmployee = employeeRepository.findByEmail("test@joycrew.com"); - - // Then - assertThat(foundEmployee).isPresent(); - assertThat(foundEmployee.get().getEmail()).isEqualTo("test@joycrew.com"); - assertThat(foundEmployee.get().getCompany().getCompanyName()).isEqualTo("테스트회사"); - assertThat(foundEmployee.get().getDepartment().getName()).isEqualTo("테스트부서"); - } - - @Test - @DisplayName("이메일로 직원 조회 실패 - 존재하지 않는 이메일") - void findByEmail_NotFound() { - // When - Optional foundEmployee = employeeRepository.findByEmail("nonexistent@joycrew.com"); - - // Then - assertThat(foundEmployee).isEmpty(); - } -} diff --git a/src/test/java/com/joycrew/backend/repository/WalletRepositoryTest.java b/src/test/java/com/joycrew/backend/repository/WalletRepositoryTest.java deleted file mode 100644 index edf0fae..0000000 --- a/src/test/java/com/joycrew/backend/repository/WalletRepositoryTest.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.joycrew.backend.repository; - -import com.joycrew.backend.entity.Company; -import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.entity.Wallet; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; - -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; - -@DataJpaTest -class WalletRepositoryTest { - - @Autowired - private TestEntityManager entityManager; - @Autowired - private WalletRepository walletRepository; - - private Employee testEmployeeWithWallet; - private Employee testEmployeeWithoutWallet; - - @BeforeEach - void setUp() { - Company testCompany = Company.builder().companyName("테스트회사").build(); - entityManager.persist(testCompany); - - testEmployeeWithWallet = Employee.builder() - .company(testCompany) - .email("walletuser@joycrew.com") - .passwordHash("pass123") - .employeeName("지갑유저") - .role(UserRole.EMPLOYEE) - .build(); - entityManager.persist(testEmployeeWithWallet); - - testEmployeeWithoutWallet = Employee.builder() - .company(testCompany) - .email("nowallet@joycrew.com") - .passwordHash("pass123") - .employeeName("지갑없는유저") - .role(UserRole.EMPLOYEE) - .build(); - entityManager.persist(testEmployeeWithoutWallet); - - // [수정] Wallet은 이제 new 키워드와 생성자를 통해서만 생성 - Wallet testWallet = new Wallet(testEmployeeWithWallet); - // [수정] 도메인 메서드를 사용하여 상태 변경 (Setter 대신) - testWallet.addPoints(5000); - entityManager.persist(testWallet); - - // [수정] employee.setWallet()은 불가능하며, 불필요하므로 제거. - // Wallet이 Employee의 참조를 가지고 있으므로 관계는 이미 설정됨. - - entityManager.flush(); - entityManager.clear(); - } - - @Test - @DisplayName("Employee ID로 Wallet 조회 성공") - void findByEmployee_EmployeeId_Success() { - // When - Optional foundWallet = walletRepository.findByEmployee_EmployeeId(testEmployeeWithWallet.getEmployeeId()); - - // Then - assertThat(foundWallet).isPresent(); - assertThat(foundWallet.get().getEmployee().getEmployeeId()).isEqualTo(testEmployeeWithWallet.getEmployeeId()); - assertThat(foundWallet.get().getBalance()).isEqualTo(5000); - } - - @Test - @DisplayName("Employee ID로 Wallet 조회 실패 - Wallet 없음") - void findByEmployee_EmployeeId_NotFound() { - // When - Optional foundWallet = walletRepository.findByEmployee_EmployeeId(testEmployeeWithoutWallet.getEmployeeId()); - - // Then - assertThat(foundWallet).isEmpty(); - } -} diff --git a/src/test/java/com/joycrew/backend/security/WithMockUserPrincipal.java b/src/test/java/com/joycrew/backend/security/WithMockUserPrincipal.java index 8ab4f5f..0ee1e33 100644 --- a/src/test/java/com/joycrew/backend/security/WithMockUserPrincipal.java +++ b/src/test/java/com/joycrew/backend/security/WithMockUserPrincipal.java @@ -7,5 +7,7 @@ @Retention(RetentionPolicy.RUNTIME) @WithSecurityContext(factory = WithMockUserPrincipalSecurityContextFactory.class) public @interface WithMockUserPrincipal { + long id() default 1L; String email() default "testuser@joycrew.com"; + String role() default "EMPLOYEE"; } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/security/WithMockUserPrincipalSecurityContextFactory.java b/src/test/java/com/joycrew/backend/security/WithMockUserPrincipalSecurityContextFactory.java index 1253d8f..8186637 100644 --- a/src/test/java/com/joycrew/backend/security/WithMockUserPrincipalSecurityContextFactory.java +++ b/src/test/java/com/joycrew/backend/security/WithMockUserPrincipalSecurityContextFactory.java @@ -1,6 +1,7 @@ package com.joycrew.backend.security; import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.enums.AdminLevel; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; @@ -11,16 +12,15 @@ public class WithMockUserPrincipalSecurityContextFactory implements WithSecurity public SecurityContext createSecurityContext(WithMockUserPrincipal annotation) { SecurityContext context = SecurityContextHolder.createEmptyContext(); Employee mockEmployee = Employee.builder() - .employeeId(1L) + .employeeId(annotation.id()) .email(annotation.email()) - .employeeName("테스트유저") - .role(UserRole.EMPLOYEE) - .passwordHash("mockPassword") + .role(AdminLevel.valueOf(annotation.role())) .build(); + UserPrincipal principal = new UserPrincipal(mockEmployee); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( - principal, principal.getPassword(), principal.getAuthorities()); + principal, null, principal.getAuthorities()); context.setAuthentication(authentication); return context; } -} +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/AdminEmployeeServiceTest.java b/src/test/java/com/joycrew/backend/service/AdminEmployeeServiceTest.java new file mode 100644 index 0000000..6c2d2dd --- /dev/null +++ b/src/test/java/com/joycrew/backend/service/AdminEmployeeServiceTest.java @@ -0,0 +1,161 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.EmployeeRegistrationRequest; +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.entity.Department; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.AdminLevel; +import com.joycrew.backend.repository.CompanyRepository; +import com.joycrew.backend.repository.DepartmentRepository; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.WalletRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AdminEmployeeServiceTest { + + @Mock + private EmployeeRepository employeeRepository; + @Mock + private CompanyRepository companyRepository; + @Mock + private DepartmentRepository departmentRepository; + @Mock + private WalletRepository walletRepository; + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private AdminEmployeeService adminEmployeeService; + + private EmployeeRegistrationRequest request; + private Company mockCompany; + + @BeforeEach + void setUp() { + request = new EmployeeRegistrationRequest( + "테스트유저", + "test@joycrew.com", + "password123!", + "JoyCrew", + "Engineering", + "Developer", + AdminLevel.EMPLOYEE + ); + + mockCompany = Company.builder().companyId(1L).companyName("JoyCrew").build(); + } + + @Test + @DisplayName("[Service] 단일 직원 등록 성공") + void registerEmployee_Success() { + // Given + Employee savedEmployee = Employee.builder().employeeId(1L).email(request.email()).build(); + + when(employeeRepository.findByEmail(request.email())).thenReturn(Optional.empty()); + when(companyRepository.findByCompanyName(request.companyName())).thenReturn(Optional.of(mockCompany)); + when(departmentRepository.findByCompanyAndName(any(), anyString())).thenReturn(Optional.of(mock(Department.class))); + when(passwordEncoder.encode(request.initialPassword())).thenReturn("encodedPassword"); + when(employeeRepository.save(any(Employee.class))).thenReturn(savedEmployee); + + // When + Employee result = adminEmployeeService.registerEmployee(request); + + // Then + assertThat(result).isEqualTo(savedEmployee); + verify(walletRepository, times(1)).save(any(Wallet.class)); + } + + @Test + @DisplayName("[Service] 단일 직원 등록 실패 - 이메일 중복") + void registerEmployee_Failure_EmailAlreadyExists() { + // Given + when(employeeRepository.findByEmail(request.email())).thenReturn(Optional.of(mock(Employee.class))); + + // When & Then + assertThatThrownBy(() -> adminEmployeeService.registerEmployee(request)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("이미 사용 중인 이메일입니다."); + } + + @Test + @DisplayName("[Service] 단일 직원 등록 실패 - 존재하지 않는 회사") + void registerEmployee_Failure_CompanyNotFound() { + // Given + when(employeeRepository.findByEmail(request.email())).thenReturn(Optional.empty()); + when(companyRepository.findByCompanyName(request.companyName())).thenReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> adminEmployeeService.registerEmployee(request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 회사명입니다."); + } + + @Test + @DisplayName("[Service] CSV 파일로 직원 대량 등록 성공") + void registerEmployeesFromCsv_Success() throws IOException { + // Given + String csvContent = "name,email,initialPassword,companyName,departmentName,position,level\n" + + "김조이,joy@joycrew.com,joy123,JoyCrew,Engineering,Developer,EMPLOYEE\n" + + "박크루,crew@joycrew.com,crew123,JoyCrew,Product,PO,MANAGER"; + MockMultipartFile file = new MockMultipartFile("file", "employees.csv", "text/csv", csvContent.getBytes(StandardCharsets.UTF_8)); + + when(employeeRepository.findByEmail(anyString())).thenReturn(Optional.empty()); + when(companyRepository.findByCompanyName(anyString())).thenReturn(Optional.of(mockCompany)); + when(departmentRepository.findByCompanyAndName(any(), anyString())).thenReturn(Optional.of(mock(Department.class))); + + // When + adminEmployeeService.registerEmployeesFromCsv(file); + + // Then + verify(employeeRepository, times(2)).findByEmail(anyString()); + verify(employeeRepository, times(2)).save(any(Employee.class)); + verify(walletRepository, times(2)).save(any(Wallet.class)); + } + + @Test + @DisplayName("[Service] CSV 파일 대량 등록 시 일부 행 실패해도 계속 진행") + void registerEmployeesFromCsv_PartialFailure() throws IOException { + // Given + String csvContent = "name,email,initialPassword,companyName,departmentName,position,level\n" + + "김조이,joy@joycrew.com,joy123,JoyCrew,Engineering,Developer,EMPLOYEE\n" + + "이실패,fail@joycrew.com,fail123,WrongCompany,None,Intern,EMPLOYEE\n" + // 실패할 행 + "박크루,crew@joycrew.com,crew123,JoyCrew,Product,PO,MANAGER"; + MockMultipartFile file = new MockMultipartFile("file", "employees.csv", "text/csv", csvContent.getBytes(StandardCharsets.UTF_8)); + + when(employeeRepository.findByEmail("joy@joycrew.com")).thenReturn(Optional.empty()); + when(employeeRepository.findByEmail("crew@joycrew.com")).thenReturn(Optional.empty()); + when(companyRepository.findByCompanyName("JoyCrew")).thenReturn(Optional.of(mockCompany)); + when(departmentRepository.findByCompanyAndName(any(), anyString())).thenReturn(Optional.of(mock(Department.class))); + + when(employeeRepository.findByEmail("fail@joycrew.com")).thenReturn(Optional.empty()); + when(companyRepository.findByCompanyName("WrongCompany")).thenReturn(Optional.empty()); + + // When + adminEmployeeService.registerEmployeesFromCsv(file); + + // Then + verify(employeeRepository, times(3)).findByEmail(anyString()); + verify(employeeRepository, times(2)).save(any(Employee.class)); + verify(walletRepository, times(2)).save(any(Wallet.class)); + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java index 20eb26b..026a4ea 100644 --- a/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java +++ b/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java @@ -5,6 +5,7 @@ import com.joycrew.backend.dto.LoginRequest; import com.joycrew.backend.dto.LoginResponse; import com.joycrew.backend.entity.Company; +import com.joycrew.backend.entity.enums.AdminLevel; import com.joycrew.backend.repository.CompanyRepository; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.security.JwtUtil; @@ -53,7 +54,7 @@ void setUp() { defaultCompany.getCompanyName(), null, "사원", - UserRole.EMPLOYEE + AdminLevel.EMPLOYEE ); adminEmployeeService.registerEmployee(request); } @@ -74,7 +75,7 @@ void login_Integration_Success() { assertThat(response.email()).isEqualTo(testEmail); assertThat(response.userId()).isEqualTo(employeeRepository.findByEmail(testEmail).get().getEmployeeId()); assertThat(response.name()).isEqualTo(testName); - assertThat(response.role()).isEqualTo(UserRole.EMPLOYEE); + assertThat(response.role()).isEqualTo(AdminLevel.EMPLOYEE); String extractedEmail = jwtUtil.getEmailFromToken(response.accessToken()); assertThat(extractedEmail).isEqualTo(testEmail); diff --git a/src/test/java/com/joycrew/backend/service/AuthServiceTest.java b/src/test/java/com/joycrew/backend/service/AuthServiceTest.java index 3cc3239..75d2899 100644 --- a/src/test/java/com/joycrew/backend/service/AuthServiceTest.java +++ b/src/test/java/com/joycrew/backend/service/AuthServiceTest.java @@ -3,8 +3,13 @@ import com.joycrew.backend.dto.LoginRequest; import com.joycrew.backend.dto.LoginResponse; import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.AdminLevel; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.WalletRepository; import com.joycrew.backend.security.JwtUtil; import com.joycrew.backend.security.UserPrincipal; +import io.jsonwebtoken.JwtException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -16,10 +21,14 @@ import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -29,12 +38,19 @@ class AuthServiceTest { private JwtUtil jwtUtil; @Mock private AuthenticationManager authenticationManager; + @Mock + private WalletRepository walletRepository; + @Mock + private EmployeeRepository employeeRepository; + @Mock + private PasswordEncoder passwordEncoder; + @Mock + private EmailService emailService; @InjectMocks private AuthService authService; private Employee testEmployee; - private LoginRequest testLoginRequest; private String testToken = "mocked.jwt.token"; @BeforeEach @@ -44,42 +60,42 @@ void setUp() { .email("test@joycrew.com") .passwordHash("encodedPassword") .employeeName("테스트유저") - .role(UserRole.EMPLOYEE) + .role(AdminLevel.EMPLOYEE) .status("ACTIVE") + .profileImageUrl("http://example.com/profile.jpg") .build(); - - testLoginRequest = new LoginRequest("test@joycrew.com", "password123"); } @Test - @DisplayName("로그인 성공 시 JWT 토큰과 사용자 정보 반환") + @DisplayName("[Service] 로그인 성공") void login_Success() { // Given + LoginRequest testLoginRequest = new LoginRequest("test@joycrew.com", "password123"); UserPrincipal principal = new UserPrincipal(testEmployee); Authentication successfulAuth = new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities()); + Wallet mockWallet = new Wallet(testEmployee); + mockWallet.addPoints(100); + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) .thenReturn(successfulAuth); - + when(walletRepository.findByEmployee_EmployeeId(1L)).thenReturn(Optional.of(mockWallet)); when(jwtUtil.generateToken(anyString())).thenReturn(testToken); // When LoginResponse response = authService.login(testLoginRequest); // Then - assertThat(response).isNotNull(); assertThat(response.accessToken()).isEqualTo(testToken); assertThat(response.message()).isEqualTo("로그인 성공"); - assertThat(response.userId()).isEqualTo(testEmployee.getEmployeeId()); - assertThat(response.email()).isEqualTo(testEmployee.getEmail()); - - verify(authenticationManager, times(1)).authenticate(any(UsernamePasswordAuthenticationToken.class)); - verify(jwtUtil, times(1)).generateToken(testEmployee.getEmail()); + assertThat(response.totalPoint()).isEqualTo(100); + assertThat(response.profileImageUrl()).isEqualTo(testEmployee.getProfileImageUrl()); } @Test - @DisplayName("로그인 실패 - 자격 증명 오류 (BadCredentialsException)") + @DisplayName("[Service] 로그인 실패 - 자격 증명 오류") void login_Failure_WrongPassword() { // Given + LoginRequest testLoginRequest = new LoginRequest("test@joycrew.com", "wrongPassword"); when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) .thenThrow(new BadCredentialsException("Bad credentials")); @@ -87,4 +103,61 @@ void login_Failure_WrongPassword() { assertThatThrownBy(() -> authService.login(testLoginRequest)) .isInstanceOf(BadCredentialsException.class); } + + @Test + @DisplayName("[Service] 비밀번호 재설정 요청 성공 - 이메일 존재") + void requestPasswordReset_Success_EmailExists() { + // Given + String email = "test@joycrew.com"; + when(employeeRepository.findByEmail(email)).thenReturn(Optional.of(testEmployee)); + when(jwtUtil.generateToken(eq(email), anyLong())).thenReturn(testToken); + + // When + authService.requestPasswordReset(email); + + // Then + verify(emailService, times(1)).sendPasswordResetEmail(email, testToken); + } + + @Test + @DisplayName("[Service] 비밀번호 재설정 요청 성공 - 이메일 존재하지 않음 (공격 방지)") + void requestPasswordReset_Success_EmailDoesNotExist() { + // Given + String email = "notfound@joycrew.com"; + when(employeeRepository.findByEmail(email)).thenReturn(Optional.empty()); + + // When + authService.requestPasswordReset(email); + + // Then + verify(emailService, never()).sendPasswordResetEmail(anyString(), anyString()); + } + + @Test + @DisplayName("[Service] 비밀번호 재설정 확인 성공") + void confirmPasswordReset_Success() { + // Given + String newPassword = "newPassword123!"; + when(jwtUtil.getEmailFromToken(testToken)).thenReturn(testEmployee.getEmail()); + when(employeeRepository.findByEmail(testEmployee.getEmail())).thenReturn(Optional.of(testEmployee)); + + // When + authService.confirmPasswordReset(testToken, newPassword); + + // Then + verify(testEmployee, times(1)).changePassword(newPassword, passwordEncoder); + } + + @Test + @DisplayName("[Service] 비밀번호 재설정 확인 실패 - 유효하지 않은 토큰") + void confirmPasswordReset_Failure_InvalidToken() { + // Given + String invalidToken = "invalid-token"; + when(jwtUtil.getEmailFromToken(invalidToken)).thenThrow(new JwtException("Invalid Token")); + + // When & Then + assertThatThrownBy(() -> authService.confirmPasswordReset(invalidToken, "newPassword")) + .isInstanceOf(BadCredentialsException.class) + .hasMessage("유효하지 않거나 만료된 토큰입니다."); + } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/EmailServiceTest.java b/src/test/java/com/joycrew/backend/service/EmailServiceTest.java new file mode 100644 index 0000000..9f0fd74 --- /dev/null +++ b/src/test/java/com/joycrew/backend/service/EmailServiceTest.java @@ -0,0 +1,46 @@ +package com.joycrew.backend.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class EmailServiceTest { + + @Mock + private JavaMailSender mailSender; + + @InjectMocks + private EmailService emailService; + + @Test + @DisplayName("[Service] 비밀번호 재설정 이메일 발송 - MailSender 호출 검증") + void sendPasswordResetEmail_Success() { + // Given + String toEmail = "test@joycrew.com"; + String token = "test-token"; + String expectedFrontendUrl = "https://joycrew.co.kr/reset-password?token=" + token; + + // When + emailService.sendPasswordResetEmail(toEmail, token); + + // Then + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(SimpleMailMessage.class); + verify(mailSender, times(1)).send(messageCaptor.capture()); + + SimpleMailMessage sentMessage = messageCaptor.getValue(); + assertThat(sentMessage.getTo()).contains(toEmail); + assertThat(sentMessage.getSubject()).isEqualTo("[JoyCrew] 비밀번호 재설정 안내"); + assertThat(sentMessage.getText()).contains(expectedFrontendUrl); + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/EmployeeQueryServiceTest.java b/src/test/java/com/joycrew/backend/service/EmployeeQueryServiceTest.java new file mode 100644 index 0000000..28a10f2 --- /dev/null +++ b/src/test/java/com/joycrew/backend/service/EmployeeQueryServiceTest.java @@ -0,0 +1,66 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.PagedEmployeeResponse; +import com.joycrew.backend.entity.Employee; +import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class EmployeeQueryServiceTest { + + @Mock + private EntityManager em; + + @InjectMocks + private EmployeeQueryService employeeQueryService; + + @Test + @DisplayName("[Service] 직원 목록 조회 - 페이징 정보와 함께 반환") + void getEmployees_Success() { + // Given + String keyword = "test"; + int page = 0; + int size = 10; + Long currentUserId = 1L; + + TypedQuery countQuery = mock(TypedQuery.class); + TypedQuery dataQuery = mock(TypedQuery.class); + Employee mockEmployee = Employee.builder().employeeId(2L).employeeName("Test User").build(); + + when(em.createQuery(anyString(), eq(Long.class))).thenReturn(countQuery); + when(countQuery.setParameter(anyString(), any())).thenReturn(countQuery); + when(countQuery.getSingleResult()).thenReturn(1L); + + when(em.createQuery(anyString(), eq(Employee.class))).thenReturn(dataQuery); + when(dataQuery.setParameter(anyString(), any())).thenReturn(dataQuery); + when(dataQuery.setFirstResult(anyInt())).thenReturn(dataQuery); + when(dataQuery.setMaxResults(anyInt())).thenReturn(dataQuery); + when(dataQuery.getResultList()).thenReturn(List.of(mockEmployee)); + + // When + PagedEmployeeResponse response = employeeQueryService.getEmployees(keyword, page, size, currentUserId); + + // Then + assertThat(response).isNotNull(); + assertThat(response.employees()).hasSize(1); + assertThat(response.currentPage()).isEqualTo(page); + assertThat(response.totalPages()).isEqualTo(1); + assertThat(response.isLastPage()).isTrue(); + + verify(dataQuery, times(1)).setParameter("keyword", "%" + keyword.toLowerCase() + "%"); + verify(dataQuery, times(1)).setParameter("currentUserId", currentUserId); + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java index d6912ee..22617ca 100644 --- a/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java +++ b/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java @@ -5,6 +5,7 @@ import com.joycrew.backend.entity.Company; import com.joycrew.backend.entity.Department; import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.enums.AdminLevel; import com.joycrew.backend.repository.CompanyRepository; import com.joycrew.backend.repository.DepartmentRepository; import com.joycrew.backend.repository.EmployeeRepository; @@ -53,7 +54,7 @@ void registerEmployee_Success() { // Given EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( "신규직원", "new.employee@joycrew.com", "password123!", - testCompany.getCompanyName(), testDepartment.getName(), "사원", UserRole.EMPLOYEE + testCompany.getCompanyName(), testDepartment.getName(), "사원", AdminLevel.EMPLOYEE ); // When diff --git a/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java b/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java index bc2b1da..076ffa1 100644 --- a/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java +++ b/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java @@ -1,9 +1,12 @@ package com.joycrew.backend.service; import com.joycrew.backend.dto.PasswordChangeRequest; +import com.joycrew.backend.dto.UserProfileResponse; import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; import com.joycrew.backend.exception.UserNotFoundException; import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.WalletRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -14,6 +17,7 @@ import java.util.Optional; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; @@ -24,11 +28,53 @@ class EmployeeServiceTest { @Mock private EmployeeRepository employeeRepository; @Mock + private WalletRepository walletRepository; + @Mock private PasswordEncoder passwordEncoder; @InjectMocks private EmployeeService employeeService; + @Test + @DisplayName("[Service] 프로필 조회 성공 - 지갑 존재") + void getUserProfile_Success_WalletExists() { + // Given + String userEmail = "test@joycrew.com"; + Employee mockEmployee = Employee.builder().employeeId(1L).email(userEmail).employeeName("테스트유저").build(); + Wallet mockWallet = new Wallet(mockEmployee); + mockWallet.addPoints(200); + + when(employeeRepository.findByEmail(userEmail)).thenReturn(Optional.of(mockEmployee)); + when(walletRepository.findByEmployee_EmployeeId(1L)).thenReturn(Optional.of(mockWallet)); + + // When + UserProfileResponse response = employeeService.getUserProfile(userEmail); + + // Then + assertThat(response).isNotNull(); + assertThat(response.name()).isEqualTo("테스트유저"); // getName() -> name()으로 수정 + assertThat(response.totalBalance()).isEqualTo(200); // getPointBalance() -> totalBalance()로 수정 + } + + @Test + @DisplayName("[Service] 프로필 조회 성공 - 지갑 없음 (기본값 0으로 생성)") + void getUserProfile_Success_WalletDoesNotExist() { + // Given + String userEmail = "test@joycrew.com"; + Employee mockEmployee = Employee.builder().employeeId(1L).email(userEmail).employeeName("테스트유저").build(); + + when(employeeRepository.findByEmail(userEmail)).thenReturn(Optional.of(mockEmployee)); + when(walletRepository.findByEmployee_EmployeeId(1L)).thenReturn(Optional.empty()); + + // When + UserProfileResponse response = employeeService.getUserProfile(userEmail); + + // Then + assertThat(response).isNotNull(); + assertThat(response.name()).isEqualTo("테스트유저"); + assertThat(response.totalBalance()).isEqualTo(0); + } + @Test @DisplayName("[Service] 비밀번호 변경 성공 - Employee의 changePassword 메서드 호출 검증") void forcePasswordChange_Success() { @@ -58,4 +104,4 @@ void forcePasswordChange_Failure_UserNotFound() { assertThatThrownBy(() -> employeeService.forcePasswordChange(userEmail, request)) .isInstanceOf(UserNotFoundException.class); } -} +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/RecognitionServiceTest.java b/src/test/java/com/joycrew/backend/service/RecognitionServiceTest.java new file mode 100644 index 0000000..2582133 --- /dev/null +++ b/src/test/java/com/joycrew/backend/service/RecognitionServiceTest.java @@ -0,0 +1,103 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.RecognitionRequest; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.Tag; +import com.joycrew.backend.exception.InsufficientPointsException; +import com.joycrew.backend.exception.UserNotFoundException; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.RewardPointTransactionRepository; +import com.joycrew.backend.repository.WalletRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RecognitionServiceTest { + + @Mock + private EmployeeRepository employeeRepository; + @Mock + private WalletRepository walletRepository; + @Mock + private RewardPointTransactionRepository transactionRepository; + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private RecognitionService recognitionService; + + private Employee sender; + private Employee receiver; + private Wallet senderWallet; + private Wallet receiverWallet; + private RecognitionRequest request; + + @BeforeEach + void setUp() { + sender = Employee.builder().employeeId(1L).email("sender@joycrew.com").build(); + receiver = Employee.builder().employeeId(2L).email("receiver@joycrew.com").build(); + senderWallet = spy(new Wallet(sender)); + receiverWallet = spy(new Wallet(receiver)); + request = new RecognitionRequest(2L, 100, "Great job!", List.of(Tag.TEAMWORK)); + } + + @Test + @DisplayName("[Service] 포인트 선물 성공") + void sendRecognition_Success() { + // Given + senderWallet.addPoints(500); + + when(employeeRepository.findByEmail("sender@joycrew.com")).thenReturn(Optional.of(sender)); + when(employeeRepository.findById(2L)).thenReturn(Optional.of(receiver)); + when(walletRepository.findByEmployee_EmployeeId(1L)).thenReturn(Optional.of(senderWallet)); + when(walletRepository.findByEmployee_EmployeeId(2L)).thenReturn(Optional.of(receiverWallet)); + + // When + recognitionService.sendRecognition("sender@joycrew.com", request); + + // Then + verify(senderWallet, times(1)).spendPoints(100); + verify(receiverWallet, times(1)).addPoints(100); + verify(transactionRepository, times(1)).save(any()); + verify(eventPublisher, times(1)).publishEvent(any()); + } + + @Test + @DisplayName("[Service] 포인트 선물 실패 - 보내는 사용자를 찾을 수 없음") + void sendRecognition_Failure_SenderNotFound() { + // Given + when(employeeRepository.findByEmail(anyString())).thenReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> recognitionService.sendRecognition("sender@joycrew.com", request)) + .isInstanceOf(UserNotFoundException.class); + } + + @Test + @DisplayName("[Service] 포인트 선물 실패 - 포인트 부족") + void sendRecognition_Failure_InsufficientPoints() { + // Given + when(employeeRepository.findByEmail("sender@joycrew.com")).thenReturn(Optional.of(sender)); + when(employeeRepository.findById(2L)).thenReturn(Optional.of(receiver)); + when(walletRepository.findByEmployee_EmployeeId(1L)).thenReturn(Optional.of(senderWallet)); + when(walletRepository.findByEmployee_EmployeeId(2L)).thenReturn(Optional.of(receiverWallet)); + + // When & Then + assertThatThrownBy(() -> recognitionService.sendRecognition("sender@joycrew.com", request)) + .isInstanceOf(InsufficientPointsException.class); + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/WalletServiceTest.java b/src/test/java/com/joycrew/backend/service/WalletServiceTest.java new file mode 100644 index 0000000..a953ce9 --- /dev/null +++ b/src/test/java/com/joycrew/backend/service/WalletServiceTest.java @@ -0,0 +1,65 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.PointBalanceResponse; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.exception.UserNotFoundException; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.WalletRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WalletServiceTest { + + @Mock + private WalletRepository walletRepository; + @Mock + private EmployeeRepository employeeRepository; + + @InjectMocks + private WalletService walletService; + + @Test + @DisplayName("[Service] 포인트 잔액 조회 성공") + void getPointBalance_Success() { + // Given + String userEmail = "test@joycrew.com"; + Employee mockEmployee = Employee.builder().employeeId(1L).build(); + Wallet mockWallet = new Wallet(mockEmployee); + mockWallet.addPoints(500); + + when(employeeRepository.findByEmail(userEmail)).thenReturn(Optional.of(mockEmployee)); + when(walletRepository.findByEmployee_EmployeeId(1L)).thenReturn(Optional.of(mockWallet)); + + // When + PointBalanceResponse response = walletService.getPointBalance(userEmail); + + // Then + assertThat(response).isNotNull(); + assertThat(response.totalBalance()).isEqualTo(500); + assertThat(response.giftableBalance()).isEqualTo(500); + } + + @Test + @DisplayName("[Service] 포인트 잔액 조회 실패 - 사용자를 찾을 수 없음") + void getPointBalance_Failure_UserNotFound() { + // Given + String userEmail = "notfound@joycrew.com"; + when(employeeRepository.findByEmail(userEmail)).thenReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> walletService.getPointBalance(userEmail)) + .isInstanceOf(UserNotFoundException.class); + } +} \ No newline at end of file From cb26f50fc7e27f040a834bdfb5302555c40a766d Mon Sep 17 00:00:00 2001 From: yeoEun Date: Tue, 5 Aug 2025 16:02:31 +0900 Subject: [PATCH 068/135] feat: p2p send points+fix test errors --- ...ntroller.java => GiftPointController.java} | 21 +++---- ...tionRequest.java => GiftPointRequest.java} | 6 +- ...tionService.java => GiftPointService.java} | 16 +++-- .../config/TestUserDetailsService.java | 5 +- .../AdminEmployeeControllerTest.java | 5 +- .../controller/AuthControllerTest.java | 3 +- .../EmployeeQueryControllerTest.java | 45 ++++++++------ .../controller/GiftPointControllerTest.java | 58 +++++++++++++++++++ .../controller/UserControllerTest.java | 3 +- .../repository/EmployeeRepositoryTest.java | 3 +- .../repository/WalletRepositoryTest.java | 5 +- ...ckUserPrincipalSecurityContextFactory.java | 10 +++- .../service/AuthServiceIntegrationTest.java | 5 +- .../backend/service/AuthServiceTest.java | 3 +- .../EmployeeServiceIntegrationTest.java | 3 +- 15 files changed, 143 insertions(+), 48 deletions(-) rename src/main/java/com/joycrew/backend/controller/{RecognitionController.java => GiftPointController.java} (64%) rename src/main/java/com/joycrew/backend/dto/{RecognitionRequest.java => GiftPointRequest.java} (82%) rename src/main/java/com/joycrew/backend/service/{RecognitionService.java => GiftPointService.java} (83%) create mode 100644 src/test/java/com/joycrew/backend/controller/GiftPointControllerTest.java diff --git a/src/main/java/com/joycrew/backend/controller/RecognitionController.java b/src/main/java/com/joycrew/backend/controller/GiftPointController.java similarity index 64% rename from src/main/java/com/joycrew/backend/controller/RecognitionController.java rename to src/main/java/com/joycrew/backend/controller/GiftPointController.java index 5cd3e7a..89aa02a 100644 --- a/src/main/java/com/joycrew/backend/controller/RecognitionController.java +++ b/src/main/java/com/joycrew/backend/controller/GiftPointController.java @@ -1,9 +1,9 @@ package com.joycrew.backend.controller; -import com.joycrew.backend.dto.RecognitionRequest; +import com.joycrew.backend.dto.GiftPointRequest; import com.joycrew.backend.dto.SuccessResponse; import com.joycrew.backend.security.UserPrincipal; -import com.joycrew.backend.service.RecognitionService; +import com.joycrew.backend.service.GiftPointService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; @@ -13,20 +13,21 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "인정/보상", description = "동료 간 포인트 보상 API") +@Tag(name = "포인트 선물", description = "동료 간 포인트 선물 API") @RestController -@RequestMapping("/api/recognitions") +@RequestMapping("/api/gift-points") @RequiredArgsConstructor -public class RecognitionController { - private final RecognitionService recognitionService; +public class GiftPointController { + + private final GiftPointService giftPointService; @Operation(summary = "동료에게 포인트 선물하기", security = @SecurityRequirement(name = "Authorization")) @PostMapping - public ResponseEntity sendPoints( + public ResponseEntity giftPoints( @AuthenticationPrincipal UserPrincipal principal, - @Valid @RequestBody RecognitionRequest request + @Valid @RequestBody GiftPointRequest request ) { - recognitionService.sendRecognition(principal.getUsername(), request); + giftPointService.giftPointsToColleague(principal.getUsername(), request); return ResponseEntity.ok(new SuccessResponse("포인트를 성공적으로 보냈습니다.")); } -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/dto/RecognitionRequest.java b/src/main/java/com/joycrew/backend/dto/GiftPointRequest.java similarity index 82% rename from src/main/java/com/joycrew/backend/dto/RecognitionRequest.java rename to src/main/java/com/joycrew/backend/dto/GiftPointRequest.java index 78cfc86..c0845e3 100644 --- a/src/main/java/com/joycrew/backend/dto/RecognitionRequest.java +++ b/src/main/java/com/joycrew/backend/dto/GiftPointRequest.java @@ -4,9 +4,10 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; + import java.util.List; -public record RecognitionRequest( +public record GiftPointRequest( @NotNull(message = "받는 사람 ID는 필수입니다.") Long receiverId, @@ -18,5 +19,6 @@ public record RecognitionRequest( String message, @NotNull(message = "태그는 필수입니다.") + @Size(min = 1, max = 3, message = "태그는 최소 1개, 최대 3개까지 선택 가능합니다.") List tags -) {} \ No newline at end of file +) {} diff --git a/src/main/java/com/joycrew/backend/service/RecognitionService.java b/src/main/java/com/joycrew/backend/service/GiftPointService.java similarity index 83% rename from src/main/java/com/joycrew/backend/service/RecognitionService.java rename to src/main/java/com/joycrew/backend/service/GiftPointService.java index 2292216..72f335e 100644 --- a/src/main/java/com/joycrew/backend/service/RecognitionService.java +++ b/src/main/java/com/joycrew/backend/service/GiftPointService.java @@ -1,6 +1,6 @@ package com.joycrew.backend.service; -import com.joycrew.backend.dto.RecognitionRequest; +import com.joycrew.backend.dto.GiftPointRequest; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.RewardPointTransaction; import com.joycrew.backend.entity.Wallet; @@ -17,14 +17,15 @@ @Service @RequiredArgsConstructor -public class RecognitionService { +public class GiftPointService { + private final EmployeeRepository employeeRepository; private final WalletRepository walletRepository; private final RewardPointTransactionRepository transactionRepository; private final ApplicationEventPublisher eventPublisher; @Transactional - public void sendRecognition(String senderEmail, RecognitionRequest request) { + public void giftPointsToColleague(String senderEmail, GiftPointRequest request) { Employee sender = employeeRepository.findByEmail(senderEmail) .orElseThrow(() -> new UserNotFoundException("보내는 사용자를 찾을 수 없습니다.")); Employee receiver = employeeRepository.findById(request.receiverId()) @@ -35,9 +36,11 @@ public void sendRecognition(String senderEmail, RecognitionRequest request) { Wallet receiverWallet = walletRepository.findByEmployee_EmployeeId(receiver.getEmployeeId()) .orElseThrow(() -> new IllegalStateException("받는 사용자의 지갑이 없습니다.")); + // 포인트 이체 senderWallet.spendPoints(request.points()); receiverWallet.addPoints(request.points()); + // 트랜잭션 기록 RewardPointTransaction transaction = RewardPointTransaction.builder() .sender(sender) .receiver(receiver) @@ -48,6 +51,9 @@ public void sendRecognition(String senderEmail, RecognitionRequest request) { .build(); transactionRepository.save(transaction); - eventPublisher.publishEvent(new RecognitionEvent(this, sender.getEmployeeId(), receiver.getEmployeeId(), request.points(), request.message())); + // 이벤트 발행 (예: 알림용) + eventPublisher.publishEvent( + new RecognitionEvent(this, sender.getEmployeeId(), receiver.getEmployeeId(), request.points(), request.message()) + ); } -} \ No newline at end of file +} diff --git a/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java b/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java index 81dc419..fcc3668 100644 --- a/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java +++ b/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java @@ -1,6 +1,7 @@ package com.joycrew.backend.config; import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.enums.AdminLevel; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -19,7 +20,7 @@ public TestUserDetailsService() { .employeeId(1L) .email("testuser@joycrew.com") .employeeName("테스트유저") - .role(UserRole.EMPLOYEE) + .role(AdminLevel.EMPLOYEE) .status("ACTIVE") .passwordHash("{noop}password") .build()); @@ -28,7 +29,7 @@ public TestUserDetailsService() { .employeeId(99L) .email("nowallet@joycrew.com") .employeeName("지갑없음") - .role(UserRole.EMPLOYEE) + .role(AdminLevel.EMPLOYEE) .status("ACTIVE") .passwordHash("{noop}password") .build()); diff --git a/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java b/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java index c8e5f75..ff4574d 100644 --- a/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java @@ -5,6 +5,7 @@ import com.joycrew.backend.entity.Company; import com.joycrew.backend.entity.Department; import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.enums.AdminLevel; import com.joycrew.backend.service.AdminEmployeeService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -46,7 +47,7 @@ void registerEmployee_success() throws Exception { "조이크루", // companyName "인사팀", // departmentName "사원", // position - UserRole.EMPLOYEE // role + AdminLevel.EMPLOYEE // role ); // Given - 서비스가 반환할 Employee mock 객체 @@ -57,7 +58,7 @@ void registerEmployee_success() throws Exception { .company(Company.builder().companyId(1L).companyName("조이크루").build()) .department(Department.builder().departmentId(1L).name("인사팀").build()) .position("사원") - .role(UserRole.EMPLOYEE) + .role(AdminLevel.EMPLOYEE) .build(); when(adminEmployeeService.registerEmployee(any(EmployeeRegistrationRequest.class))) diff --git a/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java b/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java index 9b35e01..6bb284b 100644 --- a/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.joycrew.backend.dto.LoginRequest; import com.joycrew.backend.dto.LoginResponse; +import com.joycrew.backend.entity.enums.AdminLevel; import com.joycrew.backend.exception.GlobalExceptionHandler; import com.joycrew.backend.service.AuthService; import jakarta.servlet.http.HttpServletRequest; @@ -55,7 +56,7 @@ public PasswordEncoder passwordEncoder() { void login_Success() throws Exception { LoginRequest request = new LoginRequest("test@joycrew.com", "password123!"); LoginResponse successResponse = new LoginResponse( - "mocked.jwt.token", "로그인 성공", 1L, "테스트유저", "test@joycrew.com", UserRole.EMPLOYEE + "mocked.jwt.token", "로그인 성공", 1L, "테스트유저", "test@joycrew.com", AdminLevel.EMPLOYEE, 500, "https://example.com/profile.jpg" ); when(authService.login(any(LoginRequest.class))).thenReturn(successResponse); diff --git a/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java b/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java index ad33d07..8bd8027 100644 --- a/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.joycrew.backend.dto.EmployeeQueryResponse; +import com.joycrew.backend.dto.PagedEmployeeResponse; import com.joycrew.backend.service.EmployeeQueryService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -33,15 +34,20 @@ class EmployeeQueryControllerTest { @DisplayName("GET /api/employee/query - 직원 목록 검색 성공") void searchEmployees_success() throws Exception { // Given - EmployeeQueryResponse mockEmployee = EmployeeQueryResponse.builder() - .profileImageUrl("https://cdn.joycrew.com/profile/user1.jpg") - .employeeName("김여은") - .departmentName("인사팀") - .position("사원") - .build(); + EmployeeQueryResponse mockEmployee = new EmployeeQueryResponse( + 1L, + "https://cdn.joycrew.com/profile/user1.jpg", + "김여은", + "인사팀", + "사원" + ); - when(employeeQueryService.getEmployees(anyString(), anyInt(), anyInt())) - .thenReturn(List.of(mockEmployee)); + + when(employeeQueryService.getEmployees(anyString(), anyInt(), anyInt(), anyLong())) + .thenReturn(new PagedEmployeeResponse( + List.of(mockEmployee), + 0, 1, true + )); // When & Then mockMvc.perform(get("/api/employee/query") @@ -60,15 +66,20 @@ void searchEmployees_success() throws Exception { @DisplayName("GET /api/employee/query - 검색어 없이도 정상 조회") void searchEmployees_noKeyword() throws Exception { // Given - EmployeeQueryResponse mockEmployee = EmployeeQueryResponse.builder() - .profileImageUrl(null) - .employeeName("홍길동") - .departmentName(null) - .position("주임") - .build(); - - when(employeeQueryService.getEmployees(isNull(), anyInt(), anyInt())) - .thenReturn(List.of(mockEmployee)); + EmployeeQueryResponse mockEmployee = new EmployeeQueryResponse( + 2L, + null, + "홍길동", + null, + "주임" + ); + + + when(employeeQueryService.getEmployees(isNull(), anyInt(), anyInt(), anyLong())) + .thenReturn(new PagedEmployeeResponse( + List.of(mockEmployee), + 0, 1, true + )); // When & Then mockMvc.perform(get("/api/employee/query") diff --git a/src/test/java/com/joycrew/backend/controller/GiftPointControllerTest.java b/src/test/java/com/joycrew/backend/controller/GiftPointControllerTest.java new file mode 100644 index 0000000..4c442ba --- /dev/null +++ b/src/test/java/com/joycrew/backend/controller/GiftPointControllerTest.java @@ -0,0 +1,58 @@ +package com.joycrew.backend.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.joycrew.backend.dto.GiftPointRequest; +import com.joycrew.backend.entity.enums.Tag; +import com.joycrew.backend.security.WithMockUserPrincipal; +import com.joycrew.backend.service.GiftPointService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(GiftPointController.class) +class GiftPointControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private GiftPointService giftPointService; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @WithMockUserPrincipal(email = "sender@example.com") + @DisplayName("동료에게 포인트 선물 성공") + void testGiftPointsSuccess() throws Exception { + // given + GiftPointRequest request = new GiftPointRequest( + 2L, + 50, + "수고하셨어요!", + List.of(Tag.TEAMWORK, Tag.INNOVATION) + ); + + // mock 서비스 로직 + doNothing().when(giftPointService).giftPointsToColleague(any(), any()); + + // when & then + mockMvc.perform(post("/api/gift-points") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("포인트를 성공적으로 보냈습니다.")); + } +} diff --git a/src/test/java/com/joycrew/backend/controller/UserControllerTest.java b/src/test/java/com/joycrew/backend/controller/UserControllerTest.java index ae8d7e0..63c882c 100644 --- a/src/test/java/com/joycrew/backend/controller/UserControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/UserControllerTest.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.joycrew.backend.dto.PasswordChangeRequest; import com.joycrew.backend.dto.UserProfileResponse; +import com.joycrew.backend.entity.enums.AdminLevel; import com.joycrew.backend.exception.GlobalExceptionHandler; import com.joycrew.backend.security.WithMockUserPrincipal; import com.joycrew.backend.service.EmployeeService; @@ -45,7 +46,7 @@ void getProfile_Success() throws Exception { "https://cdn.joycrew.com/profile/testuser.jpg", 1500, 100, - UserRole.EMPLOYEE, + AdminLevel.EMPLOYEE, "개발팀", "사원" ); diff --git a/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java b/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java index 7b823b8..d7ec550 100644 --- a/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java +++ b/src/test/java/com/joycrew/backend/repository/EmployeeRepositoryTest.java @@ -4,6 +4,7 @@ import com.joycrew.backend.entity.Department; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.AdminLevel; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -41,7 +42,7 @@ void setUp() { .passwordHash("encodedPassword") .employeeName("김테스트") .position("사원") - .role(UserRole.EMPLOYEE) + .role(AdminLevel.EMPLOYEE) .build(); entityManager.persist(testEmployee); diff --git a/src/test/java/com/joycrew/backend/repository/WalletRepositoryTest.java b/src/test/java/com/joycrew/backend/repository/WalletRepositoryTest.java index edf0fae..b1c0531 100644 --- a/src/test/java/com/joycrew/backend/repository/WalletRepositoryTest.java +++ b/src/test/java/com/joycrew/backend/repository/WalletRepositoryTest.java @@ -3,6 +3,7 @@ import com.joycrew.backend.entity.Company; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.AdminLevel; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -35,7 +36,7 @@ void setUp() { .email("walletuser@joycrew.com") .passwordHash("pass123") .employeeName("지갑유저") - .role(UserRole.EMPLOYEE) + .role(AdminLevel.EMPLOYEE) .build(); entityManager.persist(testEmployeeWithWallet); @@ -44,7 +45,7 @@ void setUp() { .email("nowallet@joycrew.com") .passwordHash("pass123") .employeeName("지갑없는유저") - .role(UserRole.EMPLOYEE) + .role(AdminLevel.EMPLOYEE) .build(); entityManager.persist(testEmployeeWithoutWallet); diff --git a/src/test/java/com/joycrew/backend/security/WithMockUserPrincipalSecurityContextFactory.java b/src/test/java/com/joycrew/backend/security/WithMockUserPrincipalSecurityContextFactory.java index 1253d8f..7d6ddda 100644 --- a/src/test/java/com/joycrew/backend/security/WithMockUserPrincipalSecurityContextFactory.java +++ b/src/test/java/com/joycrew/backend/security/WithMockUserPrincipalSecurityContextFactory.java @@ -1,11 +1,15 @@ package com.joycrew.backend.security; +import com.joycrew.backend.entity.Company; import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.enums.AdminLevel; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.test.context.support.WithSecurityContextFactory; +import java.time.LocalDateTime; + public class WithMockUserPrincipalSecurityContextFactory implements WithSecurityContextFactory { @Override public SecurityContext createSecurityContext(WithMockUserPrincipal annotation) { @@ -14,8 +18,12 @@ public SecurityContext createSecurityContext(WithMockUserPrincipal annotation) { .employeeId(1L) .email(annotation.email()) .employeeName("테스트유저") - .role(UserRole.EMPLOYEE) + .role(AdminLevel.EMPLOYEE) .passwordHash("mockPassword") + .company(Company.builder().companyId(1L).companyName("조이크루").build()) + .status("ACTIVE") + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) .build(); UserPrincipal principal = new UserPrincipal(mockEmployee); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( diff --git a/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java index 20eb26b..026a4ea 100644 --- a/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java +++ b/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java @@ -5,6 +5,7 @@ import com.joycrew.backend.dto.LoginRequest; import com.joycrew.backend.dto.LoginResponse; import com.joycrew.backend.entity.Company; +import com.joycrew.backend.entity.enums.AdminLevel; import com.joycrew.backend.repository.CompanyRepository; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.security.JwtUtil; @@ -53,7 +54,7 @@ void setUp() { defaultCompany.getCompanyName(), null, "사원", - UserRole.EMPLOYEE + AdminLevel.EMPLOYEE ); adminEmployeeService.registerEmployee(request); } @@ -74,7 +75,7 @@ void login_Integration_Success() { assertThat(response.email()).isEqualTo(testEmail); assertThat(response.userId()).isEqualTo(employeeRepository.findByEmail(testEmail).get().getEmployeeId()); assertThat(response.name()).isEqualTo(testName); - assertThat(response.role()).isEqualTo(UserRole.EMPLOYEE); + assertThat(response.role()).isEqualTo(AdminLevel.EMPLOYEE); String extractedEmail = jwtUtil.getEmailFromToken(response.accessToken()); assertThat(extractedEmail).isEqualTo(testEmail); diff --git a/src/test/java/com/joycrew/backend/service/AuthServiceTest.java b/src/test/java/com/joycrew/backend/service/AuthServiceTest.java index 3cc3239..771bb30 100644 --- a/src/test/java/com/joycrew/backend/service/AuthServiceTest.java +++ b/src/test/java/com/joycrew/backend/service/AuthServiceTest.java @@ -3,6 +3,7 @@ import com.joycrew.backend.dto.LoginRequest; import com.joycrew.backend.dto.LoginResponse; import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.enums.AdminLevel; import com.joycrew.backend.security.JwtUtil; import com.joycrew.backend.security.UserPrincipal; import org.junit.jupiter.api.BeforeEach; @@ -44,7 +45,7 @@ void setUp() { .email("test@joycrew.com") .passwordHash("encodedPassword") .employeeName("테스트유저") - .role(UserRole.EMPLOYEE) + .role(AdminLevel.EMPLOYEE) .status("ACTIVE") .build(); diff --git a/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java index d6912ee..22617ca 100644 --- a/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java +++ b/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java @@ -5,6 +5,7 @@ import com.joycrew.backend.entity.Company; import com.joycrew.backend.entity.Department; import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.enums.AdminLevel; import com.joycrew.backend.repository.CompanyRepository; import com.joycrew.backend.repository.DepartmentRepository; import com.joycrew.backend.repository.EmployeeRepository; @@ -53,7 +54,7 @@ void registerEmployee_Success() { // Given EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( "신규직원", "new.employee@joycrew.com", "password123!", - testCompany.getCompanyName(), testDepartment.getName(), "사원", UserRole.EMPLOYEE + testCompany.getCompanyName(), testDepartment.getName(), "사원", AdminLevel.EMPLOYEE ); // When From f948b9dc6d54185f006c14ea590ddc4601794822 Mon Sep 17 00:00:00 2001 From: yeoEun Date: Tue, 5 Aug 2025 17:04:38 +0900 Subject: [PATCH 069/135] feat: admin-employee-query --- .../controller/AdminEmployeeController.java | 91 ++++++++++--------- .../dto/AdminEmployeeQueryResponse.java | 40 ++++++++ .../dto/AdminPagedEmployeeResponse.java | 19 ++++ .../joycrew/backend/dto/GiftPointRequest.java | 7 ++ .../backend/service/AdminEmployeeService.java | 76 +++++++++++++--- .../AdminEmployeeControllerTest.java | 63 ++++++++++--- 6 files changed, 225 insertions(+), 71 deletions(-) create mode 100644 src/main/java/com/joycrew/backend/dto/AdminEmployeeQueryResponse.java create mode 100644 src/main/java/com/joycrew/backend/dto/AdminPagedEmployeeResponse.java diff --git a/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java b/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java index 2c863a3..f3fd2f5 100644 --- a/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java +++ b/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java @@ -1,8 +1,8 @@ package com.joycrew.backend.controller; +import com.joycrew.backend.dto.AdminPagedEmployeeResponse; import com.joycrew.backend.dto.EmployeeRegistrationRequest; import com.joycrew.backend.dto.EmployeeRegistrationSuccessResponse; -import com.joycrew.backend.entity.Employee; import com.joycrew.backend.service.AdminEmployeeService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -10,6 +10,7 @@ import io.swagger.v3.oas.annotations.media.ExampleObject; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -19,7 +20,7 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -@Tag(name = "직원 관리", description = "HR 관리자의 단일 직원 등록 API") +@Tag(name = "직원 관리", description = "HR 관리자의 직원 등록 및 조회 API") @RestController @RequestMapping("/api/admin/employees") @RequiredArgsConstructor @@ -30,23 +31,13 @@ public class AdminEmployeeController { @Operation( summary = "직원 등록", description = "HR 관리자가 단일 직원을 등록합니다.", - security = @SecurityRequirement(name = "Authorization"), - responses = { - @ApiResponse( - responseCode = "200", - description = "직원 등록 성공", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = EmployeeRegistrationSuccessResponse.class) - ) - ) - } + security = @SecurityRequirement(name = "Authorization") ) @PostMapping public ResponseEntity registerEmployee( @Valid @RequestBody EmployeeRegistrationRequest request ) { - Employee created = adminEmployeeService.registerEmployee(request); + var created = adminEmployeeService.registerEmployee(request); return ResponseEntity.ok( new EmployeeRegistrationSuccessResponse("직원 생성 완료 (ID: " + created.getEmployeeId() + ")") ); @@ -55,35 +46,11 @@ public ResponseEntity registerEmployee( @Operation( summary = "직원 일괄 등록 (CSV)", description = """ - HR 관리자가 CSV 파일을 업로드하여 여러 직원을 등록합니다. - CSV는 다음의 헤더를 포함해야 합니다: - name,email,initialPassword,companyName,departmentName,position,role - """, - security = @SecurityRequirement(name = "Authorization"), - responses = { - @ApiResponse( - responseCode = "200", - description = "직원 일괄 등록 완료", - content = @Content( - mediaType = "application/json", - examples = @ExampleObject( - name = "성공 응답 예시", - value = "\"CSV 업로드 및 직원 등록이 완료되었습니다.\"" - ) - ) - ), - @ApiResponse( - responseCode = "400", - description = "잘못된 요청 형식 또는 CSV 파싱 오류", - content = @Content( - mediaType = "application/json", - examples = @ExampleObject( - name = "실패 응답 예시", - value = "\"CSV 파일 읽기 실패\"" - ) - ) - ) - } + HR 관리자가 CSV 파일을 업로드하여 여러 직원을 등록합니다. + CSV는 다음의 헤더를 포함해야 합니다: + name,email,initialPassword,companyName,departmentName,position,role + """, + security = @SecurityRequirement(name = "Authorization") ) @PostMapping(value = "/bulk", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity registerEmployeesFromCsv( @@ -93,4 +60,42 @@ public ResponseEntity registerEmployeesFromCsv( adminEmployeeService.registerEmployeesFromCsv(file); return ResponseEntity.ok("CSV 업로드 및 직원 등록이 완료되었습니다."); } + + @Operation( + summary = "전체 직원 목록 조회 (검색 포함)", + description = "HR 관리자가 전체 직원 목록을 조회하거나 이름, 이메일, 부서명 기준으로 검색할 수 있습니다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "직원 목록 조회 성공", content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = AdminPagedEmployeeResponse.class), + examples = @ExampleObject(value = """ + { + "employees": [ + { + "employeeId": 1, + "employeeName": "김여은", + "email": "kye02@example.com", + "departmentName": "인사팀", + "position": "사원", + "profileImageUrl": "https://cdn.joycrew.com/profile/1.jpg", + "adminLevel": "EMPLOYEE" + } + ], + "currentPage": 1, + "totalPages": 1, + "last": true + } + """) + )) + }) + @GetMapping + public ResponseEntity searchEmployees( + @Parameter(description = "이름, 이메일, 부서명 중 일부로 검색") @RequestParam(required = false) String keyword, + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지당 직원 수", example = "10") @RequestParam(defaultValue = "10") int size + ) { + AdminPagedEmployeeResponse result = adminEmployeeService.searchEmployees(keyword, page, size); + return ResponseEntity.ok(result); + } } diff --git a/src/main/java/com/joycrew/backend/dto/AdminEmployeeQueryResponse.java b/src/main/java/com/joycrew/backend/dto/AdminEmployeeQueryResponse.java new file mode 100644 index 0000000..1e8e7be --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/AdminEmployeeQueryResponse.java @@ -0,0 +1,40 @@ +package com.joycrew.backend.dto; + +import com.joycrew.backend.entity.Employee; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "관리자용 직원 검색 응답 DTO") +public record AdminEmployeeQueryResponse( + @Schema(description = "직원 ID", example = "1") + Long employeeId, + + @Schema(description = "직원 이름", example = "김조이") + String employeeName, + + @Schema(description = "직원 이메일", example = "joy@example.com") + String email, + + @Schema(description = "부서명", example = "개발팀") + String departmentName, + + @Schema(description = "직책", example = "백엔드 개발자") + String position, + + @Schema(description = "프로필 이미지 URL") + String profileImageUrl, + + @Schema(description = "직원 권한 등급", example = "HR_ADMIN") + String adminLevel +) { + public static AdminEmployeeQueryResponse from(Employee employee) { + return new AdminEmployeeQueryResponse( + employee.getEmployeeId(), + employee.getEmployeeName(), + employee.getEmail(), + employee.getDepartment() != null ? employee.getDepartment().getName() : null, + employee.getPosition(), + employee.getProfileImageUrl(), + employee.getRole().name() + ); + } +} diff --git a/src/main/java/com/joycrew/backend/dto/AdminPagedEmployeeResponse.java b/src/main/java/com/joycrew/backend/dto/AdminPagedEmployeeResponse.java new file mode 100644 index 0000000..058e8bc --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/AdminPagedEmployeeResponse.java @@ -0,0 +1,19 @@ +package com.joycrew.backend.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "관리자용 직원 목록 페이징 응답 DTO") +public record AdminPagedEmployeeResponse( + @Schema(description = "직원 목록") + List employees, + + @Schema(description = "현재 페이지 번호") + int currentPage, + + @Schema(description = "전체 페이지 수") + int totalPages, + + @Schema(description = "마지막 페이지 여부") + boolean last +) {} diff --git a/src/main/java/com/joycrew/backend/dto/GiftPointRequest.java b/src/main/java/com/joycrew/backend/dto/GiftPointRequest.java index c0845e3..3950dc3 100644 --- a/src/main/java/com/joycrew/backend/dto/GiftPointRequest.java +++ b/src/main/java/com/joycrew/backend/dto/GiftPointRequest.java @@ -1,23 +1,30 @@ package com.joycrew.backend.dto; import com.joycrew.backend.entity.enums.Tag; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import java.util.List; +@Schema(description = "포인트 선물 요청 DTO") public record GiftPointRequest( + + @Schema(description = "포인트를 받을 직원의 ID", example = "2") @NotNull(message = "받는 사람 ID는 필수입니다.") Long receiverId, + @Schema(description = "선물할 포인트 수", example = "50", minimum = "1") @NotNull(message = "포인트는 필수입니다.") @Min(value = 1, message = "포인트는 1 이상이어야 합니다.") int points, + @Schema(description = "응원의 메시지 (선택 사항, 최대 255자)", example = "이번 프로젝트 수고하셨어요!") @Size(max = 255, message = "메시지는 255자를 초과할 수 없습니다.") String message, + @Schema(description = "포인트 선물에 함께 전달할 태그 목록 (최소 1개, 최대 3개)", example = "[\"TEAMWORK\", \"LEADERSHIP\"]") @NotNull(message = "태그는 필수입니다.") @Size(min = 1, max = 3, message = "태그는 최소 1개, 최대 3개까지 선택 가능합니다.") List tags diff --git a/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java b/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java index 46fc94c..bbe1e9d 100644 --- a/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java +++ b/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java @@ -1,6 +1,6 @@ package com.joycrew.backend.service; -import com.joycrew.backend.dto.EmployeeRegistrationRequest; +import com.joycrew.backend.dto.*; import com.joycrew.backend.entity.Company; import com.joycrew.backend.entity.Department; import com.joycrew.backend.entity.Employee; @@ -10,6 +10,9 @@ import com.joycrew.backend.repository.DepartmentRepository; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.WalletRepository; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.TypedQuery; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.crypto.password.PasswordEncoder; @@ -21,6 +24,7 @@ import java.io.IOException; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; +import java.util.List; @Slf4j @Service @@ -34,6 +38,9 @@ public class AdminEmployeeService { private final WalletRepository walletRepository; private final PasswordEncoder passwordEncoder; + @PersistenceContext + private final EntityManager em; + public Employee registerEmployee(EmployeeRegistrationRequest request) { if (employeeRepository.findByEmail(request.email()).isPresent()) { throw new IllegalStateException("이미 사용 중인 이메일입니다."); @@ -78,7 +85,7 @@ public void registerEmployeesFromCsv(MultipartFile file) { } String[] tokens = line.split(","); - if (tokens.length < 6) { // 최소 6개 필드 필요 (level은 optional) + if (tokens.length < 6) { log.warn("누락된 필드가 있는 행 건너뜀: {}", line); continue; } @@ -87,13 +94,13 @@ public void registerEmployeesFromCsv(MultipartFile file) { AdminLevel adminLevel = parseAdminLevel(tokens.length > 6 ? tokens[6].trim() : "EMPLOYEE"); EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( - tokens[0].trim(), // name - tokens[1].trim(), // email - tokens[2].trim(), // password - tokens[3].trim(), // companyName - tokens[4].trim().isBlank() ? null : tokens[4].trim(), // departmentName (nullable) - tokens[5].trim(), // position - adminLevel // level + tokens[0].trim(), + tokens[1].trim(), + tokens[2].trim(), + tokens[3].trim(), + tokens[4].trim().isBlank() ? null : tokens[4].trim(), + tokens[5].trim(), + adminLevel ); registerEmployee(request); } catch (Exception e) { @@ -106,12 +113,9 @@ public void registerEmployeesFromCsv(MultipartFile file) { } } - /** - * 문자열을 AdminLevel enum으로 변환 - */ private AdminLevel parseAdminLevel(String level) { if (level == null || level.isBlank()) { - return AdminLevel.EMPLOYEE; // 기본값 + return AdminLevel.EMPLOYEE; } try { @@ -121,4 +125,48 @@ private AdminLevel parseAdminLevel(String level) { return AdminLevel.EMPLOYEE; } } -} \ No newline at end of file + + @Transactional(readOnly = true) + public AdminPagedEmployeeResponse searchEmployees(String keyword, int page, int size) { + StringBuilder whereClause = new StringBuilder("WHERE 1=1 "); + if (keyword != null && !keyword.isBlank()) { + whereClause.append("AND (LOWER(e.employeeName) LIKE :keyword ") + .append("OR LOWER(e.email) LIKE :keyword ") + .append("OR LOWER(d.name) LIKE :keyword) "); + } + + // 총 개수 조회 + String countJpql = "SELECT COUNT(e) FROM Employee e LEFT JOIN e.department d " + whereClause; + TypedQuery countQuery = em.createQuery(countJpql, Long.class); + if (keyword != null && !keyword.isBlank()) { + countQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); + } + long total = countQuery.getSingleResult(); + int totalPages = (int) Math.ceil((double) total / size); + + // 데이터 조회 + String dataJpql = "SELECT e FROM Employee e " + + "LEFT JOIN FETCH e.department d " + + "LEFT JOIN FETCH e.company c " + + whereClause + + "ORDER BY e.employeeName ASC"; + TypedQuery dataQuery = em.createQuery(dataJpql, Employee.class) + .setFirstResult(page * size) + .setMaxResults(size); + if (keyword != null && !keyword.isBlank()) { + dataQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); + } + + // Admin 전용 DTO로 변환 + List employees = dataQuery.getResultList().stream() + .map(AdminEmployeeQueryResponse::from) + .toList(); + + return new AdminPagedEmployeeResponse( + employees, + page + 1, + totalPages, + page >= totalPages - 1 + ); + } +} diff --git a/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java b/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java index ff4574d..b4c50d3 100644 --- a/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java @@ -1,6 +1,8 @@ package com.joycrew.backend.controller; import com.fasterxml.jackson.databind.ObjectMapper; +import com.joycrew.backend.dto.AdminEmployeeQueryResponse; +import com.joycrew.backend.dto.AdminPagedEmployeeResponse; import com.joycrew.backend.dto.EmployeeRegistrationRequest; import com.joycrew.backend.entity.Company; import com.joycrew.backend.entity.Department; @@ -19,11 +21,11 @@ import org.springframework.test.web.servlet.MockMvc; import java.nio.charset.StandardCharsets; +import java.util.List; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(controllers = AdminEmployeeController.class, @@ -39,18 +41,17 @@ class AdminEmployeeControllerTest { @WithMockUser(roles = "HR_ADMIN") @DisplayName("POST /api/admin/employees - 직원 등록 성공") void registerEmployee_success() throws Exception { - // Given - 요청 DTO (회사명/부서명 기반) + // Given EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( - "김여은", // name - "kye02@example.com", // email - "password123!", // initialPassword - "조이크루", // companyName - "인사팀", // departmentName - "사원", // position - AdminLevel.EMPLOYEE // role + "김여은", + "kye02@example.com", + "password123!", + "조이크루", + "인사팀", + "사원", + AdminLevel.EMPLOYEE ); - // Given - 서비스가 반환할 Employee mock 객체 Employee mockEmployee = Employee.builder() .employeeId(1L) .employeeName("김여은") @@ -64,7 +65,6 @@ void registerEmployee_success() throws Exception { when(adminEmployeeService.registerEmployee(any(EmployeeRegistrationRequest.class))) .thenReturn(mockEmployee); - // When & Then - 요청 수행 및 응답 검증 mockMvc.perform(post("/api/admin/employees") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) @@ -76,7 +76,6 @@ void registerEmployee_success() throws Exception { @WithMockUser(roles = "HR_ADMIN") @DisplayName("POST /api/admin/employees/bulk - 직원 일괄 등록 성공") void registerEmployeesFromCsv_success() throws Exception { - // Given: 예제 CSV 내용 String csvContent = """ name,email,initialPassword,companyName,departmentName,position,role 김여은,kye02@example.com,password123,조이크루,인사팀,사원,EMPLOYEE @@ -89,7 +88,6 @@ void registerEmployeesFromCsv_success() throws Exception { csvContent.getBytes(StandardCharsets.UTF_8) ); - // When & Then mockMvc.perform(multipart("/api/admin/employees/bulk") .file(file) .contentType(MediaType.MULTIPART_FORM_DATA)) @@ -97,4 +95,41 @@ void registerEmployeesFromCsv_success() throws Exception { .andExpect(content().string("CSV 업로드 및 직원 등록이 완료되었습니다.")); } + @Test + @WithMockUser(roles = "HR_ADMIN") + @DisplayName("GET /api/admin/employees - 전체 직원 목록 조회 (검색 포함)") + void searchEmployees_success() throws Exception { + AdminEmployeeQueryResponse employeeDto = new AdminEmployeeQueryResponse( + 1L, + "김여은", + "kye02@example.com", + "조이크루", + "인사팀", + "사원", + "https://cdn.joycrew.com/profile/1.jpg" + ); + + + AdminPagedEmployeeResponse pagedResponse = new AdminPagedEmployeeResponse( + List.of(employeeDto), + 1, + 1, + true + ); + + when(adminEmployeeService.searchEmployees(any(), any(), any())) + .thenReturn(pagedResponse); + + mockMvc.perform(get("/api/admin/employees") + .param("keyword", "김여은") + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalPages").value(1)) + .andExpect(jsonPath("$.currentPage").value(1)) + .andExpect(jsonPath("$.last").value(true)) + .andExpect(jsonPath("$.employees[0].employeeName").value("김여은")) + .andExpect(jsonPath("$.employees[0].email").value("kye02@example.com")) + .andExpect(jsonPath("$.employees[0].adminLevel").value("EMPLOYEE")); + } } From 150c560a26c531dc3530257d77d4323b2c9e812e Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Wed, 6 Aug 2025 21:39:25 +0900 Subject: [PATCH 070/135] =?UTF-8?q?hotfix=20:=20=EA=B8=B4=EA=B8=89=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AuthControllerTest.java | 9 +++-- .../EmployeeQueryControllerTest.java | 2 - .../controller/GiftPointControllerTest.java | 5 ++- .../backend/service/AuthServiceTest.java | 38 +++++++++++++++++++ 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java b/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java index 0fc438e..5fe4621 100644 --- a/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java @@ -60,7 +60,10 @@ void login_Success() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.accessToken").value("mocked.jwt.token")) .andExpect(jsonPath("$.message").value("로그인 성공")) - .andExpect(jsonPath("$.adminLevel").value("EMPLOYEE")); + .andExpect(jsonPath("$.userId").value(1L)) + .andExpect(jsonPath("$.email").value("test@joycrew.com")) + .andExpect(jsonPath("$.name").value("테스트유저")) + .andExpect(jsonPath("$.role").value("EMPLOYEE")); // <-- 이 부분을 수정합니다. } @Test @@ -74,7 +77,7 @@ void login_Failure_AuthenticationError() throws Exception { mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isUnauthorized()) // GlobalExceptionHandler에 따라 401 반환 + .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.code").value("AUTHENTICATION_FAILED")); } @@ -88,4 +91,4 @@ void logout_Success() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("로그아웃 되었습니다.")); } -} +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java b/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java index aee2b70..e549a94 100644 --- a/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java @@ -13,7 +13,6 @@ import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; import java.util.List; @@ -21,7 +20,6 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(controllers = EmployeeQueryController.class) diff --git a/src/test/java/com/joycrew/backend/controller/GiftPointControllerTest.java b/src/test/java/com/joycrew/backend/controller/GiftPointControllerTest.java index 4c442ba..d05df26 100644 --- a/src/test/java/com/joycrew/backend/controller/GiftPointControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/GiftPointControllerTest.java @@ -12,6 +12,7 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; // <-- 이 import 추가 import java.util.List; @@ -44,15 +45,15 @@ void testGiftPointsSuccess() throws Exception { List.of(Tag.TEAMWORK, Tag.INNOVATION) ); - // mock 서비스 로직 doNothing().when(giftPointService).giftPointsToColleague(any(), any()); // when & then mockMvc.perform(post("/api/gift-points") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) + .with(SecurityMockMvcRequestPostProcessors.csrf()) // <-- 이 라인 추가 ) .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("포인트를 성공적으로 보냈습니다.")); } -} +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/AuthServiceTest.java b/src/test/java/com/joycrew/backend/service/AuthServiceTest.java index 771bb30..4118301 100644 --- a/src/test/java/com/joycrew/backend/service/AuthServiceTest.java +++ b/src/test/java/com/joycrew/backend/service/AuthServiceTest.java @@ -3,7 +3,9 @@ import com.joycrew.backend.dto.LoginRequest; import com.joycrew.backend.dto.LoginResponse; import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; import com.joycrew.backend.entity.enums.AdminLevel; +import com.joycrew.backend.repository.WalletRepository; import com.joycrew.backend.security.JwtUtil; import com.joycrew.backend.security.UserPrincipal; import org.junit.jupiter.api.BeforeEach; @@ -13,10 +15,15 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.quality.Strictness; +import org.mockito.junit.jupiter.MockitoSettings; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -24,12 +31,15 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) class AuthServiceTest { @Mock private JwtUtil jwtUtil; @Mock private AuthenticationManager authenticationManager; + @Mock + private WalletRepository walletRepository; @InjectMocks private AuthService authService; @@ -61,6 +71,11 @@ void login_Success() { when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) .thenReturn(successfulAuth); + Wallet mockWallet = mock(Wallet.class); + when(mockWallet.getBalance()).thenReturn(1000); + when(mockWallet.getGiftablePoint()).thenReturn(500); + when(walletRepository.findByEmployee_EmployeeId(anyLong())).thenReturn(Optional.of(mockWallet)); + when(jwtUtil.generateToken(anyString())).thenReturn(testToken); // When @@ -72,9 +87,13 @@ void login_Success() { assertThat(response.message()).isEqualTo("로그인 성공"); assertThat(response.userId()).isEqualTo(testEmployee.getEmployeeId()); assertThat(response.email()).isEqualTo(testEmployee.getEmail()); + assertThat(response.role()).isEqualTo(testEmployee.getRole()); + // assertThat(response.totalPoint()).isEqualTo(1000); // DTO에 totalPoint 필드가 있다면 추가 + // assertThat(response.profileImageUrl()).isEqualTo(testEmployee.getProfileImageUrl()); // DTO에 profileImageUrl 필드가 있다면 추가 verify(authenticationManager, times(1)).authenticate(any(UsernamePasswordAuthenticationToken.class)); verify(jwtUtil, times(1)).generateToken(testEmployee.getEmail()); + verify(walletRepository, times(1)).findByEmployee_EmployeeId(testEmployee.getEmployeeId()); } @Test @@ -87,5 +106,24 @@ void login_Failure_WrongPassword() { // When & Then assertThatThrownBy(() -> authService.login(testLoginRequest)) .isInstanceOf(BadCredentialsException.class); + + verify(authenticationManager, times(1)).authenticate(any(UsernamePasswordAuthenticationToken.class)); + // WalletRepository는 호출되지 않음 + verify(walletRepository, never()).findByEmployee_EmployeeId(anyLong()); + } + + @Test + @DisplayName("로그인 실패 - 사용자 없음 (UsernameNotFoundException)") + void login_Failure_UserNotFound() { + // Given + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) + .thenThrow(new UsernameNotFoundException("User not found")); + + // When & Then + assertThatThrownBy(() -> authService.login(testLoginRequest)) + .isInstanceOf(UsernameNotFoundException.class); + + verify(authenticationManager, times(1)).authenticate(any(UsernamePasswordAuthenticationToken.class)); + verify(walletRepository, never()).findByEmployee_EmployeeId(anyLong()); } } \ No newline at end of file From 3edcb85a96bd83c55912a83ba2cdf50625464efd Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Thu, 7 Aug 2025 14:36:13 +0900 Subject: [PATCH 071/135] =?UTF-8?q?feat=20:=20=ED=8F=AC=EC=9D=B8=ED=8A=B8?= =?UTF-8?q?=20=EA=B1=B0=EB=9E=98=20=EB=82=B4=EC=97=AD=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TransactionHistoryController.java | 33 ++++++++++++++ .../dto/AdminPointDistributionRequest.java | 20 +++++++++ .../dto/TransactionHistoryResponse.java | 16 +++++++ .../backend/dto/UserProfileUpdateRequest.java | 17 +++++++ .../service/TransactionHistoryService.java | 45 +++++++++++++++++++ 5 files changed, 131 insertions(+) create mode 100644 src/main/java/com/joycrew/backend/controller/TransactionHistoryController.java create mode 100644 src/main/java/com/joycrew/backend/dto/AdminPointDistributionRequest.java create mode 100644 src/main/java/com/joycrew/backend/dto/TransactionHistoryResponse.java create mode 100644 src/main/java/com/joycrew/backend/dto/UserProfileUpdateRequest.java create mode 100644 src/main/java/com/joycrew/backend/service/TransactionHistoryService.java diff --git a/src/main/java/com/joycrew/backend/controller/TransactionHistoryController.java b/src/main/java/com/joycrew/backend/controller/TransactionHistoryController.java new file mode 100644 index 0000000..edfefc8 --- /dev/null +++ b/src/main/java/com/joycrew/backend/controller/TransactionHistoryController.java @@ -0,0 +1,33 @@ +package com.joycrew.backend.controller; + +import com.joycrew.backend.dto.TransactionHistoryResponse; +import com.joycrew.backend.security.UserPrincipal; +import com.joycrew.backend.service.TransactionHistoryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Tag(name = "거래 내역", description = "포인트 거래 내역 조회 API") +@RestController +@RequestMapping("/api/transactions") +@RequiredArgsConstructor +public class TransactionHistoryController { + + private final TransactionHistoryService transactionHistoryService; + + @Operation(summary = "포인트 거래 내역 조회", security = @SecurityRequirement(name = "Authorization")) + @GetMapping + public ResponseEntity> getMyTransactions( + @AuthenticationPrincipal UserPrincipal principal) { + List history = transactionHistoryService.getTransactionHistory(principal.getUsername()); + return ResponseEntity.ok(history); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/AdminPointDistributionRequest.java b/src/main/java/com/joycrew/backend/dto/AdminPointDistributionRequest.java new file mode 100644 index 0000000..728d1f6 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/AdminPointDistributionRequest.java @@ -0,0 +1,20 @@ +package com.joycrew.backend.dto; + +import com.joycrew.backend.entity.enums.TransactionType; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record AdminPointDistributionRequest( + @NotEmpty(message = "직원 ID 목록은 비어있을 수 없습니다.") + List employeeIds, + + @NotNull(message = "포인트는 필수입니다.") + int points, + + @NotNull(message = "메시지는 필수입니다.") + String message, + + @NotNull(message = "거래 유형은 필수입니다.") + TransactionType type +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/TransactionHistoryResponse.java b/src/main/java/com/joycrew/backend/dto/TransactionHistoryResponse.java new file mode 100644 index 0000000..bafb9ec --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/TransactionHistoryResponse.java @@ -0,0 +1,16 @@ +package com.joycrew.backend.dto; + +import com.joycrew.backend.entity.enums.TransactionType; +import lombok.Builder; + +import java.time.LocalDateTime; + +@Builder +public record TransactionHistoryResponse( + Long transactionId, + TransactionType type, + int amount, + String counterparty, + String message, + LocalDateTime transactionDate +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/UserProfileUpdateRequest.java b/src/main/java/com/joycrew/backend/dto/UserProfileUpdateRequest.java new file mode 100644 index 0000000..d9d9ef2 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/UserProfileUpdateRequest.java @@ -0,0 +1,17 @@ +package com.joycrew.backend.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record UserProfileUpdateRequest( + @Schema(description = "변경할 직원 이름", example = "김조이") + String name, + + @Schema(description = "변경할 프로필 이미지 URL") + String profileImageUrl, + + @Schema(description = "변경할 개인 이메일") + String personalEmail, + + @Schema(description = "변경할 연락처") + String phoneNumber +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java b/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java new file mode 100644 index 0000000..dc22271 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java @@ -0,0 +1,45 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.TransactionHistoryResponse; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.exception.UserNotFoundException; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.RewardPointTransactionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TransactionHistoryService { + + private final RewardPointTransactionRepository transactionRepository; + private final EmployeeRepository employeeRepository; + + public List getTransactionHistory(String userEmail) { + Employee user = employeeRepository.findByEmail(userEmail) + .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다: " + userEmail)); + + return transactionRepository.findBySenderOrReceiverOrderByTransactionDateDesc(user, user) + .stream() + .map(tx -> { + boolean isSender = user.equals(tx.getSender()); + int amount = isSender ? -tx.getPointAmount() : tx.getPointAmount(); + String counterparty = isSender ? tx.getReceiver().getEmployeeName() : (tx.getSender() != null ? tx.getSender().getEmployeeName() : "시스템"); + + return TransactionHistoryResponse.builder() + .transactionId(tx.getTransactionId()) + .type(tx.getType()) + .amount(amount) + .counterparty(counterparty) + .message(tx.getMessage()) + .transactionDate(tx.getTransactionDate()) + .build(); + }) + .collect(Collectors.toList()); + } +} \ No newline at end of file From 039667d5d3348c42eb0144fc28fe6dff0522882a Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Thu, 7 Aug 2025 14:43:51 +0900 Subject: [PATCH 072/135] =?UTF-8?q?feat=20:=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=A7=81=EC=9B=90=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EB=B6=84?= =?UTF-8?q?=EB=B0=B0,=20=EC=A7=81=EC=9B=90=20=EC=82=AD=EC=A0=9C,=20?= =?UTF-8?q?=EC=A7=81=EC=9B=90=20=EC=A0=95=EB=B3=B4=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8,=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EA=B1=B0?= =?UTF-8?q?=EB=9E=98=20=EB=82=B4=EC=97=AD=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AdminEmployeeController.java | 31 +++++++- .../backend/controller/UserController.java | 10 +++ .../dto/AdminEmployeeQueryResponse.java | 18 ++++- .../com/joycrew/backend/entity/Employee.java | 32 ++++++++ .../backend/service/AdminEmployeeService.java | 77 +++++++++++++++++-- .../backend/service/EmployeeService.java | 20 +++++ 6 files changed, 175 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java b/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java index f3fd2f5..5807cd6 100644 --- a/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java +++ b/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java @@ -1,8 +1,7 @@ package com.joycrew.backend.controller; -import com.joycrew.backend.dto.AdminPagedEmployeeResponse; -import com.joycrew.backend.dto.EmployeeRegistrationRequest; -import com.joycrew.backend.dto.EmployeeRegistrationSuccessResponse; +import com.joycrew.backend.dto.*; +import com.joycrew.backend.security.UserPrincipal; import com.joycrew.backend.service.AdminEmployeeService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -17,6 +16,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -98,4 +98,29 @@ public ResponseEntity searchEmployees( AdminPagedEmployeeResponse result = adminEmployeeService.searchEmployees(keyword, page, size); return ResponseEntity.ok(result); } + + @Operation(summary = "직원 정보 업데이트", security = @SecurityRequirement(name = "Authorization")) + @PatchMapping("/{employeeId}") + public ResponseEntity updateEmployee( + @PathVariable Long employeeId, + @RequestBody AdminEmployeeUpdateRequest request) { + adminEmployeeService.updateEmployee(employeeId, request); + return ResponseEntity.ok(new SuccessResponse("직원 정보가 성공적으로 업데이트되었습니다.")); + } + + @Operation(summary = "직원 삭제 (비활성화)", security = @SecurityRequirement(name = "Authorization")) + @DeleteMapping("/{employeeId}") + public ResponseEntity deleteEmployee(@PathVariable Long employeeId) { + adminEmployeeService.deleteEmployee(employeeId); + return ResponseEntity.ok(new SuccessResponse("직원이 성공적으로 삭제(비활성화) 처리되었습니다.")); + } + + @Operation(summary = "포인트 일괄 분배 및 회수", description = "포인트에 양수 값을 넣으면 분배, 음수 값을 넣으면 회수(반대 거래)됩니다.", security = @SecurityRequirement(name = "Authorization")) + @PostMapping("/points/distribute") + public ResponseEntity distributePoints( + @Valid @RequestBody AdminPointDistributionRequest request, + @AuthenticationPrincipal UserPrincipal principal) { + adminEmployeeService.distributePoints(request, principal.getEmployee()); + return ResponseEntity.ok(new SuccessResponse("포인트 분배(회수) 작업이 완료되었습니다.")); + } } diff --git a/src/main/java/com/joycrew/backend/controller/UserController.java b/src/main/java/com/joycrew/backend/controller/UserController.java index 0c6e034..ecd1212 100644 --- a/src/main/java/com/joycrew/backend/controller/UserController.java +++ b/src/main/java/com/joycrew/backend/controller/UserController.java @@ -3,6 +3,7 @@ import com.joycrew.backend.dto.PasswordChangeRequest; import com.joycrew.backend.dto.SuccessResponse; import com.joycrew.backend.dto.UserProfileResponse; +import com.joycrew.backend.dto.UserProfileUpdateRequest; import com.joycrew.backend.security.UserPrincipal; import com.joycrew.backend.service.EmployeeService; import io.swagger.v3.oas.annotations.Operation; @@ -39,4 +40,13 @@ public ResponseEntity forceChangePassword( employeeService.forcePasswordChange(principal.getUsername(), request); return ResponseEntity.ok(new SuccessResponse("비밀번호가 성공적으로 변경되었습니다.")); } + + @Operation(summary = "내 정보 수정", description = "변경을 원하는 필드만 요청 본문에 포함하여 전송합니다.", security = @SecurityRequirement(name = "Authorization")) + @PatchMapping("/profile") + public ResponseEntity updateMyProfile( + @AuthenticationPrincipal UserPrincipal principal, + @RequestBody UserProfileUpdateRequest request) { + employeeService.updateUserProfile(principal.getUsername(), request); + return ResponseEntity.ok(new SuccessResponse("내 정보가 성공적으로 수정되었습니다.")); + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/AdminEmployeeQueryResponse.java b/src/main/java/com/joycrew/backend/dto/AdminEmployeeQueryResponse.java index 1e8e7be..c95e0cc 100644 --- a/src/main/java/com/joycrew/backend/dto/AdminEmployeeQueryResponse.java +++ b/src/main/java/com/joycrew/backend/dto/AdminEmployeeQueryResponse.java @@ -3,6 +3,8 @@ import com.joycrew.backend.entity.Employee; import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; + @Schema(description = "관리자용 직원 검색 응답 DTO") public record AdminEmployeeQueryResponse( @Schema(description = "직원 ID", example = "1") @@ -24,7 +26,16 @@ public record AdminEmployeeQueryResponse( String profileImageUrl, @Schema(description = "직원 권한 등급", example = "HR_ADMIN") - String adminLevel + String adminLevel, + + @Schema(description = "생년월일", example = "1995-05-10") + LocalDate birthday, + + @Schema(description = "주소", example = "서울시 강남구 테헤란로 123") + String address, + + @Schema(description = "입사일", example = "2023-01-10") + LocalDate hireDate ) { public static AdminEmployeeQueryResponse from(Employee employee) { return new AdminEmployeeQueryResponse( @@ -34,7 +45,10 @@ public static AdminEmployeeQueryResponse from(Employee employee) { employee.getDepartment() != null ? employee.getDepartment().getName() : null, employee.getPosition(), employee.getProfileImageUrl(), - employee.getRole().name() + employee.getRole().name(), + employee.getBirthday(), + employee.getAddress(), + employee.getHireDate() ); } } diff --git a/src/main/java/com/joycrew/backend/entity/Employee.java b/src/main/java/com/joycrew/backend/entity/Employee.java index db31c74..b18b5fd 100644 --- a/src/main/java/com/joycrew/backend/entity/Employee.java +++ b/src/main/java/com/joycrew/backend/entity/Employee.java @@ -8,6 +8,7 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.crypto.password.PasswordEncoder; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collection; @@ -51,6 +52,9 @@ public class Employee implements UserDetails { private String personalEmail; private String phoneNumber; private String shippingAddress; + private LocalDate birthday; // 생일 + private String address; // 주소 + private LocalDate hireDate; private Boolean emailNotificationEnabled; private Boolean appNotificationEnabled; private String language; @@ -137,4 +141,32 @@ public boolean isEnabled() { public void changePassword(String rawPassword, PasswordEncoder encoder) { this.passwordHash = encoder.encode(rawPassword); } + + public void updateName(String newName) { + this.employeeName = newName; + } + + public void updatePosition(String newPosition) { + this.position = newPosition; + } + + public void updateRole(AdminLevel newRole) { + this.role = newRole; + } + + public void updateStatus(String newStatus) { + this.status = newStatus; + } + + public void updateProfileImageUrl(String newUrl) { + this.profileImageUrl = newUrl; + } + + public void updatePersonalEmail(String newEmail) { + this.personalEmail = newEmail; + } + + public void updatePhoneNumber(String newNumber) { + this.phoneNumber = newNumber; + } } diff --git a/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java b/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java index bbe1e9d..4e9a8d8 100644 --- a/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java +++ b/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java @@ -1,15 +1,10 @@ package com.joycrew.backend.service; import com.joycrew.backend.dto.*; -import com.joycrew.backend.entity.Company; -import com.joycrew.backend.entity.Department; -import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.*; import com.joycrew.backend.entity.enums.AdminLevel; -import com.joycrew.backend.repository.CompanyRepository; -import com.joycrew.backend.repository.DepartmentRepository; -import com.joycrew.backend.repository.EmployeeRepository; -import com.joycrew.backend.repository.WalletRepository; +import com.joycrew.backend.exception.UserNotFoundException; +import com.joycrew.backend.repository.*; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import jakarta.persistence.TypedQuery; @@ -35,6 +30,7 @@ public class AdminEmployeeService { private final EmployeeRepository employeeRepository; private final CompanyRepository companyRepository; private final DepartmentRepository departmentRepository; + private final RewardPointTransactionRepository transactionRepository; private final WalletRepository walletRepository; private final PasswordEncoder passwordEncoder; @@ -169,4 +165,69 @@ public AdminPagedEmployeeResponse searchEmployees(String keyword, int page, int page >= totalPages - 1 ); } + + public Employee updateEmployee(Long employeeId, AdminEmployeeUpdateRequest request) { + Employee employee = employeeRepository.findById(employeeId) + .orElseThrow(() -> new UserNotFoundException("ID가 " + employeeId + "인 직원을 찾을 수 없습니다.")); + + if (request.name() != null) { + employee.updateName(request.name()); + } + if (request.departmentId() != null) { + Department department = departmentRepository.findById(request.departmentId()) + .orElseThrow(() -> new IllegalArgumentException("ID가 " + request.departmentId() + "인 부서를 찾을 수 없습니다.")); + employee.assignToDepartment(department); + } + if (request.position() != null) { + employee.updatePosition(request.position()); + } + if (request.level() != null) { + employee.updateRole(request.level()); + } + if (request.status() != null) { + employee.updateStatus(request.status()); + } + return employeeRepository.save(employee); + } + + public void deleteEmployee(Long employeeId) { + Employee employee = employeeRepository.findById(employeeId) + .orElseThrow(() -> new UserNotFoundException("ID가 " + employeeId + "인 직원을 찾을 수 없습니다.")); + employee.updateStatus("DELETED"); + employeeRepository.save(employee); + } + + public void distributePoints(AdminPointDistributionRequest request, Employee admin) { + List employees = employeeRepository.findAllById(request.employeeIds()); + if (employees.size() != request.employeeIds().size()) { + throw new UserNotFoundException("일부 직원을 찾을 수 없습니다. 요청을 확인해주세요."); + } + + for (Employee employee : employees) { + Wallet wallet = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) + .orElseThrow(() -> new IllegalStateException(employee.getEmployeeName() + "님의 지갑이 없습니다.")); + + if (request.points() > 0) { + wallet.addPoints(request.points()); + } else { + wallet.spendPoints(Math.abs(request.points())); + } + + RewardPointTransaction transaction = RewardPointTransaction.builder() + .sender(admin) + .receiver(employee) + .pointAmount(request.points()) + .message(request.message()) + .type(request.type()) + .build(); + transactionRepository.save(transaction); + } + } + + @Transactional(readOnly = true) + public List getAllEmployees() { + return employeeRepository.findAll().stream() + .map(AdminEmployeeQueryResponse::from) + .toList(); + } } diff --git a/src/main/java/com/joycrew/backend/service/EmployeeService.java b/src/main/java/com/joycrew/backend/service/EmployeeService.java index 61fcccd..cd89f6d 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeService.java @@ -2,6 +2,7 @@ import com.joycrew.backend.dto.PasswordChangeRequest; import com.joycrew.backend.dto.UserProfileResponse; +import com.joycrew.backend.dto.UserProfileUpdateRequest; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.Wallet; import com.joycrew.backend.exception.UserNotFoundException; @@ -36,4 +37,23 @@ public void forcePasswordChange(String userEmail, PasswordChangeRequest request) .orElseThrow(() -> new UserNotFoundException("인증된 사용자를 찾을 수 없습니다.")); employee.changePassword(request.newPassword(), passwordEncoder); } + + public void updateUserProfile(String userEmail, UserProfileUpdateRequest request) { + Employee employee = employeeRepository.findByEmail(userEmail) + .orElseThrow(() -> new UserNotFoundException("인증된 사용자를 찾을 수 없습니다.")); + + if (request.name() != null) { + employee.updateName(request.name()); + } + if (request.profileImageUrl() != null) { + employee.updateProfileImageUrl(request.profileImageUrl()); + } + if (request.personalEmail() != null) { + employee.updatePersonalEmail(request.personalEmail()); + } + if (request.phoneNumber() != null) { + employee.updatePhoneNumber(request.phoneNumber()); + } + employeeRepository.save(employee); + } } \ No newline at end of file From c2cbad6a522c8054dcd5d7b26991e06c7db41f3a Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Thu, 7 Aug 2025 15:13:46 +0900 Subject: [PATCH 073/135] =?UTF-8?q?test=20:=20=EA=B4=80=EB=A0=A8=20test=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 - .../backend/config/SecurityConfig.java | 4 +- .../dto/EmployeeRegistrationRequest.java | 12 +- .../backend/dto/UserProfileResponse.java | 12 +- .../backend/dto/UserProfileUpdateRequest.java | 10 +- .../com/joycrew/backend/entity/Employee.java | 8 ++ .../backend/service/AdminEmployeeService.java | 42 +++++-- .../backend/service/EmployeeService.java | 6 + .../backend/service/GiftPointService.java | 1 - src/main/resources/application-dev.yml | 39 +++++++ .../AdminEmployeeControllerTest.java | 84 ++++++++++---- .../controller/AuthControllerTest.java | 2 +- .../EmployeeQueryControllerTest.java | 2 +- .../controller/GiftPointControllerTest.java | 2 +- .../TransactionHistoryControllerTest.java | 61 ++++++++++ .../controller/UserControllerTest.java | 43 ++++--- .../service/AdminEmployeeServiceTest.java | 57 ++++++++-- .../service/AdminFeaturesIntegrationTest.java | 105 ++++++++++++++++++ .../service/AuthServiceIntegrationTest.java | 3 +- .../EmployeeServiceIntegrationTest.java | 5 +- .../TransactionHistoryServiceTest.java | 70 ++++++++++++ 21 files changed, 496 insertions(+), 73 deletions(-) create mode 100644 src/main/resources/application-dev.yml create mode 100644 src/test/java/com/joycrew/backend/controller/TransactionHistoryControllerTest.java create mode 100644 src/test/java/com/joycrew/backend/service/AdminFeaturesIntegrationTest.java create mode 100644 src/test/java/com/joycrew/backend/service/TransactionHistoryServiceTest.java diff --git a/.gitignore b/.gitignore index 3507da3..affa3e2 100644 --- a/.gitignore +++ b/.gitignore @@ -57,7 +57,6 @@ Thumbs.db .env.development.local .env.test.local .env.local -src/main/resources/application-dev.yml # Spring Boot / Maven (if also used) /target/ diff --git a/src/main/java/com/joycrew/backend/config/SecurityConfig.java b/src/main/java/com/joycrew/backend/config/SecurityConfig.java index 818fb44..1e8fe9f 100644 --- a/src/main/java/com/joycrew/backend/config/SecurityConfig.java +++ b/src/main/java/com/joycrew/backend/config/SecurityConfig.java @@ -46,8 +46,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/", "/h2-console/**", "/api/auth/login", - "/api/auth/password-reset/request", // 접근 허용 추가 - "/api/auth/password-reset/confirm", // 접근 허용 추가 + "/api/auth/password-reset/request", + "/api/auth/password-reset/confirm", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html" diff --git a/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java b/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java index 6f77b34..5a4daa3 100644 --- a/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java +++ b/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java @@ -6,6 +6,8 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; +import java.time.LocalDate; + public record EmployeeRegistrationRequest ( @NotBlank(message = "이름은 필수입니다.") String name, @@ -21,11 +23,17 @@ public record EmployeeRegistrationRequest ( @NotBlank(message = "회사명은 필수입니다.") String companyName, - String departmentName, // nullable 가능 + String departmentName, @NotBlank(message = "직책은 필수입니다.") String position, @NotNull(message = "역할은 필수입니다.") - AdminLevel level + AdminLevel level, + + LocalDate birthday, + + String address, + + LocalDate hireDate ) {} diff --git a/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java b/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java index 6ac8240..cc8929d 100644 --- a/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java +++ b/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java @@ -5,6 +5,8 @@ import com.joycrew.backend.entity.enums.AdminLevel; import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; + @Schema(description = "사용자 프로필 응답 DTO") public record UserProfileResponse( @Schema(description = "사용자 고유 ID") Long employeeId, @@ -15,7 +17,10 @@ public record UserProfileResponse( @Schema(description = "현재 선물 가능한 포인트 잔액") Integer giftableBalance, @Schema(description = "사용자 권한")AdminLevel level, @Schema(description = "소속 부서") String department, - @Schema(description = "직책") String position + @Schema(description = "직책") String position, + @Schema(description = "생년월일") LocalDate birthday, + @Schema(description = "주소") String address, + @Schema(description = "입사일") LocalDate hireDate ) { public static UserProfileResponse from(Employee employee, Wallet wallet) { String departmentName = employee.getDepartment() != null ? employee.getDepartment().getName() : null; @@ -28,7 +33,10 @@ public static UserProfileResponse from(Employee employee, Wallet wallet) { wallet.getGiftablePoint(), employee.getRole(), departmentName, - employee.getPosition() + employee.getPosition(), + employee.getBirthday(), + employee.getAddress(), + employee.getHireDate() ); } } diff --git a/src/main/java/com/joycrew/backend/dto/UserProfileUpdateRequest.java b/src/main/java/com/joycrew/backend/dto/UserProfileUpdateRequest.java index d9d9ef2..3422746 100644 --- a/src/main/java/com/joycrew/backend/dto/UserProfileUpdateRequest.java +++ b/src/main/java/com/joycrew/backend/dto/UserProfileUpdateRequest.java @@ -2,6 +2,8 @@ import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; + public record UserProfileUpdateRequest( @Schema(description = "변경할 직원 이름", example = "김조이") String name, @@ -13,5 +15,11 @@ public record UserProfileUpdateRequest( String personalEmail, @Schema(description = "변경할 연락처") - String phoneNumber + String phoneNumber, + + @Schema(description = "변경할 생년월일") + LocalDate birthday, + + @Schema(description = "변경할 주소") + String address ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/Employee.java b/src/main/java/com/joycrew/backend/entity/Employee.java index b18b5fd..e17d5fd 100644 --- a/src/main/java/com/joycrew/backend/entity/Employee.java +++ b/src/main/java/com/joycrew/backend/entity/Employee.java @@ -169,4 +169,12 @@ public void updatePersonalEmail(String newEmail) { public void updatePhoneNumber(String newNumber) { this.phoneNumber = newNumber; } + + public void updateBirthday(LocalDate birthday) { + this.birthday = birthday; + } + + public void updateAddress(String address) { + this.address = address; + } } diff --git a/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java b/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java index 4e9a8d8..43a2905 100644 --- a/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java +++ b/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java @@ -19,6 +19,8 @@ import java.io.IOException; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; import java.util.List; @Slf4j @@ -60,6 +62,9 @@ public Employee registerEmployee(EmployeeRegistrationRequest request) { .position(request.position()) .role(request.level()) .status("ACTIVE") + .birthday(request.birthday()) + .address(request.address()) + .hireDate(request.hireDate()) .build(); Employee savedEmployee = employeeRepository.save(newEmployee); @@ -87,16 +92,22 @@ public void registerEmployeesFromCsv(MultipartFile file) { } try { - AdminLevel adminLevel = parseAdminLevel(tokens.length > 6 ? tokens[6].trim() : "EMPLOYEE"); + AdminLevel adminLevel = parseAdminLevel(tokens[6].trim()); + LocalDate birthday = parseDate(tokens[7].trim()); + String address = tokens[8].trim(); + LocalDate hireDate = parseDate(tokens[9].trim()); EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( - tokens[0].trim(), - tokens[1].trim(), - tokens[2].trim(), - tokens[3].trim(), - tokens[4].trim().isBlank() ? null : tokens[4].trim(), - tokens[5].trim(), - adminLevel + tokens[0].trim(), // name + tokens[1].trim(), // email + tokens[2].trim(), // initialPassword + tokens[3].trim(), // companyName + tokens[4].trim().isBlank() ? null : tokens[4].trim(), // departmentName + tokens[5].trim(), // position + adminLevel, // level + birthday, // birthday + address, // address + hireDate // hireDate ); registerEmployee(request); } catch (Exception e) { @@ -109,6 +120,18 @@ public void registerEmployeesFromCsv(MultipartFile file) { } } + private LocalDate parseDate(String dateStr) { + if (dateStr == null || dateStr.isBlank()) { + return null; + } + try { + return LocalDate.parse(dateStr); // YYYY-MM-DD 형식 + } catch (DateTimeParseException e) { + log.warn("잘못된 날짜 형식: {}. null로 처리합니다.", dateStr); + return null; + } + } + private AdminLevel parseAdminLevel(String level) { if (level == null || level.isBlank()) { return AdminLevel.EMPLOYEE; @@ -131,7 +154,6 @@ public AdminPagedEmployeeResponse searchEmployees(String keyword, int page, int .append("OR LOWER(d.name) LIKE :keyword) "); } - // 총 개수 조회 String countJpql = "SELECT COUNT(e) FROM Employee e LEFT JOIN e.department d " + whereClause; TypedQuery countQuery = em.createQuery(countJpql, Long.class); if (keyword != null && !keyword.isBlank()) { @@ -140,7 +162,6 @@ public AdminPagedEmployeeResponse searchEmployees(String keyword, int page, int long total = countQuery.getSingleResult(); int totalPages = (int) Math.ceil((double) total / size); - // 데이터 조회 String dataJpql = "SELECT e FROM Employee e " + "LEFT JOIN FETCH e.department d " + "LEFT JOIN FETCH e.company c " + @@ -153,7 +174,6 @@ public AdminPagedEmployeeResponse searchEmployees(String keyword, int page, int dataQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); } - // Admin 전용 DTO로 변환 List employees = dataQuery.getResultList().stream() .map(AdminEmployeeQueryResponse::from) .toList(); diff --git a/src/main/java/com/joycrew/backend/service/EmployeeService.java b/src/main/java/com/joycrew/backend/service/EmployeeService.java index cd89f6d..5b410d0 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeService.java @@ -54,6 +54,12 @@ public void updateUserProfile(String userEmail, UserProfileUpdateRequest request if (request.phoneNumber() != null) { employee.updatePhoneNumber(request.phoneNumber()); } + if (request.birthday() != null) { + employee.updateBirthday(request.birthday()); + } + if (request.address() != null) { + employee.updateAddress(request.address()); + } employeeRepository.save(employee); } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/GiftPointService.java b/src/main/java/com/joycrew/backend/service/GiftPointService.java index 72f335e..786b1f1 100644 --- a/src/main/java/com/joycrew/backend/service/GiftPointService.java +++ b/src/main/java/com/joycrew/backend/service/GiftPointService.java @@ -51,7 +51,6 @@ public void giftPointsToColleague(String senderEmail, GiftPointRequest request) .build(); transactionRepository.save(transaction); - // 이벤트 발행 (예: 알림용) eventPublisher.publishEvent( new RecognitionEvent(this, sender.getEmployeeId(), receiver.getEmployeeId(), request.points(), request.message()) ); diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..96068ae --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,39 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb + driver-class-name: org.h2.Driver + username: sa + password: + h2: + console: + enabled: true + jpa: + hibernate: + ddl-auto: create + show-sql: false + mail: + host: smtp.gmail.com + port: 587 + username: joycrew.team@gmail.com + password: abcd efgh ijkl mnop + properties: + mail: + smtp: + auth: true + starttls: + enable: true + +jwt: + secret: dev-test-secret-key-for-joycrew-backend-at-least-256-bits-long! + +logging: + level: + org.hibernate.SQL: DEBUG + org.hibernate.type.descriptor.sql: TRACE + com.joycrew.backend: DEBUG + +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java b/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java index ff4574d..e04e4ca 100644 --- a/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java @@ -1,11 +1,13 @@ package com.joycrew.backend.controller; import com.fasterxml.jackson.databind.ObjectMapper; +import com.joycrew.backend.dto.AdminEmployeeUpdateRequest; +import com.joycrew.backend.dto.AdminPointDistributionRequest; import com.joycrew.backend.dto.EmployeeRegistrationRequest; -import com.joycrew.backend.entity.Company; -import com.joycrew.backend.entity.Department; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.enums.AdminLevel; +import com.joycrew.backend.entity.enums.TransactionType; +import com.joycrew.backend.security.WithMockUserPrincipal; import com.joycrew.backend.service.AdminEmployeeService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -19,11 +21,11 @@ import org.springframework.test.web.servlet.MockMvc; import java.nio.charset.StandardCharsets; +import java.util.List; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(controllers = AdminEmployeeController.class, @@ -39,32 +41,25 @@ class AdminEmployeeControllerTest { @WithMockUser(roles = "HR_ADMIN") @DisplayName("POST /api/admin/employees - 직원 등록 성공") void registerEmployee_success() throws Exception { - // Given - 요청 DTO (회사명/부서명 기반) + // Given EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( - "김여은", // name - "kye02@example.com", // email - "password123!", // initialPassword - "조이크루", // companyName - "인사팀", // departmentName - "사원", // position - AdminLevel.EMPLOYEE // role + "김여은", + "kye02@example.com", + "password123!", + "조이크루", + "인사팀", + "사원", + AdminLevel.EMPLOYEE, + null, + null, + null ); - // Given - 서비스가 반환할 Employee mock 객체 - Employee mockEmployee = Employee.builder() - .employeeId(1L) - .employeeName("김여은") - .email("kye02@example.com") - .company(Company.builder().companyId(1L).companyName("조이크루").build()) - .department(Department.builder().departmentId(1L).name("인사팀").build()) - .position("사원") - .role(AdminLevel.EMPLOYEE) - .build(); - + Employee mockEmployee = Employee.builder().employeeId(1L).build(); when(adminEmployeeService.registerEmployee(any(EmployeeRegistrationRequest.class))) .thenReturn(mockEmployee); - // When & Then - 요청 수행 및 응답 검증 + // When & Then mockMvc.perform(post("/api/admin/employees") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) @@ -97,4 +92,45 @@ void registerEmployeesFromCsv_success() throws Exception { .andExpect(content().string("CSV 업로드 및 직원 등록이 완료되었습니다.")); } + @Test + @DisplayName("PATCH /api/admin/employees/{id} - 직원 정보 업데이트 성공") + @WithMockUser(roles = "SUPER_ADMIN") + void updateEmployee_Success() throws Exception { + // Given + AdminEmployeeUpdateRequest request = new AdminEmployeeUpdateRequest("업데이트된 이름", null, "팀장", null, null); + + // When & Then + mockMvc.perform(patch("/api/admin/employees/1") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("직원 정보가 성공적으로 업데이트되었습니다.")); + } + + @Test + @DisplayName("DELETE /api/admin/employees/{id} - 직원 삭제 성공") + @WithMockUser(roles = "SUPER_ADMIN") + void deleteEmployee_Success() throws Exception { + // When & Then + mockMvc.perform(delete("/api/admin/employees/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("직원이 성공적으로 삭제(비활성화) 처리되었습니다.")); + } + + @Test + @DisplayName("POST /api/admin/points/distribute - 포인트 분배 성공") + @WithMockUserPrincipal(role="SUPER_ADMIN") + void distributePoints_Success() throws Exception { + // Given + AdminPointDistributionRequest request = new AdminPointDistributionRequest( + List.of(1L, 2L), 100, "보너스", TransactionType.ADMIN_ADJUSTMENT); + + // When & Then + mockMvc.perform(post("/api/admin/employees/points/distribute") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("포인트 분배(회수) 작업이 완료되었습니다.")); + } + } diff --git a/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java b/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java index 5fe4621..7fd95f8 100644 --- a/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java @@ -63,7 +63,7 @@ void login_Success() throws Exception { .andExpect(jsonPath("$.userId").value(1L)) .andExpect(jsonPath("$.email").value("test@joycrew.com")) .andExpect(jsonPath("$.name").value("테스트유저")) - .andExpect(jsonPath("$.role").value("EMPLOYEE")); // <-- 이 부분을 수정합니다. + .andExpect(jsonPath("$.role").value("EMPLOYEE")); } @Test diff --git a/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java b/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java index e549a94..306492d 100644 --- a/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java @@ -58,7 +58,7 @@ void searchEmployees_success() throws Exception { .andExpect(jsonPath("$.employees[0].departmentName").value("인사팀")) .andExpect(jsonPath("$.currentPage").value(0)) .andExpect(jsonPath("$.totalPages").value(1)) - .andExpect(jsonPath("$.isLastPage").value(true)); // 수정: lastPage -> isLastPage + .andExpect(jsonPath("$.isLastPage").value(true)); } @Test diff --git a/src/test/java/com/joycrew/backend/controller/GiftPointControllerTest.java b/src/test/java/com/joycrew/backend/controller/GiftPointControllerTest.java index d05df26..949a98f 100644 --- a/src/test/java/com/joycrew/backend/controller/GiftPointControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/GiftPointControllerTest.java @@ -51,7 +51,7 @@ void testGiftPointsSuccess() throws Exception { mockMvc.perform(post("/api/gift-points") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) - .with(SecurityMockMvcRequestPostProcessors.csrf()) // <-- 이 라인 추가 + .with(SecurityMockMvcRequestPostProcessors.csrf()) ) .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("포인트를 성공적으로 보냈습니다.")); diff --git a/src/test/java/com/joycrew/backend/controller/TransactionHistoryControllerTest.java b/src/test/java/com/joycrew/backend/controller/TransactionHistoryControllerTest.java new file mode 100644 index 0000000..2d70f04 --- /dev/null +++ b/src/test/java/com/joycrew/backend/controller/TransactionHistoryControllerTest.java @@ -0,0 +1,61 @@ +package com.joycrew.backend.controller; + +import com.joycrew.backend.dto.TransactionHistoryResponse; +import com.joycrew.backend.entity.enums.TransactionType; +import com.joycrew.backend.security.EmployeeDetailsService; +import com.joycrew.backend.security.JwtUtil; +import com.joycrew.backend.security.WithMockUserPrincipal; +import com.joycrew.backend.service.TransactionHistoryService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(controllers = TransactionHistoryController.class) +class TransactionHistoryControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private TransactionHistoryService transactionHistoryService; + + @MockBean private JwtUtil jwtUtil; + @MockBean private EmployeeDetailsService employeeDetailsService; + + @Test + @DisplayName("GET /api/transactions - 내 거래 내역 조회 성공") + @WithMockUserPrincipal(email = "user@joycrew.com") + void getMyTransactions_Success() throws Exception { + // Given + List mockHistory = List.of( + TransactionHistoryResponse.builder() + .transactionId(1L) + .type(TransactionType.AWARD_P2P) + .amount(-50) + .counterparty("김동료") + .message("고마워요!") + .transactionDate(LocalDateTime.now()) + .build() + ); + when(transactionHistoryService.getTransactionHistory("user@joycrew.com")).thenReturn(mockHistory); + + // When & Then + mockMvc.perform(get("/api/transactions") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].transactionId").value(1L)) + .andExpect(jsonPath("$[0].amount").value(-50)) + .andExpect(jsonPath("$[0].counterparty").value("김동료")); + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/UserControllerTest.java b/src/test/java/com/joycrew/backend/controller/UserControllerTest.java index 63c882c..750110c 100644 --- a/src/test/java/com/joycrew/backend/controller/UserControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/UserControllerTest.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.joycrew.backend.dto.PasswordChangeRequest; import com.joycrew.backend.dto.UserProfileResponse; +import com.joycrew.backend.dto.UserProfileUpdateRequest; import com.joycrew.backend.entity.enums.AdminLevel; import com.joycrew.backend.exception.GlobalExceptionHandler; import com.joycrew.backend.security.WithMockUserPrincipal; @@ -16,13 +17,14 @@ import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; +import java.time.LocalDate; + import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -40,15 +42,12 @@ class UserControllerTest { void getProfile_Success() throws Exception { // Given UserProfileResponse mockResponse = new UserProfileResponse( - 1L, - "테스트유저", - "testuser@joycrew.com", + 1L, "테스트유저", "testuser@joycrew.com", "https://cdn.joycrew.com/profile/testuser.jpg", - 1500, - 100, - AdminLevel.EMPLOYEE, - "개발팀", - "사원" + 1500, 100, AdminLevel.EMPLOYEE, "개발팀", "사원", + LocalDate.of(1995, 5, 10), // birthday + "서울시 강남구", // address + LocalDate.of(2023, 1, 1) // hireDate ); when(employeeService.getUserProfile("testuser@joycrew.com")).thenReturn(mockResponse); @@ -56,11 +55,9 @@ void getProfile_Success() throws Exception { mockMvc.perform(get("/api/user/profile")) .andExpect(status().isOk()) .andExpect(jsonPath("$.name").value("테스트유저")) - .andExpect(jsonPath("$.profileImageUrl").value("https://cdn.joycrew.com/profile/testuser.jpg")) - .andExpect(jsonPath("$.totalBalance").value(1500)); + .andExpect(jsonPath("$.address").value("서울시 강남구")); } - @Test @DisplayName("POST /api/user/password - 비밀번호 변경 성공") @WithMockUserPrincipal @@ -77,4 +74,24 @@ void forceChangePassword_Success() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("비밀번호가 성공적으로 변경되었습니다.")); } + + @Test + @DisplayName("PATCH /api/user/profile - 내 정보 수정 성공") + @WithMockUserPrincipal + void updateMyProfile_Success() throws Exception { + // Given + UserProfileUpdateRequest request = new UserProfileUpdateRequest( + "새로운 내 이름", "http://new.image.url", null, null, + LocalDate.of(2000, 1, 1), // birthday + "경기도 성남시" // address + ); + + // When & Then + mockMvc.perform(patch("/api/user/profile") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("내 정보가 성공적으로 수정되었습니다.")); + } } diff --git a/src/test/java/com/joycrew/backend/service/AdminEmployeeServiceTest.java b/src/test/java/com/joycrew/backend/service/AdminEmployeeServiceTest.java index 6c2d2dd..0366afe 100644 --- a/src/test/java/com/joycrew/backend/service/AdminEmployeeServiceTest.java +++ b/src/test/java/com/joycrew/backend/service/AdminEmployeeServiceTest.java @@ -1,5 +1,6 @@ package com.joycrew.backend.service; +import com.joycrew.backend.dto.AdminEmployeeUpdateRequest; import com.joycrew.backend.dto.EmployeeRegistrationRequest; import com.joycrew.backend.entity.Company; import com.joycrew.backend.entity.Department; @@ -59,7 +60,8 @@ void setUp() { "JoyCrew", "Engineering", "Developer", - AdminLevel.EMPLOYEE + AdminLevel.EMPLOYEE, + null, null, null // birthday, address, hireDate ); mockCompany = Company.builder().companyId(1L).companyName("JoyCrew").build(); @@ -114,9 +116,9 @@ void registerEmployee_Failure_CompanyNotFound() { @DisplayName("[Service] CSV 파일로 직원 대량 등록 성공") void registerEmployeesFromCsv_Success() throws IOException { // Given - String csvContent = "name,email,initialPassword,companyName,departmentName,position,level\n" + - "김조이,joy@joycrew.com,joy123,JoyCrew,Engineering,Developer,EMPLOYEE\n" + - "박크루,crew@joycrew.com,crew123,JoyCrew,Product,PO,MANAGER"; + String csvContent = "name,email,initialPassword,companyName,departmentName,position,level,birthday,address,hireDate\n" + + "김조이,joy@joycrew.com,joy123,JoyCrew,Engineering,Developer,EMPLOYEE,1995-01-01,서울,2023-01-01\n" + + "박크루,crew@joycrew.com,crew123,JoyCrew,Product,PO,MANAGER,1990-02-02,경기,2022-02-02"; MockMultipartFile file = new MockMultipartFile("file", "employees.csv", "text/csv", csvContent.getBytes(StandardCharsets.UTF_8)); when(employeeRepository.findByEmail(anyString())).thenReturn(Optional.empty()); @@ -127,7 +129,6 @@ void registerEmployeesFromCsv_Success() throws IOException { adminEmployeeService.registerEmployeesFromCsv(file); // Then - verify(employeeRepository, times(2)).findByEmail(anyString()); verify(employeeRepository, times(2)).save(any(Employee.class)); verify(walletRepository, times(2)).save(any(Wallet.class)); } @@ -135,11 +136,10 @@ void registerEmployeesFromCsv_Success() throws IOException { @Test @DisplayName("[Service] CSV 파일 대량 등록 시 일부 행 실패해도 계속 진행") void registerEmployeesFromCsv_PartialFailure() throws IOException { - // Given - String csvContent = "name,email,initialPassword,companyName,departmentName,position,level\n" + - "김조이,joy@joycrew.com,joy123,JoyCrew,Engineering,Developer,EMPLOYEE\n" + - "이실패,fail@joycrew.com,fail123,WrongCompany,None,Intern,EMPLOYEE\n" + // 실패할 행 - "박크루,crew@joycrew.com,crew123,JoyCrew,Product,PO,MANAGER"; + String csvContent = "name,email,initialPassword,companyName,departmentName,position,level,birthday,address,hireDate\n" + + "김조이,joy@joycrew.com,joy123,JoyCrew,Engineering,Developer,EMPLOYEE,1995-01-01,서울,2023-01-01\n" + + "이실패,fail@joycrew.com,fail123,WrongCompany,None,Intern,EMPLOYEE,1999-01-01,부산,2024-01-01\n" + // 실패할 행 + "박크루,crew@joycrew.com,crew123,JoyCrew,Product,PO,MANAGER,1990-02-02,경기,2022-02-02"; MockMultipartFile file = new MockMultipartFile("file", "employees.csv", "text/csv", csvContent.getBytes(StandardCharsets.UTF_8)); when(employeeRepository.findByEmail("joy@joycrew.com")).thenReturn(Optional.empty()); @@ -154,8 +154,43 @@ void registerEmployeesFromCsv_PartialFailure() throws IOException { adminEmployeeService.registerEmployeesFromCsv(file); // Then - verify(employeeRepository, times(3)).findByEmail(anyString()); verify(employeeRepository, times(2)).save(any(Employee.class)); verify(walletRepository, times(2)).save(any(Wallet.class)); } + + @Test + @DisplayName("[Service] 직원 정보 업데이트 성공") + void updateEmployee_Success() { + // Given + Long employeeId = 1L; + AdminEmployeeUpdateRequest updateRequest = new AdminEmployeeUpdateRequest("새이름", null, "새직책", null, "INACTIVE"); + Employee mockEmployee = mock(Employee.class); + + when(employeeRepository.findById(employeeId)).thenReturn(Optional.of(mockEmployee)); + + // When + adminEmployeeService.updateEmployee(employeeId, updateRequest); + + // Then + verify(mockEmployee).updateName("새이름"); + verify(mockEmployee).updatePosition("새직책"); + verify(mockEmployee).updateStatus("INACTIVE"); + verify(employeeRepository).save(mockEmployee); + } + + @Test + @DisplayName("[Service] 직원 삭제(비활성화) 성공") + void deleteEmployee_Success() { + // Given + Long employeeId = 1L; + Employee mockEmployee = mock(Employee.class); + when(employeeRepository.findById(employeeId)).thenReturn(Optional.of(mockEmployee)); + + // When + adminEmployeeService.deleteEmployee(employeeId); + + // Then + verify(mockEmployee).updateStatus("DELETED"); + verify(employeeRepository).save(mockEmployee); + } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/AdminFeaturesIntegrationTest.java b/src/test/java/com/joycrew/backend/service/AdminFeaturesIntegrationTest.java new file mode 100644 index 0000000..90fcfc9 --- /dev/null +++ b/src/test/java/com/joycrew/backend/service/AdminFeaturesIntegrationTest.java @@ -0,0 +1,105 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.AdminEmployeeUpdateRequest; +import com.joycrew.backend.dto.AdminPointDistributionRequest; +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.AdminLevel; +import com.joycrew.backend.entity.enums.TransactionType; +import com.joycrew.backend.repository.CompanyRepository; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.RewardPointTransactionRepository; +import com.joycrew.backend.repository.WalletRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Transactional +class AdminFeaturesIntegrationTest { + + @Autowired private AdminEmployeeService adminEmployeeService; + @Autowired private EmployeeRepository employeeRepository; + @Autowired private WalletRepository walletRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private RewardPointTransactionRepository transactionRepository; + + private Employee admin, employee1, employee2; + private Company company; + + @BeforeEach + void setUp() { + company = companyRepository.save(Company.builder().companyName("통합테스트회사").build()); + admin = createAndSaveEmployee("admin@test.com", "관리자", AdminLevel.SUPER_ADMIN, 0); + employee1 = createAndSaveEmployee("emp1@test.com", "직원1", AdminLevel.EMPLOYEE, 100); + employee2 = createAndSaveEmployee("emp2@test.com", "직원2", AdminLevel.EMPLOYEE, 200); + } + + private Employee createAndSaveEmployee(String email, String name, AdminLevel level, int initialPoints) { + Employee emp = Employee.builder().email(email).employeeName(name).role(level).company(company).passwordHash("...").build(); + employeeRepository.save(emp); + Wallet wallet = new Wallet(emp); + if (initialPoints > 0) { + wallet.addPoints(initialPoints); + } + walletRepository.save(wallet); + return emp; + } + + @Test + @DisplayName("[Integration] 관리자가 직원들에게 포인트를 성공적으로 분배") + void distributePoints_Success() { + // Given + AdminPointDistributionRequest request = new AdminPointDistributionRequest( + List.of(employee1.getEmployeeId(), employee2.getEmployeeId()), + 500, + "보너스 지급", + TransactionType.ADMIN_ADJUSTMENT + ); + + // When + adminEmployeeService.distributePoints(request, admin); + + // Then + Wallet wallet1 = walletRepository.findByEmployee_EmployeeId(employee1.getEmployeeId()).get(); + Wallet wallet2 = walletRepository.findByEmployee_EmployeeId(employee2.getEmployeeId()).get(); + assertThat(wallet1.getBalance()).isEqualTo(100 + 500); + assertThat(wallet2.getBalance()).isEqualTo(200 + 500); + assertThat(transactionRepository.findAll()).anyMatch(tx -> + tx.getReceiver().equals(employee1) && tx.getPointAmount() == 500 + ); + } + + @Test + @DisplayName("[Integration] 관리자가 직원의 상태를 성공적으로 DELETED로 변경 (소프트 삭제)") + void deleteEmployee_Success() { + // When + adminEmployeeService.deleteEmployee(employee1.getEmployeeId()); + + // Then + Employee deletedEmployee = employeeRepository.findById(employee1.getEmployeeId()).get(); + assertThat(deletedEmployee.getStatus()).isEqualTo("DELETED"); + } + + @Test + @DisplayName("[Integration] 관리자가 직원의 직책을 성공적으로 업데이트") + void updateEmployee_Success() { + // Given + AdminEmployeeUpdateRequest request = new AdminEmployeeUpdateRequest(null, null, "선임 연구원", null, null); + + // When + adminEmployeeService.updateEmployee(employee1.getEmployeeId(), request); + + // Then + Employee updatedEmployee = employeeRepository.findById(employee1.getEmployeeId()).get(); + assertThat(updatedEmployee.getPosition()).isEqualTo("선임 연구원"); + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java index 026a4ea..ba96255 100644 --- a/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java +++ b/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java @@ -54,7 +54,8 @@ void setUp() { defaultCompany.getCompanyName(), null, "사원", - AdminLevel.EMPLOYEE + AdminLevel.EMPLOYEE, + null, null, null // birthday, address, hireDate ); adminEmployeeService.registerEmployee(request); } diff --git a/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java index 22617ca..e9acfc7 100644 --- a/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java +++ b/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java @@ -18,6 +18,8 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; + import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest @@ -54,7 +56,8 @@ void registerEmployee_Success() { // Given EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( "신규직원", "new.employee@joycrew.com", "password123!", - testCompany.getCompanyName(), testDepartment.getName(), "사원", AdminLevel.EMPLOYEE + testCompany.getCompanyName(), testDepartment.getName(), "사원", AdminLevel.EMPLOYEE, + LocalDate.of(1998,1,1), "서울", LocalDate.now() // birthday, address, hireDate ); // When diff --git a/src/test/java/com/joycrew/backend/service/TransactionHistoryServiceTest.java b/src/test/java/com/joycrew/backend/service/TransactionHistoryServiceTest.java new file mode 100644 index 0000000..8c7b9cc --- /dev/null +++ b/src/test/java/com/joycrew/backend/service/TransactionHistoryServiceTest.java @@ -0,0 +1,70 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.TransactionHistoryResponse; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.RewardPointTransaction; +import com.joycrew.backend.entity.enums.TransactionType; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.RewardPointTransactionRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TransactionHistoryServiceTest { + + @Mock + private RewardPointTransactionRepository transactionRepository; + @Mock + private EmployeeRepository employeeRepository; + + @InjectMocks + private TransactionHistoryService transactionHistoryService; + + @Test + @DisplayName("[Service] 포인트 거래 내역 조회 성공") + void getTransactionHistory_Success() { + // Given + String userEmail = "user@joycrew.com"; + Employee user = Employee.builder().employeeId(1L).employeeName("테스트유저").email(userEmail).build(); + Employee colleague = Employee.builder().employeeId(2L).employeeName("동료").email("colleague@joycrew.com").build(); + + RewardPointTransaction sentTx = RewardPointTransaction.builder() + .transactionId(101L).sender(user).receiver(colleague) + .pointAmount(50).type(TransactionType.AWARD_P2P) + .transactionDate(LocalDateTime.now()).build(); + + RewardPointTransaction receivedTx = RewardPointTransaction.builder() + .transactionId(102L).sender(colleague).receiver(user) + .pointAmount(100).type(TransactionType.AWARD_P2P) + .transactionDate(LocalDateTime.now().minusDays(1)).build(); + + when(employeeRepository.findByEmail(userEmail)).thenReturn(Optional.of(user)); + when(transactionRepository.findBySenderOrReceiverOrderByTransactionDateDesc(user, user)) + .thenReturn(List.of(sentTx, receivedTx)); + + // When + List history = transactionHistoryService.getTransactionHistory(userEmail); + + // Then + assertThat(history).hasSize(2); + + TransactionHistoryResponse sentResponse = history.get(0); + assertThat(sentResponse.amount()).isEqualTo(-50); + assertThat(sentResponse.counterparty()).isEqualTo("동료"); + + TransactionHistoryResponse receivedResponse = history.get(1); + assertThat(receivedResponse.amount()).isEqualTo(100); + assertThat(receivedResponse.counterparty()).isEqualTo("동료"); + } +} \ No newline at end of file From 5c53dd80f7aa54f8b4d8826c4c1e9543361775eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Thu, 7 Aug 2025 16:35:40 +0900 Subject: [PATCH 074/135] =?UTF-8?q?release=20:=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-prod.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index bdf4a04..2d63af3 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -1,6 +1,6 @@ spring: datasource: - url: ${DB_HOST} + url: ${DB_HOST}?zeroDateTimeBehavior=CONVERT_TO_NULL driver-class-name: com.mysql.cj.jdbc.Driver username: ${DB_USER} password: ${DB_PASSWORD} @@ -36,4 +36,4 @@ springdoc: api-docs: path: /v3/api-docs swagger-ui: - path: /swagger-ui.html \ No newline at end of file + path: /swagger-ui.html From 112bec6dc546b8a183afc4f9381a880eb7f2c1c2 Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Thu, 7 Aug 2025 17:30:03 +0900 Subject: [PATCH 075/135] =?UTF-8?q?refactor=20:=20=ED=95=98=EB=93=9C?= =?UTF-8?q?=EC=BD=94=EB=94=A9=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/joycrew/backend/service/AuthService.java | 9 ++++++--- .../com/joycrew/backend/service/EmailService.java | 15 +++++++++------ src/main/resources/application-prod.yml | 7 ++++--- src/main/resources/application.yml | 10 ++++++++-- 4 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/joycrew/backend/service/AuthService.java b/src/main/java/com/joycrew/backend/service/AuthService.java index 4563103..4273eb0 100644 --- a/src/main/java/com/joycrew/backend/service/AuthService.java +++ b/src/main/java/com/joycrew/backend/service/AuthService.java @@ -14,6 +14,7 @@ import lombok.RequiredArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -28,7 +29,9 @@ public class AuthService { private static final Logger log = LoggerFactory.getLogger(AuthService.class); - private static final long PASSWORD_RESET_EXPIRATION_MS = 15 * 60 * 1000; + + @Value("${jwt.password-reset-expiration-ms}") + private long passwordResetExpirationMs; private final JwtUtil jwtUtil; private final AuthenticationManager authenticationManager; @@ -85,9 +88,9 @@ public void logout(HttpServletRequest request) { @Transactional(readOnly = true) public void requestPasswordReset(String email) { employeeRepository.findByEmail(email).ifPresent(employee -> { - String token = jwtUtil.generateToken(email, PASSWORD_RESET_EXPIRATION_MS); + String token = jwtUtil.generateToken(email, passwordResetExpirationMs); emailService.sendPasswordResetEmail(email, token); - log.info("비밀번호 재설정 요청 처리: {}", email); + log.info("Password reset requested for email: {}", email); }); } diff --git a/src/main/java/com/joycrew/backend/service/EmailService.java b/src/main/java/com/joycrew/backend/service/EmailService.java index 608c1f8..32b8953 100644 --- a/src/main/java/com/joycrew/backend/service/EmailService.java +++ b/src/main/java/com/joycrew/backend/service/EmailService.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.scheduling.annotation.Async; @@ -15,21 +16,23 @@ public class EmailService { private static final Logger log = LoggerFactory.getLogger(EmailService.class); private final JavaMailSender mailSender; + @Value("${app.frontend-url}") + private String frontendUrlBase; + @Async("taskExecutor") public void sendPasswordResetEmail(String toEmail, String token) { - // TODO: 프론트엔드 URL은 실제 환경에 맞게 수정해야 합니다. - String frontendUrl = "https://joycrew.co.kr/reset-password?token=" + token; + String resetUrl = frontendUrlBase + "/reset-password?token=" + token; SimpleMailMessage message = new SimpleMailMessage(); message.setTo(toEmail); - message.setSubject("[JoyCrew] 비밀번호 재설정 안내"); - message.setText("비밀번호를 재설정하려면 아래 링크를 클릭하세요. (링크는 15분간 유효합니다)\n\n" + frontendUrl); + message.setSubject("[JoyCrew] Password Reset Instructions"); + message.setText("To reset your password, please click the link below. This link is valid for 15 minutes.\n\n" + resetUrl); try { mailSender.send(message); - log.info("비밀번호 재설정 이메일 발송 완료: {}", toEmail); + log.info("Password reset email sent successfully to: {}", toEmail); } catch (Exception e) { - log.error("비밀번호 재설정 이메일 발송 실패: {}", toEmail, e); + log.error("Failed to send password reset email to: {}", toEmail, e); } } } \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 2d63af3..20df1f4 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -19,9 +19,11 @@ spring: auth: true starttls: enable: true +app: + frontend-url: https://joycrew.co.kr + jwt: secret: ${JWT_SECRET_KEY} - expiration-ms: 3600000 logging: level: @@ -31,9 +33,8 @@ logging: max-size: 10MB max-history: 7 -# [중요] 최종 출시 시점에는 삭제 필요 springdoc: api-docs: path: /v3/api-docs swagger-ui: - path: /swagger-ui.html + path: /swagger-ui.html \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 975f23a..2aacae9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,15 +11,21 @@ server: spring: application: name: joycrew - profiles: active: dev +app: + # Base URL of the frontend application for generating links in emails + frontend-url: http://localhost:3000 + jwt: + # Default token expiration time: 1 hour expiration-ms: 3600000 + # Password reset token expiration time: 15 minutes + password-reset-expiration-ms: 900000 logging: pattern: console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" level: - com.joycrew.backend: INFO + com.joycrew.backend: INFO \ No newline at end of file From 77b0275b647ef9476433e4f3b57422a5eaa578d4 Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Thu, 7 Aug 2025 17:32:20 +0900 Subject: [PATCH 076/135] =?UTF-8?q?refactor=20:=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/joycrew/backend/controller/AdminEmployeeController.java | 2 +- .../java/com/joycrew/backend/service/AdminEmployeeService.java | 2 +- .../com/joycrew/backend/service/AdminEmployeeServiceTest.java | 2 +- .../joycrew/backend/service/AdminFeaturesIntegrationTest.java | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java b/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java index 5807cd6..2ecd3a7 100644 --- a/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java +++ b/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java @@ -111,7 +111,7 @@ public ResponseEntity updateEmployee( @Operation(summary = "직원 삭제 (비활성화)", security = @SecurityRequirement(name = "Authorization")) @DeleteMapping("/{employeeId}") public ResponseEntity deleteEmployee(@PathVariable Long employeeId) { - adminEmployeeService.deleteEmployee(employeeId); + adminEmployeeService.disableEmployee(employeeId); return ResponseEntity.ok(new SuccessResponse("직원이 성공적으로 삭제(비활성화) 처리되었습니다.")); } diff --git a/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java b/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java index 43a2905..246e347 100644 --- a/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java +++ b/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java @@ -210,7 +210,7 @@ public Employee updateEmployee(Long employeeId, AdminEmployeeUpdateRequest reque return employeeRepository.save(employee); } - public void deleteEmployee(Long employeeId) { + public void disableEmployee(Long employeeId) { Employee employee = employeeRepository.findById(employeeId) .orElseThrow(() -> new UserNotFoundException("ID가 " + employeeId + "인 직원을 찾을 수 없습니다.")); employee.updateStatus("DELETED"); diff --git a/src/test/java/com/joycrew/backend/service/AdminEmployeeServiceTest.java b/src/test/java/com/joycrew/backend/service/AdminEmployeeServiceTest.java index 0366afe..92a3441 100644 --- a/src/test/java/com/joycrew/backend/service/AdminEmployeeServiceTest.java +++ b/src/test/java/com/joycrew/backend/service/AdminEmployeeServiceTest.java @@ -187,7 +187,7 @@ void deleteEmployee_Success() { when(employeeRepository.findById(employeeId)).thenReturn(Optional.of(mockEmployee)); // When - adminEmployeeService.deleteEmployee(employeeId); + adminEmployeeService.disableEmployee(employeeId); // Then verify(mockEmployee).updateStatus("DELETED"); diff --git a/src/test/java/com/joycrew/backend/service/AdminFeaturesIntegrationTest.java b/src/test/java/com/joycrew/backend/service/AdminFeaturesIntegrationTest.java index 90fcfc9..2c08e1d 100644 --- a/src/test/java/com/joycrew/backend/service/AdminFeaturesIntegrationTest.java +++ b/src/test/java/com/joycrew/backend/service/AdminFeaturesIntegrationTest.java @@ -82,7 +82,7 @@ void distributePoints_Success() { @DisplayName("[Integration] 관리자가 직원의 상태를 성공적으로 DELETED로 변경 (소프트 삭제)") void deleteEmployee_Success() { // When - adminEmployeeService.deleteEmployee(employee1.getEmployeeId()); + adminEmployeeService.disableEmployee(employee1.getEmployeeId()); // Then Employee deletedEmployee = employeeRepository.findById(employee1.getEmployeeId()).get(); From 7c5ac1e71b6154803733fc74d8d89a0d26b2df7f Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Thu, 7 Aug 2025 17:53:43 +0900 Subject: [PATCH 077/135] =?UTF-8?q?refactor=20:=20=EA=B2=B0=ED=95=A9=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AdminEmployeeController.java | 84 +++--- .../dto/AdminEmployeeQueryResponse.java | 16 +- .../backend/dto/EmployeeQueryResponse.java | 11 - .../backend/dto/PointBalanceResponse.java | 3 - .../backend/dto/UserProfileResponse.java | 19 -- .../com/joycrew/backend/entity/Employee.java | 47 +--- .../backend/security/UserPrincipal.java | 2 +- .../backend/service/AdminEmployeeService.java | 253 ------------------ .../backend/service/AdminPointService.java | 52 ++++ .../service/EmployeeManagementService.java | 109 ++++++++ .../service/EmployeeRegistrationService.java | 140 ++++++++++ .../service/mapper/EmployeeMapper.java | 57 ++++ src/main/resources/application-dev.yml | 30 +-- src/main/resources/application-prod.yml | 30 +-- src/main/resources/application.yml | 39 ++- .../AdminEmployeeControllerTest.java | 1 - 16 files changed, 474 insertions(+), 419 deletions(-) delete mode 100644 src/main/java/com/joycrew/backend/service/AdminEmployeeService.java create mode 100644 src/main/java/com/joycrew/backend/service/AdminPointService.java create mode 100644 src/main/java/com/joycrew/backend/service/EmployeeManagementService.java create mode 100644 src/main/java/com/joycrew/backend/service/EmployeeRegistrationService.java create mode 100644 src/main/java/com/joycrew/backend/service/mapper/EmployeeMapper.java diff --git a/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java b/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java index 2ecd3a7..20eb10b 100644 --- a/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java +++ b/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java @@ -2,7 +2,9 @@ import com.joycrew.backend.dto.*; import com.joycrew.backend.security.UserPrincipal; -import com.joycrew.backend.service.AdminEmployeeService; +import com.joycrew.backend.service.AdminPointService; +import com.joycrew.backend.service.EmployeeManagementService; +import com.joycrew.backend.service.EmployeeRegistrationService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; @@ -20,53 +22,55 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -@Tag(name = "직원 관리", description = "HR 관리자의 직원 등록 및 조회 API") +@Tag(name = "Employee Administration", description = "APIs for HR administrators to manage employees.") @RestController @RequestMapping("/api/admin/employees") @RequiredArgsConstructor public class AdminEmployeeController { - private final AdminEmployeeService adminEmployeeService; + private final EmployeeRegistrationService registrationService; + private final EmployeeManagementService managementService; + private final AdminPointService pointService; @Operation( - summary = "직원 등록", - description = "HR 관리자가 단일 직원을 등록합니다.", + summary = "Register a single employee", + description = "An HR administrator registers a single employee.", security = @SecurityRequirement(name = "Authorization") ) @PostMapping public ResponseEntity registerEmployee( @Valid @RequestBody EmployeeRegistrationRequest request ) { - var created = adminEmployeeService.registerEmployee(request); + var created = registrationService.registerEmployee(request); return ResponseEntity.ok( - new EmployeeRegistrationSuccessResponse("직원 생성 완료 (ID: " + created.getEmployeeId() + ")") + new EmployeeRegistrationSuccessResponse("Employee created successfully (ID: " + created.getEmployeeId() + ")") ); } @Operation( - summary = "직원 일괄 등록 (CSV)", + summary = "Bulk register employees via CSV", description = """ - HR 관리자가 CSV 파일을 업로드하여 여러 직원을 등록합니다. - CSV는 다음의 헤더를 포함해야 합니다: - name,email,initialPassword,companyName,departmentName,position,role + An HR administrator uploads a CSV file to register multiple employees. + The CSV must include the following headers: + name,email,initialPassword,companyName,departmentName,position,role,birthday,address,hireDate """, security = @SecurityRequirement(name = "Authorization") ) @PostMapping(value = "/bulk", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity registerEmployeesFromCsv( - @Parameter(description = "CSV 파일 업로드", required = true) + public ResponseEntity registerEmployeesFromCsv( + @Parameter(description = "CSV file for upload", required = true) @RequestParam("file") MultipartFile file ) { - adminEmployeeService.registerEmployeesFromCsv(file); - return ResponseEntity.ok("CSV 업로드 및 직원 등록이 완료되었습니다."); + registrationService.registerEmployeesFromCsv(file); + return ResponseEntity.ok(new SuccessResponse("CSV processed and employee registration completed.")); } @Operation( - summary = "전체 직원 목록 조회 (검색 포함)", - description = "HR 관리자가 전체 직원 목록을 조회하거나 이름, 이메일, 부서명 기준으로 검색할 수 있습니다." + summary = "Search all employees (with filtering)", + description = "An HR administrator can search the employee list by name, email, or department." ) @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "직원 목록 조회 성공", content = @Content( + @ApiResponse(responseCode = "200", description = "Employee list retrieved successfully.", content = @Content( mediaType = "application/json", schema = @Schema(implementation = AdminPagedEmployeeResponse.class), examples = @ExampleObject(value = """ @@ -74,15 +78,18 @@ public ResponseEntity registerEmployeesFromCsv( "employees": [ { "employeeId": 1, - "employeeName": "김여은", - "email": "kye02@example.com", - "departmentName": "인사팀", - "position": "사원", + "employeeName": "Jane Doe", + "email": "jane.doe@example.com", + "departmentName": "HR", + "position": "Specialist", "profileImageUrl": "https://cdn.joycrew.com/profile/1.jpg", - "adminLevel": "EMPLOYEE" + "adminLevel": "EMPLOYEE", + "birthday": "1995-05-10", + "address": "123 Teheran-ro, Gangnam-gu, Seoul", + "hireDate": "2023-01-10" } ], - "currentPage": 1, + "currentPage": 0, "totalPages": 1, "last": true } @@ -91,36 +98,37 @@ public ResponseEntity registerEmployeesFromCsv( }) @GetMapping public ResponseEntity searchEmployees( - @Parameter(description = "이름, 이메일, 부서명 중 일부로 검색") @RequestParam(required = false) String keyword, - @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(defaultValue = "0") int page, - @Parameter(description = "페이지당 직원 수", example = "10") @RequestParam(defaultValue = "10") int size + @Parameter(description = "Search keyword for name, email, or department") @RequestParam(required = false) String keyword, + @Parameter(description = "Page number (0-based)", example = "0") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "Number of employees per page", example = "10") @RequestParam(defaultValue = "10") int size ) { - AdminPagedEmployeeResponse result = adminEmployeeService.searchEmployees(keyword, page, size); + AdminPagedEmployeeResponse result = managementService.searchEmployees(keyword, page, size); return ResponseEntity.ok(result); } - @Operation(summary = "직원 정보 업데이트", security = @SecurityRequirement(name = "Authorization")) + @Operation(summary = "Update employee information", security = @SecurityRequirement(name = "Authorization")) @PatchMapping("/{employeeId}") public ResponseEntity updateEmployee( @PathVariable Long employeeId, @RequestBody AdminEmployeeUpdateRequest request) { - adminEmployeeService.updateEmployee(employeeId, request); - return ResponseEntity.ok(new SuccessResponse("직원 정보가 성공적으로 업데이트되었습니다.")); + // 'adminEmployeeService'를 'managementService'로 수정 + managementService.updateEmployee(employeeId, request); + return ResponseEntity.ok(new SuccessResponse("Employee information updated successfully.")); } - @Operation(summary = "직원 삭제 (비활성화)", security = @SecurityRequirement(name = "Authorization")) + @Operation(summary = "Deactivate an employee (soft delete)", security = @SecurityRequirement(name = "Authorization")) @DeleteMapping("/{employeeId}") public ResponseEntity deleteEmployee(@PathVariable Long employeeId) { - adminEmployeeService.disableEmployee(employeeId); - return ResponseEntity.ok(new SuccessResponse("직원이 성공적으로 삭제(비활성화) 처리되었습니다.")); + managementService.deactivateEmployee(employeeId); + return ResponseEntity.ok(new SuccessResponse("Employee successfully deactivated.")); } - @Operation(summary = "포인트 일괄 분배 및 회수", description = "포인트에 양수 값을 넣으면 분배, 음수 값을 넣으면 회수(반대 거래)됩니다.", security = @SecurityRequirement(name = "Authorization")) + @Operation(summary = "Distribute or revoke points in bulk", description = "Use a positive value for 'points' to distribute, and a negative value to revoke.", security = @SecurityRequirement(name = "Authorization")) @PostMapping("/points/distribute") public ResponseEntity distributePoints( @Valid @RequestBody AdminPointDistributionRequest request, @AuthenticationPrincipal UserPrincipal principal) { - adminEmployeeService.distributePoints(request, principal.getEmployee()); - return ResponseEntity.ok(new SuccessResponse("포인트 분배(회수) 작업이 완료되었습니다.")); + pointService.distributePoints(request, principal.getEmployee()); + return ResponseEntity.ok(new SuccessResponse("Point distribution process completed successfully.")); } -} +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/AdminEmployeeQueryResponse.java b/src/main/java/com/joycrew/backend/dto/AdminEmployeeQueryResponse.java index c95e0cc..7b6e39f 100644 --- a/src/main/java/com/joycrew/backend/dto/AdminEmployeeQueryResponse.java +++ b/src/main/java/com/joycrew/backend/dto/AdminEmployeeQueryResponse.java @@ -26,7 +26,7 @@ public record AdminEmployeeQueryResponse( String profileImageUrl, @Schema(description = "직원 권한 등급", example = "HR_ADMIN") - String adminLevel, + String adminLevel, @Schema(description = "생년월일", example = "1995-05-10") LocalDate birthday, @@ -37,18 +37,4 @@ public record AdminEmployeeQueryResponse( @Schema(description = "입사일", example = "2023-01-10") LocalDate hireDate ) { - public static AdminEmployeeQueryResponse from(Employee employee) { - return new AdminEmployeeQueryResponse( - employee.getEmployeeId(), - employee.getEmployeeName(), - employee.getEmail(), - employee.getDepartment() != null ? employee.getDepartment().getName() : null, - employee.getPosition(), - employee.getProfileImageUrl(), - employee.getRole().name(), - employee.getBirthday(), - employee.getAddress(), - employee.getHireDate() - ); - } } diff --git a/src/main/java/com/joycrew/backend/dto/EmployeeQueryResponse.java b/src/main/java/com/joycrew/backend/dto/EmployeeQueryResponse.java index 561fd40..8557b14 100644 --- a/src/main/java/com/joycrew/backend/dto/EmployeeQueryResponse.java +++ b/src/main/java/com/joycrew/backend/dto/EmployeeQueryResponse.java @@ -20,15 +20,4 @@ public record EmployeeQueryResponse( @Schema(description = "직책", example = "백엔드 개발자") String position ) { - public static EmployeeQueryResponse from(Employee employee) { - String departmentName = (employee.getDepartment() != null) ? employee.getDepartment().getName() : null; - - return new EmployeeQueryResponse( - employee.getEmployeeId(), - employee.getProfileImageUrl(), - employee.getEmployeeName(), - departmentName, - employee.getPosition() - ); - } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/PointBalanceResponse.java b/src/main/java/com/joycrew/backend/dto/PointBalanceResponse.java index ad08ee6..67be5df 100644 --- a/src/main/java/com/joycrew/backend/dto/PointBalanceResponse.java +++ b/src/main/java/com/joycrew/backend/dto/PointBalanceResponse.java @@ -8,7 +8,4 @@ public record PointBalanceResponse( @Schema(description = "현재 잔액") Integer totalBalance, @Schema(description = "선물 가능한 포인트") Integer giftableBalance ) { - public static PointBalanceResponse from(Wallet wallet) { - return new PointBalanceResponse(wallet.getBalance(), wallet.getGiftablePoint()); - } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java b/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java index cc8929d..af3271a 100644 --- a/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java +++ b/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java @@ -1,7 +1,5 @@ package com.joycrew.backend.dto; -import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.entity.Wallet; import com.joycrew.backend.entity.enums.AdminLevel; import io.swagger.v3.oas.annotations.media.Schema; @@ -22,21 +20,4 @@ public record UserProfileResponse( @Schema(description = "주소") String address, @Schema(description = "입사일") LocalDate hireDate ) { - public static UserProfileResponse from(Employee employee, Wallet wallet) { - String departmentName = employee.getDepartment() != null ? employee.getDepartment().getName() : null; - return new UserProfileResponse( - employee.getEmployeeId(), - employee.getEmployeeName(), - employee.getEmail(), - employee.getProfileImageUrl(), - wallet.getBalance(), - wallet.getGiftablePoint(), - employee.getRole(), - departmentName, - employee.getPosition(), - employee.getBirthday(), - employee.getAddress(), - employee.getHireDate() - ); - } } diff --git a/src/main/java/com/joycrew/backend/entity/Employee.java b/src/main/java/com/joycrew/backend/entity/Employee.java index e17d5fd..cab2d13 100644 --- a/src/main/java/com/joycrew/backend/entity/Employee.java +++ b/src/main/java/com/joycrew/backend/entity/Employee.java @@ -3,16 +3,12 @@ import com.joycrew.backend.entity.enums.AdminLevel; import jakarta.persistence.*; import lombok.*; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.crypto.password.PasswordEncoder; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; import java.util.List; @Entity @@ -52,8 +48,8 @@ public class Employee implements UserDetails { private String personalEmail; private String phoneNumber; private String shippingAddress; - private LocalDate birthday; // 생일 - private String address; // 주소 + private LocalDate birthday; + private String address; private LocalDate hireDate; private Boolean emailNotificationEnabled; private Boolean appNotificationEnabled; @@ -81,6 +77,10 @@ public class Employee implements UserDetails { @OneToMany(mappedBy = "employee", cascade = CascadeType.ALL, orphanRemoval = true) private List adminAccesses = new ArrayList<>(); + public boolean isActive() { + return "ACTIVE".equals(this.status); + } + public void updateLastLogin() { this.lastLoginAt = LocalDateTime.now(); } @@ -103,41 +103,6 @@ protected void onUpdate() { this.updatedAt = LocalDateTime.now(); } - @Override - public Collection getAuthorities() { - return Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + this.role)); - } - - @Override - public String getPassword() { - return this.passwordHash; - } - - @Override - public String getUsername() { - return this.email; - } - - @Override - public boolean isAccountNonExpired() { - return true; - } - - @Override - public boolean isAccountNonLocked() { - return true; - } - - @Override - public boolean isCredentialsNonExpired() { - return true; - } - - @Override - public boolean isEnabled() { - return "ACTIVE".equals(this.status); - } - public void changePassword(String rawPassword, PasswordEncoder encoder) { this.passwordHash = encoder.encode(rawPassword); } diff --git a/src/main/java/com/joycrew/backend/security/UserPrincipal.java b/src/main/java/com/joycrew/backend/security/UserPrincipal.java index 7e66307..67b40ab 100644 --- a/src/main/java/com/joycrew/backend/security/UserPrincipal.java +++ b/src/main/java/com/joycrew/backend/security/UserPrincipal.java @@ -35,7 +35,7 @@ public String getUsername() { @Override public boolean isEnabled() { - return "ACTIVE".equals(employee.getStatus()); + return employee.isActive(); } @Override diff --git a/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java b/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java deleted file mode 100644 index 246e347..0000000 --- a/src/main/java/com/joycrew/backend/service/AdminEmployeeService.java +++ /dev/null @@ -1,253 +0,0 @@ -package com.joycrew.backend.service; - -import com.joycrew.backend.dto.*; -import com.joycrew.backend.entity.*; -import com.joycrew.backend.entity.enums.AdminLevel; -import com.joycrew.backend.exception.UserNotFoundException; -import com.joycrew.backend.repository.*; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import jakarta.persistence.TypedQuery; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; -import java.time.LocalDate; -import java.time.format.DateTimeParseException; -import java.util.List; - -@Slf4j -@Service -@RequiredArgsConstructor -@Transactional -public class AdminEmployeeService { - - private final EmployeeRepository employeeRepository; - private final CompanyRepository companyRepository; - private final DepartmentRepository departmentRepository; - private final RewardPointTransactionRepository transactionRepository; - private final WalletRepository walletRepository; - private final PasswordEncoder passwordEncoder; - - @PersistenceContext - private final EntityManager em; - - public Employee registerEmployee(EmployeeRegistrationRequest request) { - if (employeeRepository.findByEmail(request.email()).isPresent()) { - throw new IllegalStateException("이미 사용 중인 이메일입니다."); - } - - Company company = companyRepository.findByCompanyName(request.companyName()) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회사명입니다.")); - - Department department = null; - if (request.departmentName() != null && !request.departmentName().isBlank()) { - department = departmentRepository.findByCompanyAndName(company, request.departmentName()) - .orElseThrow(() -> new IllegalArgumentException("해당 회사에 존재하지 않는 부서명입니다.")); - } - - Employee newEmployee = Employee.builder() - .employeeName(request.name()) - .email(request.email()) - .passwordHash(passwordEncoder.encode(request.initialPassword())) - .company(company) - .department(department) - .position(request.position()) - .role(request.level()) - .status("ACTIVE") - .birthday(request.birthday()) - .address(request.address()) - .hireDate(request.hireDate()) - .build(); - - Employee savedEmployee = employeeRepository.save(newEmployee); - walletRepository.save(new Wallet(savedEmployee)); - return savedEmployee; - } - - public void registerEmployeesFromCsv(MultipartFile file) { - try (BufferedReader reader = new BufferedReader( - new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) { - - String line; - boolean isFirstLine = true; - - while ((line = reader.readLine()) != null) { - if (isFirstLine) { - isFirstLine = false; - continue; - } - - String[] tokens = line.split(","); - if (tokens.length < 6) { - log.warn("누락된 필드가 있는 행 건너뜀: {}", line); - continue; - } - - try { - AdminLevel adminLevel = parseAdminLevel(tokens[6].trim()); - LocalDate birthday = parseDate(tokens[7].trim()); - String address = tokens[8].trim(); - LocalDate hireDate = parseDate(tokens[9].trim()); - - EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( - tokens[0].trim(), // name - tokens[1].trim(), // email - tokens[2].trim(), // initialPassword - tokens[3].trim(), // companyName - tokens[4].trim().isBlank() ? null : tokens[4].trim(), // departmentName - tokens[5].trim(), // position - adminLevel, // level - birthday, // birthday - address, // address - hireDate // hireDate - ); - registerEmployee(request); - } catch (Exception e) { - log.warn("직원 등록 실패 - 입력값: [{}], 사유: {}", line, e.getMessage()); - } - } - - } catch (IOException e) { - throw new RuntimeException("CSV 파일 읽기 실패", e); - } - } - - private LocalDate parseDate(String dateStr) { - if (dateStr == null || dateStr.isBlank()) { - return null; - } - try { - return LocalDate.parse(dateStr); // YYYY-MM-DD 형식 - } catch (DateTimeParseException e) { - log.warn("잘못된 날짜 형식: {}. null로 처리합니다.", dateStr); - return null; - } - } - - private AdminLevel parseAdminLevel(String level) { - if (level == null || level.isBlank()) { - return AdminLevel.EMPLOYEE; - } - - try { - return AdminLevel.valueOf(level.toUpperCase()); - } catch (IllegalArgumentException e) { - log.warn("유효하지 않은 권한 레벨: {}. 기본값 EMPLOYEE로 설정합니다.", level); - return AdminLevel.EMPLOYEE; - } - } - - @Transactional(readOnly = true) - public AdminPagedEmployeeResponse searchEmployees(String keyword, int page, int size) { - StringBuilder whereClause = new StringBuilder("WHERE 1=1 "); - if (keyword != null && !keyword.isBlank()) { - whereClause.append("AND (LOWER(e.employeeName) LIKE :keyword ") - .append("OR LOWER(e.email) LIKE :keyword ") - .append("OR LOWER(d.name) LIKE :keyword) "); - } - - String countJpql = "SELECT COUNT(e) FROM Employee e LEFT JOIN e.department d " + whereClause; - TypedQuery countQuery = em.createQuery(countJpql, Long.class); - if (keyword != null && !keyword.isBlank()) { - countQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); - } - long total = countQuery.getSingleResult(); - int totalPages = (int) Math.ceil((double) total / size); - - String dataJpql = "SELECT e FROM Employee e " + - "LEFT JOIN FETCH e.department d " + - "LEFT JOIN FETCH e.company c " + - whereClause + - "ORDER BY e.employeeName ASC"; - TypedQuery dataQuery = em.createQuery(dataJpql, Employee.class) - .setFirstResult(page * size) - .setMaxResults(size); - if (keyword != null && !keyword.isBlank()) { - dataQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); - } - - List employees = dataQuery.getResultList().stream() - .map(AdminEmployeeQueryResponse::from) - .toList(); - - return new AdminPagedEmployeeResponse( - employees, - page + 1, - totalPages, - page >= totalPages - 1 - ); - } - - public Employee updateEmployee(Long employeeId, AdminEmployeeUpdateRequest request) { - Employee employee = employeeRepository.findById(employeeId) - .orElseThrow(() -> new UserNotFoundException("ID가 " + employeeId + "인 직원을 찾을 수 없습니다.")); - - if (request.name() != null) { - employee.updateName(request.name()); - } - if (request.departmentId() != null) { - Department department = departmentRepository.findById(request.departmentId()) - .orElseThrow(() -> new IllegalArgumentException("ID가 " + request.departmentId() + "인 부서를 찾을 수 없습니다.")); - employee.assignToDepartment(department); - } - if (request.position() != null) { - employee.updatePosition(request.position()); - } - if (request.level() != null) { - employee.updateRole(request.level()); - } - if (request.status() != null) { - employee.updateStatus(request.status()); - } - return employeeRepository.save(employee); - } - - public void disableEmployee(Long employeeId) { - Employee employee = employeeRepository.findById(employeeId) - .orElseThrow(() -> new UserNotFoundException("ID가 " + employeeId + "인 직원을 찾을 수 없습니다.")); - employee.updateStatus("DELETED"); - employeeRepository.save(employee); - } - - public void distributePoints(AdminPointDistributionRequest request, Employee admin) { - List employees = employeeRepository.findAllById(request.employeeIds()); - if (employees.size() != request.employeeIds().size()) { - throw new UserNotFoundException("일부 직원을 찾을 수 없습니다. 요청을 확인해주세요."); - } - - for (Employee employee : employees) { - Wallet wallet = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) - .orElseThrow(() -> new IllegalStateException(employee.getEmployeeName() + "님의 지갑이 없습니다.")); - - if (request.points() > 0) { - wallet.addPoints(request.points()); - } else { - wallet.spendPoints(Math.abs(request.points())); - } - - RewardPointTransaction transaction = RewardPointTransaction.builder() - .sender(admin) - .receiver(employee) - .pointAmount(request.points()) - .message(request.message()) - .type(request.type()) - .build(); - transactionRepository.save(transaction); - } - } - - @Transactional(readOnly = true) - public List getAllEmployees() { - return employeeRepository.findAll().stream() - .map(AdminEmployeeQueryResponse::from) - .toList(); - } -} diff --git a/src/main/java/com/joycrew/backend/service/AdminPointService.java b/src/main/java/com/joycrew/backend/service/AdminPointService.java new file mode 100644 index 0000000..436c074 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/AdminPointService.java @@ -0,0 +1,52 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.AdminPointDistributionRequest; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.RewardPointTransaction; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.exception.UserNotFoundException; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.RewardPointTransactionRepository; +import com.joycrew.backend.repository.WalletRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class AdminPointService { + + private final EmployeeRepository employeeRepository; + private final WalletRepository walletRepository; + private final RewardPointTransactionRepository transactionRepository; + + public void distributePoints(AdminPointDistributionRequest request, Employee admin) { + List employees = employeeRepository.findAllById(request.employeeIds()); + if (employees.size() != request.employeeIds().size()) { + throw new UserNotFoundException("Could not find some of the requested employees. Please verify the IDs."); + } + + for (Employee employee : employees) { + Wallet wallet = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) + .orElseThrow(() -> new IllegalStateException("Wallet not found for employee: " + employee.getEmployeeName())); + + if (request.points() > 0) { + wallet.addPoints(request.points()); + } else { + wallet.spendPoints(Math.abs(request.points())); + } + + RewardPointTransaction transaction = RewardPointTransaction.builder() + .sender(admin) + .receiver(employee) + .pointAmount(request.points()) + .message(request.message()) + .type(request.type()) + .build(); + transactionRepository.save(transaction); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/EmployeeManagementService.java b/src/main/java/com/joycrew/backend/service/EmployeeManagementService.java new file mode 100644 index 0000000..67f5034 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/EmployeeManagementService.java @@ -0,0 +1,109 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.AdminEmployeeQueryResponse; +import com.joycrew.backend.dto.AdminEmployeeUpdateRequest; +import com.joycrew.backend.dto.AdminPagedEmployeeResponse; +import com.joycrew.backend.entity.Department; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.exception.UserNotFoundException; +import com.joycrew.backend.repository.DepartmentRepository; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.service.mapper.EmployeeMapper; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.TypedQuery; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class EmployeeManagementService { + + private final EmployeeRepository employeeRepository; + private final DepartmentRepository departmentRepository; + private final EmployeeMapper employeeMapper; + @PersistenceContext + private final EntityManager em; + + @Transactional(readOnly = true) + public AdminPagedEmployeeResponse searchEmployees(String keyword, int page, int size) { + StringBuilder whereClause = new StringBuilder("WHERE 1=1 "); + if (keyword != null && !keyword.isBlank()) { + whereClause.append("AND (LOWER(e.employeeName) LIKE :keyword ") + .append("OR LOWER(e.email) LIKE :keyword ") + .append("OR LOWER(d.name) LIKE :keyword) "); + } + + String countJpql = "SELECT COUNT(e) FROM Employee e LEFT JOIN e.department d " + whereClause; + TypedQuery countQuery = em.createQuery(countJpql, Long.class); + if (keyword != null && !keyword.isBlank()) { + countQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); + } + long total = countQuery.getSingleResult(); + int totalPages = (int) Math.ceil((double) total / size); + + String dataJpql = "SELECT e FROM Employee e " + + "LEFT JOIN FETCH e.department d " + + "LEFT JOIN FETCH e.company c " + + whereClause + + "ORDER BY e.employeeName ASC"; + TypedQuery dataQuery = em.createQuery(dataJpql, Employee.class) + .setFirstResult(page * size) + .setMaxResults(size); + if (keyword != null && !keyword.isBlank()) { + dataQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); + } + + List employees = dataQuery.getResultList().stream() + .map(employeeMapper::toAdminEmployeeQueryResponse) // Use Mapper + .toList(); + + return new AdminPagedEmployeeResponse( + employees, + page, // Return 0-based page index for consistency + totalPages, + page >= totalPages - 1 + ); + } + + public Employee updateEmployee(Long employeeId, AdminEmployeeUpdateRequest request) { + Employee employee = employeeRepository.findById(employeeId) + .orElseThrow(() -> new UserNotFoundException("Employee not found with ID: " + employeeId)); + + if (request.name() != null) { + employee.updateName(request.name()); + } + if (request.departmentId() != null) { + Department department = departmentRepository.findById(request.departmentId()) + .orElseThrow(() -> new IllegalArgumentException("Department not found with ID: " + request.departmentId())); + employee.assignToDepartment(department); + } + if (request.position() != null) { + employee.updatePosition(request.position()); + } + if (request.level() != null) { + employee.updateRole(request.level()); + } + if (request.status() != null) { + employee.updateStatus(request.status()); + } + return employee; // @Transactional will handle the save + } + + public void deactivateEmployee(Long employeeId) { + Employee employee = employeeRepository.findById(employeeId) + .orElseThrow(() -> new UserNotFoundException("Employee not found with ID: " + employeeId)); + employee.updateStatus("DELETED"); + } + + @Transactional(readOnly = true) + public List getAllEmployees() { + return employeeRepository.findAll().stream() + .map(employeeMapper::toAdminEmployeeQueryResponse) // Use Mapper + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/EmployeeRegistrationService.java b/src/main/java/com/joycrew/backend/service/EmployeeRegistrationService.java new file mode 100644 index 0000000..4ec00c3 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/EmployeeRegistrationService.java @@ -0,0 +1,140 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.EmployeeRegistrationRequest; +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.entity.Department; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.AdminLevel; +import com.joycrew.backend.repository.CompanyRepository; +import com.joycrew.backend.repository.DepartmentRepository; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.WalletRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class EmployeeRegistrationService { + + private final EmployeeRepository employeeRepository; + private final CompanyRepository companyRepository; + private final DepartmentRepository departmentRepository; + private final WalletRepository walletRepository; + private final PasswordEncoder passwordEncoder; + + public Employee registerEmployee(EmployeeRegistrationRequest request) { + if (employeeRepository.findByEmail(request.email()).isPresent()) { + throw new IllegalStateException("This email is already in use."); + } + + Company company = companyRepository.findByCompanyName(request.companyName()) + .orElseThrow(() -> new IllegalArgumentException("Company with the given name does not exist.")); + + Department department = null; + if (request.departmentName() != null && !request.departmentName().isBlank()) { + department = departmentRepository.findByCompanyAndName(company, request.departmentName()) + .orElseThrow(() -> new IllegalArgumentException("Department with the given name does not exist in this company.")); + } + + Employee newEmployee = Employee.builder() + .employeeName(request.name()) + .email(request.email()) + .passwordHash(passwordEncoder.encode(request.initialPassword())) + .company(company) + .department(department) + .position(request.position()) + .role(request.level()) + .status("ACTIVE") + .birthday(request.birthday()) + .address(request.address()) + .hireDate(request.hireDate()) + .build(); + + Employee savedEmployee = employeeRepository.save(newEmployee); + walletRepository.save(new Wallet(savedEmployee)); + return savedEmployee; + } + + public void registerEmployeesFromCsv(MultipartFile file) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) { + String line; + boolean isFirstLine = true; + + while ((line = reader.readLine()) != null) { + if (isFirstLine) { + isFirstLine = false; + continue; + } + + String[] tokens = line.split(","); + if (tokens.length < 10) { // Adjusted for all fields including optional ones + log.warn("Skipping row with missing fields: {}", line); + continue; + } + + try { + AdminLevel adminLevel = parseAdminLevel(tokens[6].trim()); + LocalDate birthday = parseDate(tokens[7].trim()); + String address = tokens[8].trim(); + LocalDate hireDate = parseDate(tokens[9].trim()); + + EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( + tokens[0].trim(), // name + tokens[1].trim(), // email + tokens[2].trim(), // initialPassword + tokens[3].trim(), // companyName + tokens[4].trim().isBlank() ? null : tokens[4].trim(), // departmentName + tokens[5].trim(), // position + adminLevel, + birthday, + address, + hireDate + ); + registerEmployee(request); + } catch (Exception e) { + log.warn("Failed to register employee. Input: [{}], Reason: {}", line, e.getMessage()); + } + } + } catch (IOException e) { + throw new RuntimeException("Failed to read CSV file.", e); + } + } + + private LocalDate parseDate(String dateStr) { + if (dateStr == null || dateStr.isBlank()) { + return null; + } + try { + return LocalDate.parse(dateStr); // Expects YYYY-MM-DD format + } catch (DateTimeParseException e) { + log.warn("Invalid date format: {}. Processing as null.", dateStr); + return null; + } + } + + private AdminLevel parseAdminLevel(String level) { + if (level == null || level.isBlank()) { + return AdminLevel.EMPLOYEE; + } + try { + return AdminLevel.valueOf(level.toUpperCase()); + } catch (IllegalArgumentException e) { + log.warn("Invalid role level: {}. Defaulting to EMPLOYEE.", level); + return AdminLevel.EMPLOYEE; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/mapper/EmployeeMapper.java b/src/main/java/com/joycrew/backend/service/mapper/EmployeeMapper.java new file mode 100644 index 0000000..8fa3f55 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/mapper/EmployeeMapper.java @@ -0,0 +1,57 @@ +package com.joycrew.backend.service.mapper; + +import com.joycrew.backend.dto.AdminEmployeeQueryResponse; +import com.joycrew.backend.dto.EmployeeQueryResponse; +import com.joycrew.backend.dto.UserProfileResponse; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import org.springframework.stereotype.Component; + +@Component +public class EmployeeMapper { + + public EmployeeQueryResponse toEmployeeQueryResponse(Employee employee) { + String departmentName = (employee.getDepartment() != null) ? employee.getDepartment().getName() : null; + return new EmployeeQueryResponse( + employee.getEmployeeId(), + employee.getProfileImageUrl(), + employee.getEmployeeName(), + departmentName, + employee.getPosition() + ); + } + + public AdminEmployeeQueryResponse toAdminEmployeeQueryResponse(Employee employee) { + String departmentName = (employee.getDepartment() != null) ? employee.getDepartment().getName() : null; + return new AdminEmployeeQueryResponse( + employee.getEmployeeId(), + employee.getEmployeeName(), + employee.getEmail(), + departmentName, + employee.getPosition(), + employee.getProfileImageUrl(), + employee.getRole().name(), + employee.getBirthday(), + employee.getAddress(), + employee.getHireDate() + ); + } + + public UserProfileResponse toUserProfileResponse(Employee employee, Wallet wallet) { + String departmentName = employee.getDepartment() != null ? employee.getDepartment().getName() : null; + return new UserProfileResponse( + employee.getEmployeeId(), + employee.getEmployeeName(), + employee.getEmail(), + employee.getProfileImageUrl(), + wallet.getBalance(), + wallet.getGiftablePoint(), + employee.getRole(), + departmentName, + employee.getPosition(), + employee.getBirthday(), + employee.getAddress(), + employee.getHireDate() + ); + } +} \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 96068ae..34672e6 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -1,39 +1,37 @@ +# =================================================================== +# DEVELOPMENT PROFILE +# +# Properties that are active when the 'dev' profile is enabled. +# =================================================================== spring: + # For development, an in-memory H2 database is used. datasource: url: jdbc:h2:mem:testdb driver-class-name: org.h2.Driver username: sa password: + # Enables the H2 database web console for direct access. h2: console: enabled: true + # The database schema is created from scratch on every application start. jpa: hibernate: ddl-auto: create - show-sql: false + + # Uses development-specific Gmail credentials. + # NOTE: Using an App Password instead of the actual password is recommended. mail: - host: smtp.gmail.com - port: 587 username: joycrew.team@gmail.com password: abcd efgh ijkl mnop - properties: - mail: - smtp: - auth: true - starttls: - enable: true +# Development JWT secret key (safe to expose for local development). jwt: secret: dev-test-secret-key-for-joycrew-backend-at-least-256-bits-long! +# Log levels are adjusted for detailed output during development. logging: level: org.hibernate.SQL: DEBUG org.hibernate.type.descriptor.sql: TRACE - com.joycrew.backend: DEBUG - -springdoc: - api-docs: - path: /v3/api-docs - swagger-ui: - path: /swagger-ui.html \ No newline at end of file + com.joycrew.backend: DEBUG \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 20df1f4..c07faa0 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -1,30 +1,32 @@ +# =================================================================== +# PRODUCTION PROFILE +# +# Properties that are active when the 'prod' profile is enabled. +# =================================================================== spring: + # In production, the application connects to an external MySQL database. + # For security, database credentials are read from environment variables. datasource: url: ${DB_HOST}?zeroDateTimeBehavior=CONVERT_TO_NULL driver-class-name: com.mysql.cj.jdbc.Driver username: ${DB_USER} password: ${DB_PASSWORD} + # Updates the schema on application start if it differs from the entities. jpa: hibernate: ddl-auto: update show-sql: false + + # Production Gmail credentials are read from environment variables. mail: - host: smtp.gmail.com - port: 587 username: ${GMAIL_USERNAME} password: ${GMAIL_PASSWORD} - properties: - mail: - smtp: - auth: true - starttls: - enable: true -app: - frontend-url: https://joycrew.co.kr +# For security, the production JWT secret key MUST be set via an environment variable. jwt: secret: ${JWT_SECRET_KEY} +# In production, logs are written to a file for persistence and analysis. logging: level: com.joycrew.backend: INFO @@ -33,8 +35,6 @@ logging: max-size: 10MB max-history: 7 -springdoc: - api-docs: - path: /v3/api-docs - swagger-ui: - path: /swagger-ui.html \ No newline at end of file +# The frontend service URL for the production environment. +app: + frontend-url: https://joycrew.co.kr \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2aacae9..858d527 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,9 @@ +# =================================================================== +# COMMON CONFIGURATION +# +# This file contains default properties shared across all profiles (dev, prod, etc.). +# Profile-specific properties in files like 'application-dev.yml' will override these values. +# =================================================================== server: port: 8082 tomcat: @@ -11,21 +17,42 @@ server: spring: application: name: joycrew + # Specifies the default active profile. profiles: active: dev + # Common mail server settings used by all profiles. + mail: + host: smtp.gmail.com + port: 587 + properties: + mail: + smtp: + auth: true + starttls: + enable: true -app: - # Base URL of the frontend application for generating links in emails - frontend-url: http://localhost:3000 +# API documentation (Swagger/OpenAPI) settings. +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html +# Default JWT settings. jwt: - # Default token expiration time: 1 hour + # Default access token expiration: 1 hour (3,600,000 ms) expiration-ms: 3600000 - # Password reset token expiration time: 15 minutes + # Password reset token expiration: 15 minutes (900,000 ms) password-reset-expiration-ms: 900000 +# Common logging settings. logging: pattern: console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" level: - com.joycrew.backend: INFO \ No newline at end of file + com.joycrew.backend: INFO + +# Application-specific common properties. +app: + # Default frontend URL, primarily for the 'dev' environment. + frontend-url: http://localhost:3000 \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java b/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java index e04e4ca..21dd2f0 100644 --- a/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java @@ -8,7 +8,6 @@ import com.joycrew.backend.entity.enums.AdminLevel; import com.joycrew.backend.entity.enums.TransactionType; import com.joycrew.backend.security.WithMockUserPrincipal; -import com.joycrew.backend.service.AdminEmployeeService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; From 5eefb9186eac9392cfcff693dbaeb9358c19ad58 Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Thu, 7 Aug 2025 18:19:59 +0900 Subject: [PATCH 078/135] =?UTF-8?q?refactor=20:=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A1=9C=EA=B7=B8=20=EC=98=81=EB=AC=B8=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cd.yml | 25 ++++++--- .github/workflows/ci.yml | 14 ++++- .gitignore | 55 +++++++++++++------ build.gradle | 32 +++++++++-- .../backend/config/SecurityConfig.java | 6 +- .../joycrew/backend/config/SwaggerConfig.java | 4 +- .../controller/AdminEmployeeController.java | 1 - .../backend/controller/AuthController.java | 26 ++++----- .../controller/EmployeeQueryController.java | 14 ++--- .../controller/GiftPointController.java | 8 +-- .../TransactionHistoryController.java | 4 +- .../backend/controller/UserController.java | 12 ++-- .../backend/controller/WalletController.java | 4 +- .../dto/AdminEmployeeQueryResponse.java | 27 ++++----- .../dto/AdminPagedEmployeeResponse.java | 12 ++-- .../dto/AdminPointDistributionRequest.java | 8 +-- .../backend/dto/EmployeeQueryResponse.java | 16 +++--- .../dto/EmployeeRegistrationRequest.java | 18 +++--- .../EmployeeRegistrationSuccessResponse.java | 6 +- .../joycrew/backend/dto/GiftPointRequest.java | 24 ++++---- .../com/joycrew/backend/dto/LoginRequest.java | 10 ++-- .../joycrew/backend/dto/LoginResponse.java | 18 +++--- .../backend/dto/PagedEmployeeResponse.java | 10 ++-- .../dto/PasswordResetConfirmRequest.java | 13 ++--- .../backend/dto/PasswordResetRequest.java | 9 ++- .../backend/dto/PointBalanceResponse.java | 10 ++-- .../joycrew/backend/dto/SuccessResponse.java | 8 +-- .../backend/dto/UserProfileResponse.java | 29 +++++----- .../backend/dto/UserProfileUpdateRequest.java | 13 +++-- .../com/joycrew/backend/entity/Company.java | 2 +- .../com/joycrew/backend/entity/Employee.java | 2 +- .../entity/RewardPointTransaction.java | 2 +- .../com/joycrew/backend/entity/Wallet.java | 6 +- .../backend/event/NotificationListener.java | 9 +-- .../exception/GlobalExceptionHandler.java | 4 +- .../joycrew/backend/service/AuthService.java | 10 ++-- .../backend/service/EmployeeQueryService.java | 7 ++- .../backend/service/EmployeeService.java | 12 ++-- .../backend/service/GiftPointService.java | 15 ++--- .../service/TransactionHistoryService.java | 5 +- .../backend/service/WalletService.java | 6 +- .../service/mapper/EmployeeMapper.java | 5 ++ 42 files changed, 294 insertions(+), 227 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index d395325..436ba20 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -1,5 +1,7 @@ +# Workflow name name: Java Spring Boot CD +# This workflow runs on pushes to the 'main' branch. on: push: branches: @@ -7,38 +9,47 @@ on: jobs: deploy: + # The type of runner that the job will run on. runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + # Step 1: Checks out the repository code. + - name: Checkout repository + uses: actions/checkout@v4 + # Step 2: Sets up JDK 17 for building the project. - name: Set up JDK 17 uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '17' + # Step 3: Builds the executable JAR file using the 'prod' profile. - name: Build JAR for Production run: | chmod +x gradlew ./gradlew clean assemble -Pspring.profiles.active=prod + # Step 4: Finds the path to the generated JAR file. - name: Locate and Prepare JAR artifact id: get_jar_path run: | - JAR_FILE=$(find build/libs -name "*.jar" | head -n 1) + JAR_FILE=$(find build/libs -name "*.jar" | head -n 1) if [ -z "$JAR_FILE" ]; then echo "Error: No executable JAR file found!" exit 1 fi echo "jar_path=$JAR_FILE" >> $GITHUB_OUTPUT + # Step 5: Adds the EC2 server's host key to the runner's known_hosts file. + # This prevents interactive prompts during SSH/SCP connections. - name: Add EC2 Host Key to known_hosts run: | mkdir -p ~/.ssh echo "${{ secrets.SSH_HOST_KEYS }}" > ~/.ssh/known_hosts chmod 600 ~/.ssh/known_hosts + # Step 6: Securely copies the JAR file from the GitHub runner to the EC2 server. - name: Transfer JAR to EC2 Server uses: appleboy/scp-action@master with: @@ -49,15 +60,15 @@ jobs: target: ~/app/build/libs/ strip_components: 2 + # Step 7: Connects to the EC2 server via SSH and executes deployment commands. - name: Deploy and Restart Application on EC2 uses: appleboy/ssh-action@master with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_KEY }} - script: | - set -eux + set -eux # Exit on error, print commands echo "Stopping existing JoyCrew backend service..." sudo systemctl stop joycrew-backend || true @@ -70,8 +81,4 @@ jobs: sudo systemctl start joycrew-backend echo "Checking service status..." - sudo systemctl status joycrew-backend --no-pager || true - - - name: Clean up local SSH key file - if: always() - run: rm -f private_key.pem \ No newline at end of file + sudo systemctl status joycrew-backend --no-pager || true \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5883c7..ba5c9a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,7 @@ +# Workflow name name: Java Spring Boot CI +# This workflow runs on pushes to the 'develop' branch. on: push: branches: @@ -7,26 +9,36 @@ on: jobs: build: + # The type of runner that the job will run on. runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + # Step 1: Checks out your repository under $GITHUB_WORKSPACE, so your job can access it. + - name: Checkout repository + uses: actions/checkout@v4 + + # Step 2: Sets up Java Development Kit (JDK) 17. - name: Set up JDK 17 uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' + # Caches Gradle dependencies to speed up subsequent builds. cache: gradle + # Step 3: Grants execute permissions to the Gradle wrapper script. - name: Grant execute permission for gradlew run: chmod +x gradlew + # Step 4: Builds the project and runs tests using the Gradle wrapper. - name: Build and Test with Gradle run: ./gradlew build + # Step 5: Uploads the generated JAR file as a build artifact. - name: Upload JAR artifact uses: actions/upload-artifact@v4 with: name: joycrew-backend-jar path: build/libs/*.jar + # Artifacts will be available for 1 day. retention-days: 1 \ No newline at end of file diff --git a/.gitignore b/.gitignore index affa3e2..d05d99e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,25 @@ -# Gradle +# =================================================================== +# Build & Output +# +# Compiled files and build outputs that are generated locally. +# =================================================================== .gradle/ build/ -!gradle/wrapper/gradle-wrapper.jar -!gradle/wrapper/gradle-wrapper.properties +out/ +/target/ -# IDE (IntelliJ IDEA) +# =================================================================== +# IDE-specific files +# +# Configuration files generated by Integrated Development Environments. +# =================================================================== +# IntelliJ IDEA .idea/ *.iml *.ipr *.iws -out/ -# IDE (Eclipse/STS) +# Eclipse / Spring Tool Suite (STS) .apt_generated/ .classpath .factorypath @@ -21,17 +29,21 @@ out/ .sts4-cache/ bin/ -# IDE (NetBeans) +# NetBeans /nbproject/private/ /nbbuild/ /dist/ /nbdist/ /.nb-gradle/ -# IDE (VS Code) +# Visual Studio Code .vscode/ -# General Logs & Temp +# =================================================================== +# Logs & Temporary Files +# +# Log files, temporary files, and local databases. +# =================================================================== *.log *.log.* *.class @@ -43,12 +55,23 @@ bin/ *.gz *.sqlite *.db +hs_err_pid*.log +/.gradle-enterprise/ -# OS Specific +# =================================================================== +# OS-specific files +# +# Files generated by operating systems (macOS, Windows). +# =================================================================== .DS_Store Thumbs.db -# Sensitive Files / Credentials +# =================================================================== +# Sensitive Information +# +# Credentials, private keys, and environment-specific files. +# These should never be committed to version control. +# =================================================================== *.pem *.key *.env @@ -58,10 +81,10 @@ Thumbs.db .env.test.local .env.local -# Spring Boot / Maven (if also used) -/target/ -/hs_err_pid*.log -/.gradle-enterprise/ - +# =================================================================== +# Miscellaneous +# +# Other generated files that should not be versioned. +# =================================================================== # Auto-generated documentation (if not meant for version control) HELP.md \ No newline at end of file diff --git a/build.gradle b/build.gradle index 7389323..7c95e2b 100644 --- a/build.gradle +++ b/build.gradle @@ -1,56 +1,76 @@ +// Defines the plugins required for the project. plugins { id 'java' id 'org.springframework.boot' version '3.3.1' id 'io.spring.dependency-management' version '1.1.5' } +// Project metadata group = 'com.joycrew' version = '0.0.1-SNAPSHOT' +// Specifies the Java version to be used for the project. java { toolchain { - languageVersion = JavaLanguageVersion.of(17) + languageVersion = JavaLanguageVersion.of(17) } } +// Configuration for dependencies. configurations { + // Ensures that compile-only dependencies (like Lombok) are available for the annotation processor. compileOnly { - extendsFrom annotationProcessor + extendsFrom annotationProcessor } } +// Specifies the repository to fetch dependencies from. repositories { mavenCentral() } +// Defines the project's dependencies. dependencies { + // Spring Boot Starters for core functionalities implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' implementation 'org.springframework.boot:spring-boot-starter-mail' + + // API Documentation (Swagger/OpenAPI) + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' + + // JSON Web Token (JWT) support implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' - runtimeOnly 'com.mysql:mysql-connector-j' - runtimeOnly 'com.h2database:h2' + + // Database Drivers + runtimeOnly 'com.mysql:mysql-connector-j' // For production + runtimeOnly 'com.h2database:h2' // For development + + // Developer Tools compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' + // Testing Dependencies testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' } +// Configures the executable JAR generation. bootJar { enabled = true } +// Disables the plain JAR generation as the bootJar is sufficient. jar { enabled = false } +// Configures the test task to use the JUnit Platform. tasks.named('test') { useJUnitPlatform() -} +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/config/SecurityConfig.java b/src/main/java/com/joycrew/backend/config/SecurityConfig.java index 1e8fe9f..588e8fd 100644 --- a/src/main/java/com/joycrew/backend/config/SecurityConfig.java +++ b/src/main/java/com/joycrew/backend/config/SecurityConfig.java @@ -60,7 +60,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType("application/json;charset=UTF-8"); String jsonResponse = objectMapper.writeValueAsString( - new ErrorResponse("UNAUTHENTICATED", "인증이 필요합니다. 로그인을 진행해주세요.") + new ErrorResponse("UNAUTHENTICATED", "Authentication is required. Please log in.") ); response.getWriter().write(jsonResponse); }) @@ -68,7 +68,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti response.setStatus(HttpStatus.FORBIDDEN.value()); response.setContentType("application/json;charset=UTF-8"); String jsonResponse = objectMapper.writeValueAsString( - new ErrorResponse("ACCESS_DENIED", "해당 리소스에 접근할 권한이 없습니다.") + new ErrorResponse("ACCESS_DENIED", "You do not have permission to access this resource.") ); response.getWriter().write(jsonResponse); }) @@ -107,4 +107,4 @@ public AuthenticationManager authenticationManager(PasswordEncoder passwordEncod authenticationProvider.setPasswordEncoder(passwordEncoder); return new ProviderManager(authenticationProvider); } -} +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/config/SwaggerConfig.java b/src/main/java/com/joycrew/backend/config/SwaggerConfig.java index 49150b0..a81d782 100644 --- a/src/main/java/com/joycrew/backend/config/SwaggerConfig.java +++ b/src/main/java/com/joycrew/backend/config/SwaggerConfig.java @@ -26,6 +26,6 @@ public OpenAPI openAPI() { .info(new Info() .title("JoyCrew API") .version("v1.0.0") - .description("JoyCrew 백엔드 API 명세서입니다.")); + .description("JoyCrew Backend API Specification.")); } -} +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java b/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java index 20eb10b..db1ceb3 100644 --- a/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java +++ b/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java @@ -111,7 +111,6 @@ public ResponseEntity searchEmployees( public ResponseEntity updateEmployee( @PathVariable Long employeeId, @RequestBody AdminEmployeeUpdateRequest request) { - // 'adminEmployeeService'를 'managementService'로 수정 managementService.updateEmployee(employeeId, request); return ResponseEntity.ok(new SuccessResponse("Employee information updated successfully.")); } diff --git a/src/main/java/com/joycrew/backend/controller/AuthController.java b/src/main/java/com/joycrew/backend/controller/AuthController.java index 0015c5d..aa3a6cc 100644 --- a/src/main/java/com/joycrew/backend/controller/AuthController.java +++ b/src/main/java/com/joycrew/backend/controller/AuthController.java @@ -11,7 +11,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -@Tag(name = "인증", description = "로그인 및 비밀번호 재설정 관련 API") +@Tag(name = "Authentication", description = "APIs for login and password reset") @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor @@ -19,36 +19,36 @@ public class AuthController { private final AuthService authService; - @Operation(summary = "로그인") - @ApiResponse(responseCode = "200", description = "로그인 성공") - @ApiResponse(responseCode = "401", description = "인증 실패") + @Operation(summary = "Login") + @ApiResponse(responseCode = "200", description = "Login successful") + @ApiResponse(responseCode = "401", description = "Authentication failed") @PostMapping("/login") public ResponseEntity login(@RequestBody @Valid LoginRequest request) { LoginResponse loginResponse = authService.login(request); return ResponseEntity.ok(loginResponse); } - @Operation(summary = "로그아웃") + @Operation(summary = "Logout") @PostMapping("/logout") public ResponseEntity logout(HttpServletRequest request) { authService.logout(request); - return ResponseEntity.ok(new SuccessResponse("로그아웃 되었습니다.")); + return ResponseEntity.ok(new SuccessResponse("You have been logged out.")); } - @Operation(summary = "비밀번호 재설정 요청 (이메일 발송)", description = "사용자 이메일로 비밀번호를 재설정할 수 있는 매직 링크를 보냅니다.") - @ApiResponse(responseCode = "200", description = "요청이 성공적으로 처리되었습니다. (이메일 존재 여부와 상관없이 동일한 응답)") + @Operation(summary = "Request password reset (sends email)", description = "Sends a magic link to the user's email to reset the password.") + @ApiResponse(responseCode = "200", description = "The request was processed successfully (the response is the same regardless of whether the email exists).") @PostMapping("/password-reset/request") public ResponseEntity requestPasswordReset(@RequestBody @Valid PasswordResetRequest request) { authService.requestPasswordReset(request.email()); - return ResponseEntity.ok(new SuccessResponse("비밀번호 재설정 이메일이 요청되었습니다. 이메일을 확인해주세요.")); + return ResponseEntity.ok(new SuccessResponse("A password reset email has been requested. Please check your email.")); } - @Operation(summary = "비밀번호 재설정 확인", description = "이메일로 받은 토큰과 새로운 비밀번호로 비밀번호를 최종 변경합니다.") - @ApiResponse(responseCode = "200", description = "비밀번호가 성공적으로 변경되었습니다.") - @ApiResponse(responseCode = "400", description = "토큰이 유효하지 않거나 만료되었습니다.") + @Operation(summary = "Confirm password reset", description = "Finalizes the password change using the token from the email and the new password.") + @ApiResponse(responseCode = "200", description = "Password changed successfully.") + @ApiResponse(responseCode = "400", description = "The token is invalid or has expired.") @PostMapping("/password-reset/confirm") public ResponseEntity confirmPasswordReset(@RequestBody @Valid PasswordResetConfirmRequest request) { authService.confirmPasswordReset(request.token(), request.newPassword()); - return ResponseEntity.ok(new SuccessResponse("비밀번호가 성공적으로 변경되었습니다.")); + return ResponseEntity.ok(new SuccessResponse("Password changed successfully.")); } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java b/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java index 43579ed..3130822 100644 --- a/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java +++ b/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java @@ -17,23 +17,23 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/employee/query") -@Tag(name = "직원 조회", description = "직원 목록 검색 API") +@Tag(name = "Employee Query", description = "API for searching employees") public class EmployeeQueryController { private final EmployeeQueryService employeeQueryService; @Operation( - summary = "직원 목록 검색", - description = "이름, 이메일, 부서명을 기준으로 통합 검색을 수행합니다. 검색 결과에서는 본인이 제외됩니다.", + summary = "Search employee list", + description = "Performs a unified search by name, email, or department. The current user is excluded from the search results.", parameters = { - @Parameter(name = "keyword", description = "검색 키워드", example = "김"), - @Parameter(name = "page", description = "페이지 번호 (0부터 시작)", example = "0"), - @Parameter(name = "size", description = "페이지당 개수", example = "20") + @Parameter(name = "keyword", description = "Search keyword", example = "John"), + @Parameter(name = "page", description = "Page number (0-based)", example = "0"), + @Parameter(name = "size", description = "Items per page", example = "20") }, responses = { @ApiResponse( responseCode = "200", - description = "직원 목록 조회 성공", + description = "Employee list retrieved successfully", content = @Content( mediaType = "application/json", schema = @Schema(implementation = PagedEmployeeResponse.class) diff --git a/src/main/java/com/joycrew/backend/controller/GiftPointController.java b/src/main/java/com/joycrew/backend/controller/GiftPointController.java index 89aa02a..714e46c 100644 --- a/src/main/java/com/joycrew/backend/controller/GiftPointController.java +++ b/src/main/java/com/joycrew/backend/controller/GiftPointController.java @@ -13,7 +13,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "포인트 선물", description = "동료 간 포인트 선물 API") +@Tag(name = "Gift Points", description = "APIs for gifting points between colleagues") @RestController @RequestMapping("/api/gift-points") @RequiredArgsConstructor @@ -21,13 +21,13 @@ public class GiftPointController { private final GiftPointService giftPointService; - @Operation(summary = "동료에게 포인트 선물하기", security = @SecurityRequirement(name = "Authorization")) + @Operation(summary = "Gift points to a colleague", security = @SecurityRequirement(name = "Authorization")) @PostMapping public ResponseEntity giftPoints( @AuthenticationPrincipal UserPrincipal principal, @Valid @RequestBody GiftPointRequest request ) { giftPointService.giftPointsToColleague(principal.getUsername(), request); - return ResponseEntity.ok(new SuccessResponse("포인트를 성공적으로 보냈습니다.")); + return ResponseEntity.ok(new SuccessResponse("Points sent successfully.")); } -} +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/controller/TransactionHistoryController.java b/src/main/java/com/joycrew/backend/controller/TransactionHistoryController.java index edfefc8..360b117 100644 --- a/src/main/java/com/joycrew/backend/controller/TransactionHistoryController.java +++ b/src/main/java/com/joycrew/backend/controller/TransactionHistoryController.java @@ -15,7 +15,7 @@ import java.util.List; -@Tag(name = "거래 내역", description = "포인트 거래 내역 조회 API") +@Tag(name = "Transaction History", description = "API for retrieving point transaction history") @RestController @RequestMapping("/api/transactions") @RequiredArgsConstructor @@ -23,7 +23,7 @@ public class TransactionHistoryController { private final TransactionHistoryService transactionHistoryService; - @Operation(summary = "포인트 거래 내역 조회", security = @SecurityRequirement(name = "Authorization")) + @Operation(summary = "Get point transaction history", security = @SecurityRequirement(name = "Authorization")) @GetMapping public ResponseEntity> getMyTransactions( @AuthenticationPrincipal UserPrincipal principal) { diff --git a/src/main/java/com/joycrew/backend/controller/UserController.java b/src/main/java/com/joycrew/backend/controller/UserController.java index ecd1212..5588e90 100644 --- a/src/main/java/com/joycrew/backend/controller/UserController.java +++ b/src/main/java/com/joycrew/backend/controller/UserController.java @@ -15,7 +15,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "사용자", description = "사용자 정보 관련 API") +@Tag(name = "User", description = "APIs related to user information") @RestController @RequestMapping("/api/user") @RequiredArgsConstructor @@ -23,7 +23,7 @@ public class UserController { private final EmployeeService employeeService; - @Operation(summary = "사용자 프로필 조회", security = @SecurityRequirement(name = "Authorization")) + @Operation(summary = "Get user profile", security = @SecurityRequirement(name = "Authorization")) @GetMapping("/profile") public ResponseEntity getProfile( @AuthenticationPrincipal UserPrincipal principal @@ -31,22 +31,22 @@ public ResponseEntity getProfile( return ResponseEntity.ok(employeeService.getUserProfile(principal.getUsername())); } - @Operation(summary = "비밀번호 변경", security = @SecurityRequirement(name = "Authorization")) + @Operation(summary = "Change password", security = @SecurityRequirement(name = "Authorization")) @PostMapping("/password") public ResponseEntity forceChangePassword( @AuthenticationPrincipal UserPrincipal principal, @Valid @RequestBody PasswordChangeRequest request ) { employeeService.forcePasswordChange(principal.getUsername(), request); - return ResponseEntity.ok(new SuccessResponse("비밀번호가 성공적으로 변경되었습니다.")); + return ResponseEntity.ok(new SuccessResponse("Password changed successfully.")); } - @Operation(summary = "내 정보 수정", description = "변경을 원하는 필드만 요청 본문에 포함하여 전송합니다.", security = @SecurityRequirement(name = "Authorization")) + @Operation(summary = "Update my information", description = "Send only the fields you want to change in the request body.", security = @SecurityRequirement(name = "Authorization")) @PatchMapping("/profile") public ResponseEntity updateMyProfile( @AuthenticationPrincipal UserPrincipal principal, @RequestBody UserProfileUpdateRequest request) { employeeService.updateUserProfile(principal.getUsername(), request); - return ResponseEntity.ok(new SuccessResponse("내 정보가 성공적으로 수정되었습니다.")); + return ResponseEntity.ok(new SuccessResponse("Your information has been updated successfully.")); } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/controller/WalletController.java b/src/main/java/com/joycrew/backend/controller/WalletController.java index acdafcd..0b48fe8 100644 --- a/src/main/java/com/joycrew/backend/controller/WalletController.java +++ b/src/main/java/com/joycrew/backend/controller/WalletController.java @@ -13,7 +13,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "지갑", description = "포인트 관련 API") +@Tag(name = "Wallet", description = "APIs related to points and wallet") @RestController @RequestMapping("/api/wallet") @RequiredArgsConstructor @@ -21,7 +21,7 @@ public class WalletController { private final WalletService walletService; - @Operation(summary = "포인트 잔액 조회", security = @SecurityRequirement(name = "Authorization")) + @Operation(summary = "Get point balance", security = @SecurityRequirement(name = "Authorization")) @GetMapping("/point") public ResponseEntity getWalletPoint( @AuthenticationPrincipal UserPrincipal principal diff --git a/src/main/java/com/joycrew/backend/dto/AdminEmployeeQueryResponse.java b/src/main/java/com/joycrew/backend/dto/AdminEmployeeQueryResponse.java index 7b6e39f..d086023 100644 --- a/src/main/java/com/joycrew/backend/dto/AdminEmployeeQueryResponse.java +++ b/src/main/java/com/joycrew/backend/dto/AdminEmployeeQueryResponse.java @@ -1,40 +1,37 @@ package com.joycrew.backend.dto; -import com.joycrew.backend.entity.Employee; import io.swagger.v3.oas.annotations.media.Schema; - import java.time.LocalDate; -@Schema(description = "관리자용 직원 검색 응답 DTO") +@Schema(description = "Admin-specific Employee Search Response DTO") public record AdminEmployeeQueryResponse( - @Schema(description = "직원 ID", example = "1") + @Schema(description = "Employee ID", example = "1") Long employeeId, - @Schema(description = "직원 이름", example = "김조이") + @Schema(description = "Employee name", example = "John Doe") String employeeName, - @Schema(description = "직원 이메일", example = "joy@example.com") + @Schema(description = "Employee email", example = "john.doe@example.com") String email, - @Schema(description = "부서명", example = "개발팀") + @Schema(description = "Department name", example = "Engineering") String departmentName, - @Schema(description = "직책", example = "백엔드 개발자") + @Schema(description = "Position or title", example = "Backend Developer") String position, - @Schema(description = "프로필 이미지 URL") + @Schema(description = "URL of the profile image") String profileImageUrl, - @Schema(description = "직원 권한 등급", example = "HR_ADMIN") + @Schema(description = "Employee role/permission level", example = "HR_ADMIN") String adminLevel, - @Schema(description = "생년월일", example = "1995-05-10") + @Schema(description = "Birth date", example = "1995-05-10") LocalDate birthday, - @Schema(description = "주소", example = "서울시 강남구 테헤란로 123") + @Schema(description = "Address", example = "123 Teheran-ro, Gangnam-gu, Seoul") String address, - @Schema(description = "입사일", example = "2023-01-10") + @Schema(description = "Hire date", example = "2023-01-10") LocalDate hireDate -) { -} +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/AdminPagedEmployeeResponse.java b/src/main/java/com/joycrew/backend/dto/AdminPagedEmployeeResponse.java index 058e8bc..7448b72 100644 --- a/src/main/java/com/joycrew/backend/dto/AdminPagedEmployeeResponse.java +++ b/src/main/java/com/joycrew/backend/dto/AdminPagedEmployeeResponse.java @@ -3,17 +3,17 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; -@Schema(description = "관리자용 직원 목록 페이징 응답 DTO") +@Schema(description = "Admin-specific Paginated Employee List Response DTO") public record AdminPagedEmployeeResponse( - @Schema(description = "직원 목록") + @Schema(description = "List of employees") List employees, - @Schema(description = "현재 페이지 번호") + @Schema(description = "Current page number (0-based)") int currentPage, - @Schema(description = "전체 페이지 수") + @Schema(description = "Total number of pages") int totalPages, - @Schema(description = "마지막 페이지 여부") + @Schema(description = "Indicates if this is the last page") boolean last -) {} +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/AdminPointDistributionRequest.java b/src/main/java/com/joycrew/backend/dto/AdminPointDistributionRequest.java index 728d1f6..50f5879 100644 --- a/src/main/java/com/joycrew/backend/dto/AdminPointDistributionRequest.java +++ b/src/main/java/com/joycrew/backend/dto/AdminPointDistributionRequest.java @@ -6,15 +6,15 @@ import java.util.List; public record AdminPointDistributionRequest( - @NotEmpty(message = "직원 ID 목록은 비어있을 수 없습니다.") + @NotEmpty(message = "Employee ID list cannot be empty.") List employeeIds, - @NotNull(message = "포인트는 필수입니다.") + @NotNull(message = "Points are required.") int points, - @NotNull(message = "메시지는 필수입니다.") + @NotNull(message = "Message is required.") String message, - @NotNull(message = "거래 유형은 필수입니다.") + @NotNull(message = "Transaction type is required.") TransactionType type ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/EmployeeQueryResponse.java b/src/main/java/com/joycrew/backend/dto/EmployeeQueryResponse.java index 8557b14..cb0e1a7 100644 --- a/src/main/java/com/joycrew/backend/dto/EmployeeQueryResponse.java +++ b/src/main/java/com/joycrew/backend/dto/EmployeeQueryResponse.java @@ -1,23 +1,21 @@ package com.joycrew.backend.dto; -import com.joycrew.backend.entity.Employee; import io.swagger.v3.oas.annotations.media.Schema; -@Schema(description = "직원 검색 결과 응답 DTO") +@Schema(description = "Employee Search Result DTO") public record EmployeeQueryResponse( - @Schema(description = "직원 고유 ID", example = "1") + @Schema(description = "Unique ID of the employee", example = "1") Long employeeId, - @Schema(description = "프로필 이미지 URL", example = "https://cdn.joycrew.com/profile/user123.jpg") + @Schema(description = "URL of the profile image", example = "https://cdn.joycrew.com/profile/user123.jpg") String profileImageUrl, - @Schema(description = "직원 이름", example = "김조이") + @Schema(description = "Name of the employee", example = "John Doe") String employeeName, - @Schema(description = "부서명", example = "개발팀") + @Schema(description = "Department name", example = "Engineering") String departmentName, - @Schema(description = "직책", example = "백엔드 개발자") + @Schema(description = "Position or title", example = "Backend Developer") String position -) { -} \ No newline at end of file +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java b/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java index 5a4daa3..268a9b2 100644 --- a/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java +++ b/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java @@ -9,26 +9,26 @@ import java.time.LocalDate; public record EmployeeRegistrationRequest ( - @NotBlank(message = "이름은 필수입니다.") + @NotBlank(message = "Name is required.") String name, - @NotBlank(message = "이메일은 필수입니다.") - @Email(message = "유효한 이메일 형식이 아닙니다.") + @NotBlank(message = "Email is required.") + @Email(message = "Must be a valid email format.") String email, - @NotBlank(message = "초기 비밀번호는 필수입니다.") - @Size(min = 8, message = "비밀번호는 8자 이상이어야 합니다.") + @NotBlank(message = "Initial password is required.") + @Size(min = 8, message = "Password must be at least 8 characters long.") String initialPassword, - @NotBlank(message = "회사명은 필수입니다.") + @NotBlank(message = "Company name is required.") String companyName, String departmentName, - @NotBlank(message = "직책은 필수입니다.") + @NotBlank(message = "Position is required.") String position, - @NotNull(message = "역할은 필수입니다.") + @NotNull(message = "Role is required.") AdminLevel level, LocalDate birthday, @@ -36,4 +36,4 @@ public record EmployeeRegistrationRequest ( String address, LocalDate hireDate -) {} +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationSuccessResponse.java b/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationSuccessResponse.java index eaca103..f904ec9 100644 --- a/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationSuccessResponse.java +++ b/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationSuccessResponse.java @@ -2,8 +2,8 @@ import io.swagger.v3.oas.annotations.media.Schema; -@Schema(description = "직원 생성 성공 응답 DTO") +@Schema(description = "Employee Creation Success Response DTO") public record EmployeeRegistrationSuccessResponse( - @Schema(example = "직원 생성 완료 (ID: 2)", description = "응답 메시지") + @Schema(example = "Employee created successfully (ID: 2)", description = "Response message") String message -) {} +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/GiftPointRequest.java b/src/main/java/com/joycrew/backend/dto/GiftPointRequest.java index 3950dc3..e6bffb2 100644 --- a/src/main/java/com/joycrew/backend/dto/GiftPointRequest.java +++ b/src/main/java/com/joycrew/backend/dto/GiftPointRequest.java @@ -8,24 +8,24 @@ import java.util.List; -@Schema(description = "포인트 선물 요청 DTO") +@Schema(description = "Gift Points Request DTO") public record GiftPointRequest( - @Schema(description = "포인트를 받을 직원의 ID", example = "2") - @NotNull(message = "받는 사람 ID는 필수입니다.") + @Schema(description = "ID of the employee who will receive the points", example = "2") + @NotNull(message = "Receiver ID is required.") Long receiverId, - @Schema(description = "선물할 포인트 수", example = "50", minimum = "1") - @NotNull(message = "포인트는 필수입니다.") - @Min(value = 1, message = "포인트는 1 이상이어야 합니다.") + @Schema(description = "Number of points to gift", example = "50", minimum = "1") + @NotNull(message = "Points are required.") + @Min(value = 1, message = "Points must be at least 1.") int points, - @Schema(description = "응원의 메시지 (선택 사항, 최대 255자)", example = "이번 프로젝트 수고하셨어요!") - @Size(max = 255, message = "메시지는 255자를 초과할 수 없습니다.") + @Schema(description = "Encouragement message (optional, max 255 chars)", example = "Great job on the project!") + @Size(max = 255, message = "Message cannot exceed 255 characters.") String message, - @Schema(description = "포인트 선물에 함께 전달할 태그 목록 (최소 1개, 최대 3개)", example = "[\"TEAMWORK\", \"LEADERSHIP\"]") - @NotNull(message = "태그는 필수입니다.") - @Size(min = 1, max = 3, message = "태그는 최소 1개, 최대 3개까지 선택 가능합니다.") + @Schema(description = "List of tags to send with the points (min 1, max 3)", example = "[\"TEAMWORK\", \"LEADERSHIP\"]") + @NotNull(message = "Tags are required.") + @Size(min = 1, max = 3, message = "Between 1 and 3 tags can be selected.") List tags -) {} +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/LoginRequest.java b/src/main/java/com/joycrew/backend/dto/LoginRequest.java index 690a41f..0184e05 100644 --- a/src/main/java/com/joycrew/backend/dto/LoginRequest.java +++ b/src/main/java/com/joycrew/backend/dto/LoginRequest.java @@ -5,12 +5,12 @@ import jakarta.validation.constraints.NotBlank; public record LoginRequest( - @Schema(description = "이메일 주소", example = "user@example.com") - @Email(message = "유효한 이메일 형식이 아닙니다.") - @NotBlank(message = "이메일은 필수입니다.") + @Schema(description = "Email address", example = "user@example.com") + @Email(message = "Must be a valid email format.") + @NotBlank(message = "Email is required.") String email, - @Schema(description = "비밀번호", example = "password123!") - @NotBlank(message = "비밀번호는 필수입니다.") + @Schema(description = "Password", example = "password123!") + @NotBlank(message = "Password is required.") String password ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/LoginResponse.java b/src/main/java/com/joycrew/backend/dto/LoginResponse.java index 2afd119..fba2888 100644 --- a/src/main/java/com/joycrew/backend/dto/LoginResponse.java +++ b/src/main/java/com/joycrew/backend/dto/LoginResponse.java @@ -3,23 +3,23 @@ import com.joycrew.backend.entity.enums.AdminLevel; import io.swagger.v3.oas.annotations.media.Schema; -@Schema(description = "로그인 응답 DTO") +@Schema(description = "Login Response DTO") public record LoginResponse( - @Schema(description = "JWT 토큰") + @Schema(description = "JWT access token") String accessToken, - @Schema(description = "응답 메시지") + @Schema(description = "Response message") String message, - @Schema(description = "사용자 고유 ID") + @Schema(description = "Unique ID of the user") Long userId, - @Schema(description = "사용자 이름") + @Schema(description = "Name of the user") String name, - @Schema(description = "사용자 이메일") + @Schema(description = "Email of the user") String email, - @Schema(description = "사용자 역할") + @Schema(description = "Role of the user") AdminLevel role, - @Schema(description = "보유 포인트") + @Schema(description = "Total points balance") Integer totalPoint, - @Schema(description = "프로필 이미지 URL") + @Schema(description = "URL of the profile image") String profileImageUrl ) { public static LoginResponse fail(String message) { diff --git a/src/main/java/com/joycrew/backend/dto/PagedEmployeeResponse.java b/src/main/java/com/joycrew/backend/dto/PagedEmployeeResponse.java index 9ec31f7..1b4bea5 100644 --- a/src/main/java/com/joycrew/backend/dto/PagedEmployeeResponse.java +++ b/src/main/java/com/joycrew/backend/dto/PagedEmployeeResponse.java @@ -3,17 +3,17 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; -@Schema(description = "페이징 처리된 직원 목록 응답 DTO") +@Schema(description = "Paginated Employee List Response DTO") public record PagedEmployeeResponse( - @Schema(description = "직원 정보 목록") + @Schema(description = "List of employee information") List employees, - @Schema(description = "현재 페이지 번호 (0부터 시작)", example = "0") + @Schema(description = "Current page number (0-based)", example = "0") int currentPage, - @Schema(description = "전체 페이지 수", example = "10") + @Schema(description = "Total number of pages", example = "10") int totalPages, - @Schema(description = "마지막 페이지 여부", example = "false") + @Schema(description = "Indicates if this is the last page", example = "false") boolean isLastPage ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/PasswordResetConfirmRequest.java b/src/main/java/com/joycrew/backend/dto/PasswordResetConfirmRequest.java index 41cb84c..b356ffc 100644 --- a/src/main/java/com/joycrew/backend/dto/PasswordResetConfirmRequest.java +++ b/src/main/java/com/joycrew/backend/dto/PasswordResetConfirmRequest.java @@ -5,14 +5,13 @@ import jakarta.validation.constraints.Pattern; public record PasswordResetConfirmRequest( - @Schema(description = "이메일로 받은 비밀번호 재설정 토큰") - @NotBlank(message = "토큰은 필수입니다.") + @Schema(description = "Password reset token received via email") + @NotBlank(message = "Token is required.") String token, - @Schema(description = "새로운 비밀번호") - @NotBlank(message = "새로운 비밀번호는 필수입니다.") + @Schema(description = "The new password") + @NotBlank(message = "New password is required.") @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&])[A-Za-z\\d@$!%*#?&]{8,20}$", - message = "비밀번호는 영문, 숫자, 특수문자를 포함하여 8자 이상 20자 이하여야 합니다.") + message = "Password must be 8-20 characters long and include at least one letter, one number, and one special character.") String newPassword -) { -} \ No newline at end of file +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/PasswordResetRequest.java b/src/main/java/com/joycrew/backend/dto/PasswordResetRequest.java index a0f7c86..cddb827 100644 --- a/src/main/java/com/joycrew/backend/dto/PasswordResetRequest.java +++ b/src/main/java/com/joycrew/backend/dto/PasswordResetRequest.java @@ -5,9 +5,8 @@ import jakarta.validation.constraints.NotBlank; public record PasswordResetRequest( - @Schema(description = "비밀번호를 재설정할 계정의 이메일", example = "user@example.com") - @NotBlank(message = "이메일은 필수입니다.") - @Email(message = "유효한 이메일 형식이 아닙니다.") + @Schema(description = "Email of the account to reset the password for", example = "user@example.com") + @NotBlank(message = "Email is required.") + @Email(message = "Must be a valid email format.") String email -) { -} \ No newline at end of file +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/PointBalanceResponse.java b/src/main/java/com/joycrew/backend/dto/PointBalanceResponse.java index 67be5df..5f9a792 100644 --- a/src/main/java/com/joycrew/backend/dto/PointBalanceResponse.java +++ b/src/main/java/com/joycrew/backend/dto/PointBalanceResponse.java @@ -1,11 +1,9 @@ package com.joycrew.backend.dto; -import com.joycrew.backend.entity.Wallet; import io.swagger.v3.oas.annotations.media.Schema; -@Schema(description = "지갑 잔액 응답 DTO") +@Schema(description = "Wallet Balance Response DTO") public record PointBalanceResponse( - @Schema(description = "현재 잔액") Integer totalBalance, - @Schema(description = "선물 가능한 포인트") Integer giftableBalance -) { -} \ No newline at end of file + @Schema(description = "Current total balance") Integer totalBalance, + @Schema(description = "Current giftable points") Integer giftableBalance +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/SuccessResponse.java b/src/main/java/com/joycrew/backend/dto/SuccessResponse.java index 57ac18b..25c59ba 100644 --- a/src/main/java/com/joycrew/backend/dto/SuccessResponse.java +++ b/src/main/java/com/joycrew/backend/dto/SuccessResponse.java @@ -2,12 +2,12 @@ import io.swagger.v3.oas.annotations.media.Schema; -@Schema(description = "작업 성공 응답 DTO") +@Schema(description = "Successful Operation Response DTO") public record SuccessResponse( - @Schema(description = "성공 메시지", example = "작업이 성공적으로 완료되었습니다.") + @Schema(description = "Success message", example = "The operation was completed successfully.") String message ) { public static SuccessResponse defaultSuccess() { - return new SuccessResponse("성공적으로 처리되었습니다."); + return new SuccessResponse("Processed successfully."); } -} +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java b/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java index af3271a..f19e244 100644 --- a/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java +++ b/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java @@ -5,19 +5,18 @@ import java.time.LocalDate; -@Schema(description = "사용자 프로필 응답 DTO") +@Schema(description = "User Profile Response DTO") public record UserProfileResponse( - @Schema(description = "사용자 고유 ID") Long employeeId, - @Schema(description = "사용자 이름") String name, - @Schema(description = "이메일 주소") String email, - @Schema(description = "프로필 이미지 URL") String profileImageUrl, - @Schema(description = "현재 총 포인트 잔액") Integer totalBalance, - @Schema(description = "현재 선물 가능한 포인트 잔액") Integer giftableBalance, - @Schema(description = "사용자 권한")AdminLevel level, - @Schema(description = "소속 부서") String department, - @Schema(description = "직책") String position, - @Schema(description = "생년월일") LocalDate birthday, - @Schema(description = "주소") String address, - @Schema(description = "입사일") LocalDate hireDate -) { -} + @Schema(description = "Unique ID of the user") Long employeeId, + @Schema(description = "Name of the user") String name, + @Schema(description = "Email address of the user") String email, + @Schema(description = "URL of the profile image") String profileImageUrl, + @Schema(description = "Current total point balance") Integer totalBalance, + @Schema(description = "Current giftable point balance") Integer giftableBalance, + @Schema(description = "Role or permission level of the user") AdminLevel level, + @Schema(description = "Department name") String department, + @Schema(description = "Position or title of the user") String position, + @Schema(description = "Birth date of the user") LocalDate birthday, + @Schema(description = "Address of the user") String address, + @Schema(description = "Hire date of the user") LocalDate hireDate +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/UserProfileUpdateRequest.java b/src/main/java/com/joycrew/backend/dto/UserProfileUpdateRequest.java index 3422746..72f69df 100644 --- a/src/main/java/com/joycrew/backend/dto/UserProfileUpdateRequest.java +++ b/src/main/java/com/joycrew/backend/dto/UserProfileUpdateRequest.java @@ -4,22 +4,23 @@ import java.time.LocalDate; +@Schema(description = "User Profile Update Request DTO") public record UserProfileUpdateRequest( - @Schema(description = "변경할 직원 이름", example = "김조이") + @Schema(description = "The employee's new name", example = "John Doe") String name, - @Schema(description = "변경할 프로필 이미지 URL") + @Schema(description = "The new profile image URL") String profileImageUrl, - @Schema(description = "변경할 개인 이메일") + @Schema(description = "The new personal email address") String personalEmail, - @Schema(description = "변경할 연락처") + @Schema(description = "The new phone number") String phoneNumber, - @Schema(description = "변경할 생년월일") + @Schema(description = "The new birth date") LocalDate birthday, - @Schema(description = "변경할 주소") + @Schema(description = "The new address") String address ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/Company.java b/src/main/java/com/joycrew/backend/entity/Company.java index 827047c..b781dbf 100644 --- a/src/main/java/com/joycrew/backend/entity/Company.java +++ b/src/main/java/com/joycrew/backend/entity/Company.java @@ -51,7 +51,7 @@ public void changeStatus(String newStatus) { public void addBudget(double amount) { if (amount < 0) { - throw new IllegalArgumentException("예산은 음수일 수 없습니다."); + throw new IllegalArgumentException("Budget amount cannot be negative."); } this.totalCompanyBalance += amount; } diff --git a/src/main/java/com/joycrew/backend/entity/Employee.java b/src/main/java/com/joycrew/backend/entity/Employee.java index cab2d13..55a38ba 100644 --- a/src/main/java/com/joycrew/backend/entity/Employee.java +++ b/src/main/java/com/joycrew/backend/entity/Employee.java @@ -17,7 +17,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) @Builder -public class Employee implements UserDetails { +public class Employee { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/joycrew/backend/entity/RewardPointTransaction.java b/src/main/java/com/joycrew/backend/entity/RewardPointTransaction.java index 72edac4..e889267 100644 --- a/src/main/java/com/joycrew/backend/entity/RewardPointTransaction.java +++ b/src/main/java/com/joycrew/backend/entity/RewardPointTransaction.java @@ -38,7 +38,7 @@ public class RewardPointTransaction { @Enumerated(EnumType.STRING) private TransactionType type; - // 태그 저장을 위해 추가된 필드 + // A collection of tags associated with the transaction. @ElementCollection(targetClass = Tag.class, fetch = FetchType.EAGER) @CollectionTable(name = "transaction_tags", joinColumns = @JoinColumn(name = "transaction_id")) @Enumerated(EnumType.STRING) diff --git a/src/main/java/com/joycrew/backend/entity/Wallet.java b/src/main/java/com/joycrew/backend/entity/Wallet.java index 3262233..1f4c916 100644 --- a/src/main/java/com/joycrew/backend/entity/Wallet.java +++ b/src/main/java/com/joycrew/backend/entity/Wallet.java @@ -35,7 +35,7 @@ public Wallet(Employee employee) { public void addPoints(int amount) { if (amount < 0) { - throw new IllegalArgumentException("포인트는 음수일 수 없습니다."); + throw new IllegalArgumentException("Points to add cannot be negative."); } this.balance += amount; this.giftablePoint += amount; @@ -43,10 +43,10 @@ public void addPoints(int amount) { public void spendPoints(int amount) { if (amount < 0) { - throw new IllegalArgumentException("포인트는 음수일 수 없습니다."); + throw new IllegalArgumentException("Points to spend cannot be negative."); } if (this.balance < amount || this.giftablePoint < amount) { - throw new InsufficientPointsException("선물 가능한 포인트가 부족합니다."); + throw new InsufficientPointsException("Insufficient giftable points."); } this.balance -= amount; this.giftablePoint -= amount; diff --git a/src/main/java/com/joycrew/backend/event/NotificationListener.java b/src/main/java/com/joycrew/backend/event/NotificationListener.java index f6d2ad4..510941a 100644 --- a/src/main/java/com/joycrew/backend/event/NotificationListener.java +++ b/src/main/java/com/joycrew/backend/event/NotificationListener.java @@ -12,13 +12,14 @@ public class NotificationListener { @Async @EventListener public void handleRecognitionEvent(RecognitionEvent event) { - log.info("포인트 전송 이벤트 수신 (비동기 처리 시작)"); + log.info("Recognition event received. Starting asynchronous processing."); try { + // Simulate a delay for notification processing (e.g., sending a push notification). Thread.sleep(2000); - log.info("{}님이 {}님에게 {} 포인트를 선물했습니다. 메시지: {}", - event.getSenderId(), event.getReceiverId(), event.getPoints(), event.getMessage()); + log.info("User {} gifted {} points to user {}. Message: {}", + event.getSenderId(), event.getPoints(), event.getReceiverId(), event.getMessage()); } catch (InterruptedException e) { - log.error("알림 처리 중 오류 발생", e); + log.error("Error occurred while processing notification", e); Thread.currentThread().interrupt(); } } diff --git a/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java b/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java index 3ed1bed..ae93fa6 100644 --- a/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java @@ -36,7 +36,7 @@ public ResponseEntity handleValidationExceptions(MethodArgumentNo @ExceptionHandler({BadCredentialsException.class, UsernameNotFoundException.class}) public ResponseEntity handleAuthenticationException(Exception ex) { log.warn("Authentication failed: {}", ex.getMessage()); - ErrorResponse response = new ErrorResponse("AUTHENTICATION_FAILED", "이메일 또는 비밀번호가 올바르지 않습니다."); + ErrorResponse response = new ErrorResponse("AUTHENTICATION_FAILED", "The email or password provided is incorrect."); return new ResponseEntity<>(response, HttpStatus.UNAUTHORIZED); } @@ -50,7 +50,7 @@ public ResponseEntity handleNotFoundException(UserNotFoundExcepti @ExceptionHandler(Exception.class) public ResponseEntity handleAllUncaughtException(Exception ex) { log.error("Unhandled internal server error occurred", ex); - ErrorResponse response = new ErrorResponse("INTERNAL_SERVER_ERROR", "서버 내부 오류가 발생했습니다. 관리자에게 문의하세요."); + ErrorResponse response = new ErrorResponse("INTERNAL_SERVER_ERROR", "An internal server error occurred. Please contact an administrator."); return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/AuthService.java b/src/main/java/com/joycrew/backend/service/AuthService.java index 4273eb0..12646f6 100644 --- a/src/main/java/com/joycrew/backend/service/AuthService.java +++ b/src/main/java/com/joycrew/backend/service/AuthService.java @@ -62,7 +62,7 @@ public LoginResponse login(LoginRequest request) { return new LoginResponse( accessToken, - "로그인 성공", + "Login successful", employee.getEmployeeId(), employee.getEmployeeName(), employee.getEmail(), @@ -81,7 +81,7 @@ public void logout(HttpServletRequest request) { final String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { String jwt = authHeader.substring(7); - log.info("Logout request received. Token blacklisting can be implemented here."); + log.info("Logout request received. Token blacklisting can be implemented here if needed."); } } @@ -100,13 +100,13 @@ public void confirmPasswordReset(String token, String newPassword) { try { email = jwtUtil.getEmailFromToken(token); } catch (JwtException e) { - throw new BadCredentialsException("유효하지 않거나 만료된 토큰입니다.", e); + throw new BadCredentialsException("Invalid or expired token.", e); } Employee employee = employeeRepository.findByEmail(email) - .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다.")); + .orElseThrow(() -> new UserNotFoundException("User not found.")); employee.changePassword(newPassword, passwordEncoder); - log.info("비밀번호 재설정 완료: {}", email); + log.info("Password has been reset for: {}", email); } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java b/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java index 2fb15c2..83306eb 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java @@ -3,6 +3,7 @@ import com.joycrew.backend.dto.EmployeeQueryResponse; import com.joycrew.backend.dto.PagedEmployeeResponse; import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.service.mapper.EmployeeMapper; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import jakarta.persistence.TypedQuery; @@ -12,6 +13,7 @@ import org.springframework.util.StringUtils; import java.util.List; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -20,6 +22,7 @@ public class EmployeeQueryService { @PersistenceContext private final EntityManager em; + private final EmployeeMapper employeeMapper; public PagedEmployeeResponse getEmployees(String keyword, int page, int size, Long currentUserId) { StringBuilder whereClause = new StringBuilder(); @@ -58,8 +61,8 @@ public PagedEmployeeResponse getEmployees(String keyword, int page, int size, Lo dataQuery.setMaxResults(size); List employees = dataQuery.getResultList().stream() - .map(EmployeeQueryResponse::from) - .toList(); + .map(employeeMapper::toEmployeeQueryResponse) + .collect(Collectors.toList()); return new PagedEmployeeResponse( employees, diff --git a/src/main/java/com/joycrew/backend/service/EmployeeService.java b/src/main/java/com/joycrew/backend/service/EmployeeService.java index 5b410d0..e58bd72 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeService.java @@ -8,6 +8,7 @@ import com.joycrew.backend.exception.UserNotFoundException; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.WalletRepository; +import com.joycrew.backend.service.mapper.EmployeeMapper; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -20,27 +21,28 @@ public class EmployeeService { private final EmployeeRepository employeeRepository; private final WalletRepository walletRepository; private final PasswordEncoder passwordEncoder; + private final EmployeeMapper employeeMapper; @Transactional(readOnly = true) public UserProfileResponse getUserProfile(String userEmail) { Employee employee = employeeRepository.findByEmail(userEmail) - .orElseThrow(() -> new UserNotFoundException("인증된 사용자를 찾을 수 없습니다.")); + .orElseThrow(() -> new UserNotFoundException("Authenticated user not found.")); Wallet wallet = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) .orElse(new Wallet(employee)); - return UserProfileResponse.from(employee, wallet); + return employeeMapper.toUserProfileResponse(employee, wallet); } public void forcePasswordChange(String userEmail, PasswordChangeRequest request) { Employee employee = employeeRepository.findByEmail(userEmail) - .orElseThrow(() -> new UserNotFoundException("인증된 사용자를 찾을 수 없습니다.")); + .orElseThrow(() -> new UserNotFoundException("Authenticated user not found.")); employee.changePassword(request.newPassword(), passwordEncoder); } public void updateUserProfile(String userEmail, UserProfileUpdateRequest request) { Employee employee = employeeRepository.findByEmail(userEmail) - .orElseThrow(() -> new UserNotFoundException("인증된 사용자를 찾을 수 없습니다.")); + .orElseThrow(() -> new UserNotFoundException("Authenticated user not found.")); if (request.name() != null) { employee.updateName(request.name()); @@ -60,6 +62,6 @@ public void updateUserProfile(String userEmail, UserProfileUpdateRequest request if (request.address() != null) { employee.updateAddress(request.address()); } - employeeRepository.save(employee); + // No explicit save call is needed due to @Transactional } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/GiftPointService.java b/src/main/java/com/joycrew/backend/service/GiftPointService.java index 786b1f1..757d4be 100644 --- a/src/main/java/com/joycrew/backend/service/GiftPointService.java +++ b/src/main/java/com/joycrew/backend/service/GiftPointService.java @@ -27,20 +27,20 @@ public class GiftPointService { @Transactional public void giftPointsToColleague(String senderEmail, GiftPointRequest request) { Employee sender = employeeRepository.findByEmail(senderEmail) - .orElseThrow(() -> new UserNotFoundException("보내는 사용자를 찾을 수 없습니다.")); + .orElseThrow(() -> new UserNotFoundException("Sender not found.")); Employee receiver = employeeRepository.findById(request.receiverId()) - .orElseThrow(() -> new UserNotFoundException("받는 사용자를 찾을 수 없습니다.")); + .orElseThrow(() -> new UserNotFoundException("Receiver not found.")); Wallet senderWallet = walletRepository.findByEmployee_EmployeeId(sender.getEmployeeId()) - .orElseThrow(() -> new IllegalStateException("보내는 사용자의 지갑이 없습니다.")); + .orElseThrow(() -> new IllegalStateException("Sender's wallet does not exist.")); Wallet receiverWallet = walletRepository.findByEmployee_EmployeeId(receiver.getEmployeeId()) - .orElseThrow(() -> new IllegalStateException("받는 사용자의 지갑이 없습니다.")); + .orElseThrow(() -> new IllegalStateException("Receiver's wallet does not exist.")); - // 포인트 이체 + // Transfer points senderWallet.spendPoints(request.points()); receiverWallet.addPoints(request.points()); - // 트랜잭션 기록 + // Record the transaction RewardPointTransaction transaction = RewardPointTransaction.builder() .sender(sender) .receiver(receiver) @@ -51,8 +51,9 @@ public void giftPointsToColleague(String senderEmail, GiftPointRequest request) .build(); transactionRepository.save(transaction); + // Publish an event for notifications or other async tasks eventPublisher.publishEvent( new RecognitionEvent(this, sender.getEmployeeId(), receiver.getEmployeeId(), request.points(), request.message()) ); } -} +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java b/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java index dc22271..75e3455 100644 --- a/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java +++ b/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java @@ -22,14 +22,15 @@ public class TransactionHistoryService { public List getTransactionHistory(String userEmail) { Employee user = employeeRepository.findByEmail(userEmail) - .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다: " + userEmail)); + .orElseThrow(() -> new UserNotFoundException("User not found with email: " + userEmail)); return transactionRepository.findBySenderOrReceiverOrderByTransactionDateDesc(user, user) .stream() .map(tx -> { boolean isSender = user.equals(tx.getSender()); int amount = isSender ? -tx.getPointAmount() : tx.getPointAmount(); - String counterparty = isSender ? tx.getReceiver().getEmployeeName() : (tx.getSender() != null ? tx.getSender().getEmployeeName() : "시스템"); + // Use "System" for transactions where the sender is null (e.g., admin awards). + String counterparty = isSender ? tx.getReceiver().getEmployeeName() : (tx.getSender() != null ? tx.getSender().getEmployeeName() : "System"); return TransactionHistoryResponse.builder() .transactionId(tx.getTransactionId()) diff --git a/src/main/java/com/joycrew/backend/service/WalletService.java b/src/main/java/com/joycrew/backend/service/WalletService.java index 0123100..ba34bb0 100644 --- a/src/main/java/com/joycrew/backend/service/WalletService.java +++ b/src/main/java/com/joycrew/backend/service/WalletService.java @@ -6,6 +6,7 @@ import com.joycrew.backend.exception.UserNotFoundException; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.WalletRepository; +import com.joycrew.backend.service.mapper.EmployeeMapper; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,14 +17,15 @@ public class WalletService { private final WalletRepository walletRepository; private final EmployeeRepository employeeRepository; + private final EmployeeMapper employeeMapper; public PointBalanceResponse getPointBalance(String userEmail) { Employee employee = employeeRepository.findByEmail(userEmail) - .orElseThrow(() -> new UserNotFoundException("인증된 사용자를 찾을 수 없습니다.")); + .orElseThrow(() -> new UserNotFoundException("Authenticated user not found.")); Wallet wallet = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) .orElse(new Wallet(employee)); - return PointBalanceResponse.from(wallet); + return employeeMapper.toPointBalanceResponse(wallet); } } diff --git a/src/main/java/com/joycrew/backend/service/mapper/EmployeeMapper.java b/src/main/java/com/joycrew/backend/service/mapper/EmployeeMapper.java index 8fa3f55..4e5718c 100644 --- a/src/main/java/com/joycrew/backend/service/mapper/EmployeeMapper.java +++ b/src/main/java/com/joycrew/backend/service/mapper/EmployeeMapper.java @@ -2,6 +2,7 @@ import com.joycrew.backend.dto.AdminEmployeeQueryResponse; import com.joycrew.backend.dto.EmployeeQueryResponse; +import com.joycrew.backend.dto.PointBalanceResponse; import com.joycrew.backend.dto.UserProfileResponse; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.Wallet; @@ -54,4 +55,8 @@ public UserProfileResponse toUserProfileResponse(Employee employee, Wallet walle employee.getHireDate() ); } + + public PointBalanceResponse toPointBalanceResponse(Wallet wallet) { + return new PointBalanceResponse(wallet.getBalance(), wallet.getGiftablePoint()); + } } \ No newline at end of file From 54c141b2d0f9bd494096c0de8a46f61e0ecc6ad2 Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Thu, 7 Aug 2025 18:25:31 +0900 Subject: [PATCH 079/135] =?UTF-8?q?refactor=20:=20N+1=20=EB=AC=B8=EC=A0=9C?= =?UTF-8?q?=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/joycrew/backend/config/SecurityConfig.java | 1 - .../backend/repository/RewardPointTransactionRepository.java | 3 +++ .../com/joycrew/backend/service/TransactionHistoryService.java | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/joycrew/backend/config/SecurityConfig.java b/src/main/java/com/joycrew/backend/config/SecurityConfig.java index 588e8fd..92661c4 100644 --- a/src/main/java/com/joycrew/backend/config/SecurityConfig.java +++ b/src/main/java/com/joycrew/backend/config/SecurityConfig.java @@ -24,7 +24,6 @@ import org.springframework.security.authentication.ProviderManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; - @Configuration @EnableWebSecurity @RequiredArgsConstructor diff --git a/src/main/java/com/joycrew/backend/repository/RewardPointTransactionRepository.java b/src/main/java/com/joycrew/backend/repository/RewardPointTransactionRepository.java index 587bb38..0a304e1 100644 --- a/src/main/java/com/joycrew/backend/repository/RewardPointTransactionRepository.java +++ b/src/main/java/com/joycrew/backend/repository/RewardPointTransactionRepository.java @@ -2,11 +2,14 @@ import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.RewardPointTransaction; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; public interface RewardPointTransactionRepository extends JpaRepository { + @EntityGraph(attributePaths = {"sender", "receiver"}) List findBySenderOrReceiverOrderByTransactionDateDesc(Employee sender, Employee receiver); + List findAllByOrderByTransactionDateDesc(); } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java b/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java index 75e3455..944dcb0 100644 --- a/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java +++ b/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java @@ -24,12 +24,12 @@ public List getTransactionHistory(String userEmail) Employee user = employeeRepository.findByEmail(userEmail) .orElseThrow(() -> new UserNotFoundException("User not found with email: " + userEmail)); + // This call now efficiently fetches transactions with related sender/receiver data. return transactionRepository.findBySenderOrReceiverOrderByTransactionDateDesc(user, user) .stream() .map(tx -> { boolean isSender = user.equals(tx.getSender()); int amount = isSender ? -tx.getPointAmount() : tx.getPointAmount(); - // Use "System" for transactions where the sender is null (e.g., admin awards). String counterparty = isSender ? tx.getReceiver().getEmployeeName() : (tx.getSender() != null ? tx.getSender().getEmployeeName() : "System"); return TransactionHistoryResponse.builder() From d460c167da735177955ac19d95468f7d50941af2 Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Thu, 7 Aug 2025 18:41:56 +0900 Subject: [PATCH 080/135] =?UTF-8?q?feat=20:=20S3=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 5 ++ .../com/joycrew/backend/config/AwsConfig.java | 23 ++++++++ .../backend/controller/UserController.java | 12 ++-- .../service/EmployeeRegistrationService.java | 1 + .../backend/service/EmployeeService.java | 9 ++- .../backend/service/S3FileStorageService.java | 55 +++++++++++++++++++ src/main/resources/application-prod.yml | 8 ++- src/main/resources/application.yml | 8 ++- src/main/resources/bootstrap.yml | 11 ++++ 9 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/joycrew/backend/config/AwsConfig.java create mode 100644 src/main/java/com/joycrew/backend/service/S3FileStorageService.java create mode 100644 src/main/resources/bootstrap.yml diff --git a/build.gradle b/build.gradle index 7c95e2b..c07c3cb 100644 --- a/build.gradle +++ b/build.gradle @@ -46,6 +46,11 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + // AWS SDK for S3 and Secrets Manager Integration + implementation platform('io.awspring.cloud:spring-cloud-aws-dependencies:3.1.1') + implementation 'io.awspring.cloud:spring-cloud-aws-starter-secrets-manager' + implementation 'software.amazon.awssdk:s3' + // Database Drivers runtimeOnly 'com.mysql:mysql-connector-j' // For production runtimeOnly 'com.h2database:h2' // For development diff --git a/src/main/java/com/joycrew/backend/config/AwsConfig.java b/src/main/java/com/joycrew/backend/config/AwsConfig.java new file mode 100644 index 0000000..170d54d --- /dev/null +++ b/src/main/java/com/joycrew/backend/config/AwsConfig.java @@ -0,0 +1,23 @@ +package com.joycrew.backend.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +@Configuration +public class AwsConfig { + + @Value("${aws.region}") + private String region; + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(DefaultCredentialsProvider.create()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/controller/UserController.java b/src/main/java/com/joycrew/backend/controller/UserController.java index 5588e90..6acd306 100644 --- a/src/main/java/com/joycrew/backend/controller/UserController.java +++ b/src/main/java/com/joycrew/backend/controller/UserController.java @@ -11,9 +11,11 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; @Tag(name = "User", description = "APIs related to user information") @RestController @@ -41,12 +43,14 @@ public ResponseEntity forceChangePassword( return ResponseEntity.ok(new SuccessResponse("Password changed successfully.")); } - @Operation(summary = "Update my information", description = "Send only the fields you want to change in the request body.", security = @SecurityRequirement(name = "Authorization")) - @PatchMapping("/profile") + @Operation(summary = "Update my information", description = "Send profile data as 'request' part and image as 'profileImage' part in a multipart/form-data request.", security = @SecurityRequirement(name = "Authorization")) + @PatchMapping(value = "/profile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity updateMyProfile( @AuthenticationPrincipal UserPrincipal principal, - @RequestBody UserProfileUpdateRequest request) { - employeeService.updateUserProfile(principal.getUsername(), request); + @RequestPart("request") UserProfileUpdateRequest request, + @RequestPart(value = "profileImage", required = false) MultipartFile profileImage) { + + employeeService.updateUserProfile(principal.getUsername(), request, profileImage); return ResponseEntity.ok(new SuccessResponse("Your information has been updated successfully.")); } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/EmployeeRegistrationService.java b/src/main/java/com/joycrew/backend/service/EmployeeRegistrationService.java index 4ec00c3..72dde81 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeRegistrationService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeRegistrationService.java @@ -35,6 +35,7 @@ public class EmployeeRegistrationService { private final DepartmentRepository departmentRepository; private final WalletRepository walletRepository; private final PasswordEncoder passwordEncoder; + private final S3FileStorageService s3FileStorageService; public Employee registerEmployee(EmployeeRegistrationRequest request) { if (employeeRepository.findByEmail(request.email()).isPresent()) { diff --git a/src/main/java/com/joycrew/backend/service/EmployeeService.java b/src/main/java/com/joycrew/backend/service/EmployeeService.java index e58bd72..b7ed75c 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeService.java @@ -13,6 +13,7 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; @Service @RequiredArgsConstructor @@ -22,6 +23,7 @@ public class EmployeeService { private final WalletRepository walletRepository; private final PasswordEncoder passwordEncoder; private final EmployeeMapper employeeMapper; + private final S3FileStorageService s3FileStorageService; @Transactional(readOnly = true) public UserProfileResponse getUserProfile(String userEmail) { @@ -40,15 +42,16 @@ public void forcePasswordChange(String userEmail, PasswordChangeRequest request) employee.changePassword(request.newPassword(), passwordEncoder); } - public void updateUserProfile(String userEmail, UserProfileUpdateRequest request) { + public void updateUserProfile(String userEmail, UserProfileUpdateRequest request, MultipartFile profileImage) { Employee employee = employeeRepository.findByEmail(userEmail) .orElseThrow(() -> new UserNotFoundException("Authenticated user not found.")); if (request.name() != null) { employee.updateName(request.name()); } - if (request.profileImageUrl() != null) { - employee.updateProfileImageUrl(request.profileImageUrl()); + if (profileImage != null && !profileImage.isEmpty()) { + String profileImageUrl = s3FileStorageService.uploadFile(profileImage); + employee.updateProfileImageUrl(profileImageUrl); } if (request.personalEmail() != null) { employee.updatePersonalEmail(request.personalEmail()); diff --git a/src/main/java/com/joycrew/backend/service/S3FileStorageService.java b/src/main/java/com/joycrew/backend/service/S3FileStorageService.java new file mode 100644 index 0000000..84be8b1 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/S3FileStorageService.java @@ -0,0 +1,55 @@ +package com.joycrew.backend.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +import java.io.IOException; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class S3FileStorageService { + + private final S3Client s3Client; + + @Value("${aws.s3.bucket-name}") + private String bucketName; + + public String uploadFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("File to upload cannot be null or empty."); + } + + String originalFilename = file.getOriginalFilename(); + String uniqueFileName = UUID.randomUUID().toString() + "-" + originalFilename; + + try { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucketName) + .key(uniqueFileName) + .contentType(file.getContentType()) + .build(); + + s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + + String fileUrl = s3Client.utilities().getUrl(builder -> builder.bucket(bucketName).key(uniqueFileName)).toExternalForm(); + log.info("File uploaded successfully to S3. URL: {}", fileUrl); + return fileUrl; + + } catch (IOException e) { + log.error("Error getting input stream from file.", e); + throw new RuntimeException("Failed to process file for upload.", e); + } catch (S3Exception e) { + log.error("Failed to upload file to S3.", e); + throw new RuntimeException("Failed to upload file to S3.", e); + } + } +} \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index c07faa0..26c591e 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -37,4 +37,10 @@ logging: # The frontend service URL for the production environment. app: - frontend-url: https://joycrew.co.kr \ No newline at end of file + frontend-url: https://joycrew.co.kr + +# AWS specific settings for production +aws: + s3: + # Production S3 bucket name + bucket-name: 'joycrew-prod-bucket' \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 858d527..40b204f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -55,4 +55,10 @@ logging: # Application-specific common properties. app: # Default frontend URL, primarily for the 'dev' environment. - frontend-url: http://localhost:3000 \ No newline at end of file + frontend-url: http://localhost:3000 + +# AWS specific settings +aws: + region: 'ap-northeast-2' + s3: + bucket-name: 'joycrew-dev-bucket' \ No newline at end of file diff --git a/src/main/resources/bootstrap.yml b/src/main/resources/bootstrap.yml new file mode 100644 index 0000000..5689c63 --- /dev/null +++ b/src/main/resources/bootstrap.yml @@ -0,0 +1,11 @@ +# =================================================================== +# SPRING BOOTSTRAP CONFIGURATION +# +# This file is loaded before application.yml and is used to +# import configuration from external sources like AWS Secrets Manager. +# =================================================================== +spring: + config: + # Imports secrets from the specified path in AWS Secrets Manager. + # The application must have the appropriate IAM role to access this. + import: "aws-secretsmanager:/secret/joycrew/prod" \ No newline at end of file From 2a23b817cfbeaeb7368db55f69f7fcdcc19c82af Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Thu, 7 Aug 2025 19:13:24 +0900 Subject: [PATCH 081/135] =?UTF-8?q?release=20:=20=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=EC=9A=A9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 40b204f..ecc5ab7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -18,9 +18,6 @@ spring: application: name: joycrew # Specifies the default active profile. - profiles: - active: dev - # Common mail server settings used by all profiles. mail: host: smtp.gmail.com port: 587 From e00ae009379a5b75e904503b39b842cf977b50a7 Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Thu, 7 Aug 2025 19:15:52 +0900 Subject: [PATCH 082/135] =?UTF-8?q?hotfix=20:=20=EC=8B=A4=EC=88=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ecc5ab7..40b204f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -18,6 +18,9 @@ spring: application: name: joycrew # Specifies the default active profile. + profiles: + active: dev + # Common mail server settings used by all profiles. mail: host: smtp.gmail.com port: 587 From e1551ef4fb56afa67183a95eb597d2865855a411 Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Thu, 7 Aug 2025 20:15:57 +0900 Subject: [PATCH 083/135] =?UTF-8?q?test=20:=20test=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/config/SecurityConfig.java | 1 + .../config/TestUserDetailsService.java | 7 +- .../AdminEmployeeControllerTest.java | 86 ++++---- .../controller/AuthControllerTest.java | 70 +++++-- .../EmployeeQueryControllerTest.java | 52 +---- .../controller/GiftPointControllerTest.java | 33 ++- .../TransactionHistoryControllerTest.java | 23 +- .../controller/UserControllerTest.java | 60 +++--- .../controller/WalletControllerTest.java | 25 +-- ...ckUserPrincipalSecurityContextFactory.java | 9 +- .../service/AdminEmployeeServiceTest.java | 196 ------------------ .../service/AdminFeaturesIntegrationTest.java | 37 ++-- .../service/AuthServiceIntegrationTest.java | 51 ++--- .../backend/service/AuthServiceTest.java | 53 +++-- .../backend/service/EmailServiceTest.java | 15 +- .../service/EmployeeQueryServiceTest.java | 14 +- .../EmployeeServiceIntegrationTest.java | 41 ++-- .../backend/service/EmployeeServiceTest.java | 38 ++-- .../GiftPointServiceIntegrationTest.java | 66 ++++++ .../backend/service/GiftPointServiceTest.java | 82 ++++++++ .../TransactionHistoryServiceTest.java | 10 +- .../backend/service/WalletServiceTest.java | 10 +- 22 files changed, 444 insertions(+), 535 deletions(-) delete mode 100644 src/test/java/com/joycrew/backend/service/AdminEmployeeServiceTest.java create mode 100644 src/test/java/com/joycrew/backend/service/GiftPointServiceIntegrationTest.java create mode 100644 src/test/java/com/joycrew/backend/service/GiftPointServiceTest.java diff --git a/src/main/java/com/joycrew/backend/config/SecurityConfig.java b/src/main/java/com/joycrew/backend/config/SecurityConfig.java index 92661c4..b261c8f 100644 --- a/src/main/java/com/joycrew/backend/config/SecurityConfig.java +++ b/src/main/java/com/joycrew/backend/config/SecurityConfig.java @@ -45,6 +45,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/", "/h2-console/**", "/api/auth/login", + "/api/auth/password-reset/request", "/api/auth/password-reset/confirm", "/v3/api-docs/**", diff --git a/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java b/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java index fcc3668..39e5586 100644 --- a/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java +++ b/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java @@ -2,6 +2,7 @@ import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.enums.AdminLevel; +import com.joycrew.backend.security.UserPrincipal; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -19,7 +20,7 @@ public TestUserDetailsService() { users.put("testuser@joycrew.com", Employee.builder() .employeeId(1L) .email("testuser@joycrew.com") - .employeeName("테스트유저") + .employeeName("Test User") .role(AdminLevel.EMPLOYEE) .status("ACTIVE") .passwordHash("{noop}password") @@ -28,7 +29,7 @@ public TestUserDetailsService() { users.put("nowallet@joycrew.com", Employee.builder() .employeeId(99L) .email("nowallet@joycrew.com") - .employeeName("지갑없음") + .employeeName("No Wallet User") .role(AdminLevel.EMPLOYEE) .status("ACTIVE") .passwordHash("{noop}password") @@ -40,6 +41,6 @@ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundEx if (!users.containsKey(username)) { throw new UsernameNotFoundException("User not found: " + username); } - return users.get(username); + return new UserPrincipal(users.get(username)); } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java b/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java index 21dd2f0..bf66980 100644 --- a/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java @@ -8,10 +8,12 @@ import com.joycrew.backend.entity.enums.AdminLevel; import com.joycrew.backend.entity.enums.TransactionType; import com.joycrew.backend.security.WithMockUserPrincipal; +import com.joycrew.backend.service.AdminPointService; +import com.joycrew.backend.service.EmployeeManagementService; +import com.joycrew.backend.service.EmployeeRegistrationService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; @@ -24,112 +26,102 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; -@WebMvcTest(controllers = AdminEmployeeController.class, - excludeAutoConfiguration = {SecurityAutoConfiguration.class}) +@WebMvcTest(controllers = AdminEmployeeController.class) class AdminEmployeeControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; - @MockBean private AdminEmployeeService adminEmployeeService; + @MockBean private EmployeeRegistrationService registrationService; + @MockBean private EmployeeManagementService managementService; + @MockBean private AdminPointService pointService; @Test - @WithMockUser(roles = "HR_ADMIN") - @DisplayName("POST /api/admin/employees - 직원 등록 성공") + @WithMockUser(roles = "SUPER_ADMIN") + @DisplayName("POST /api/admin/employees - Should register employee successfully") void registerEmployee_success() throws Exception { // Given EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( - "김여은", - "kye02@example.com", - "password123!", - "조이크루", - "인사팀", - "사원", - AdminLevel.EMPLOYEE, - null, - null, - null + "Jane Doe", "jane.doe@example.com", "password123!", + "JoyCrew", "HR", "Staff", AdminLevel.EMPLOYEE, + null, null, null ); - Employee mockEmployee = Employee.builder().employeeId(1L).build(); - when(adminEmployeeService.registerEmployee(any(EmployeeRegistrationRequest.class))) + when(registrationService.registerEmployee(any(EmployeeRegistrationRequest.class))) .thenReturn(mockEmployee); // When & Then mockMvc.perform(post("/api/admin/employees") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) + .content(objectMapper.writeValueAsString(request)) + .with(csrf())) // CSRF 토큰 추가 .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("직원 생성 완료 (ID: 1)")); + .andExpect(jsonPath("$.message").value("Employee created successfully (ID: 1)")); } @Test - @WithMockUser(roles = "HR_ADMIN") - @DisplayName("POST /api/admin/employees/bulk - 직원 일괄 등록 성공") + @WithMockUser(roles = "SUPER_ADMIN") + @DisplayName("POST /api/admin/employees/bulk - Should bulk register employees successfully") void registerEmployeesFromCsv_success() throws Exception { - // Given: 예제 CSV 내용 - String csvContent = """ - name,email,initialPassword,companyName,departmentName,position,role - 김여은,kye02@example.com,password123,조이크루,인사팀,사원,EMPLOYEE - """; - + // Given MockMultipartFile file = new MockMultipartFile( - "file", - "employees.csv", - "text/csv", - csvContent.getBytes(StandardCharsets.UTF_8) + "file", "employees.csv", "text/csv", + "name,email,initialPassword,companyName,departmentName,position,role\n".getBytes(StandardCharsets.UTF_8) ); // When & Then mockMvc.perform(multipart("/api/admin/employees/bulk") .file(file) - .contentType(MediaType.MULTIPART_FORM_DATA)) + .with(csrf())) // CSRF 토큰 추가 .andExpect(status().isOk()) - .andExpect(content().string("CSV 업로드 및 직원 등록이 완료되었습니다.")); + .andExpect(jsonPath("$.message").value("CSV processed and employee registration completed.")); } @Test - @DisplayName("PATCH /api/admin/employees/{id} - 직원 정보 업데이트 성공") + @DisplayName("PATCH /api/admin/employees/{id} - Should update employee successfully") @WithMockUser(roles = "SUPER_ADMIN") void updateEmployee_Success() throws Exception { // Given - AdminEmployeeUpdateRequest request = new AdminEmployeeUpdateRequest("업데이트된 이름", null, "팀장", null, null); + AdminEmployeeUpdateRequest request = new AdminEmployeeUpdateRequest("Updated Name", null, "Manager", null, null); // When & Then mockMvc.perform(patch("/api/admin/employees/1") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) + .content(objectMapper.writeValueAsString(request)) + .with(csrf())) // CSRF 토큰 추가 .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("직원 정보가 성공적으로 업데이트되었습니다.")); + .andExpect(jsonPath("$.message").value("Employee information updated successfully.")); } @Test - @DisplayName("DELETE /api/admin/employees/{id} - 직원 삭제 성공") + @DisplayName("DELETE /api/admin/employees/{id} - Should deactivate employee successfully") @WithMockUser(roles = "SUPER_ADMIN") void deleteEmployee_Success() throws Exception { // When & Then - mockMvc.perform(delete("/api/admin/employees/1")) + mockMvc.perform(delete("/api/admin/employees/1") + .with(csrf())) // CSRF 토큰 추가 .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("직원이 성공적으로 삭제(비활성화) 처리되었습니다.")); + .andExpect(jsonPath("$.message").value("Employee successfully deactivated.")); } @Test - @DisplayName("POST /api/admin/points/distribute - 포인트 분배 성공") + @DisplayName("POST /api/admin/points/distribute - Should distribute points successfully") @WithMockUserPrincipal(role="SUPER_ADMIN") void distributePoints_Success() throws Exception { // Given AdminPointDistributionRequest request = new AdminPointDistributionRequest( - List.of(1L, 2L), 100, "보너스", TransactionType.ADMIN_ADJUSTMENT); + List.of(1L, 2L), 100, "Bonus", TransactionType.ADMIN_ADJUSTMENT); // When & Then mockMvc.perform(post("/api/admin/employees/points/distribute") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) + .content(objectMapper.writeValueAsString(request)) + .with(csrf())) // CSRF 토큰 추가 .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("포인트 분배(회수) 작업이 완료되었습니다.")); + .andExpect(jsonPath("$.message").value("Point distribution process completed successfully.")); } - -} +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java b/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java index 7fd95f8..fc86909 100644 --- a/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java @@ -3,6 +3,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.joycrew.backend.dto.LoginRequest; import com.joycrew.backend.dto.LoginResponse; +import com.joycrew.backend.dto.PasswordResetConfirmRequest; +import com.joycrew.backend.dto.PasswordResetRequest; import com.joycrew.backend.entity.enums.AdminLevel; import com.joycrew.backend.exception.GlobalExceptionHandler; import com.joycrew.backend.service.AuthService; @@ -19,6 +21,7 @@ import org.springframework.test.web.servlet.MockMvc; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -32,25 +35,18 @@ class AuthControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; - @MockBean private AuthService authService; @Test - @DisplayName("POST /api/auth/login - 로그인 성공") + @DisplayName("POST /api/auth/login - Should succeed with correct credentials") void login_Success() throws Exception { // Given LoginRequest request = new LoginRequest("test@joycrew.com", "password123!"); LoginResponse successResponse = new LoginResponse( - "mocked.jwt.token", - "로그인 성공", - 1L, - "테스트유저", - "test@joycrew.com", - AdminLevel.EMPLOYEE, - 1000, - "http://example.com/profile.jpg" + "mocked.jwt.token", "Login successful", 1L, + "Test User", "test@joycrew.com", AdminLevel.EMPLOYEE, + 1000, "http://example.com/profile.jpg" ); - when(authService.login(any(LoginRequest.class))).thenReturn(successResponse); // When & Then @@ -59,21 +55,18 @@ void login_Success() throws Exception { .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) .andExpect(jsonPath("$.accessToken").value("mocked.jwt.token")) - .andExpect(jsonPath("$.message").value("로그인 성공")) - .andExpect(jsonPath("$.userId").value(1L)) - .andExpect(jsonPath("$.email").value("test@joycrew.com")) - .andExpect(jsonPath("$.name").value("테스트유저")) - .andExpect(jsonPath("$.role").value("EMPLOYEE")); + .andExpect(jsonPath("$.message").value("Login successful")); } @Test - @DisplayName("POST /api/auth/login - 로그인 실패 (자격 증명 오류)") + @DisplayName("POST /api/auth/login - Should fail with bad credentials") void login_Failure_AuthenticationError() throws Exception { + // Given LoginRequest request = new LoginRequest("test@joycrew.com", "wrongpassword"); - when(authService.login(any(LoginRequest.class))) - .thenThrow(new BadCredentialsException("이메일 또는 비밀번호가 올바르지 않습니다.")); + .thenThrow(new BadCredentialsException("Bad credentials")); + // When & Then mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) @@ -82,13 +75,44 @@ void login_Failure_AuthenticationError() throws Exception { } @Test - @DisplayName("POST /api/auth/logout - 로그아웃 성공") + @DisplayName("POST /api/auth/logout - Should succeed") void logout_Success() throws Exception { + // Given doNothing().when(authService).logout(any(HttpServletRequest.class)); - mockMvc.perform(post("/api/auth/logout") - .header("Authorization", "Bearer some.mock.token")) + // When & Then + mockMvc.perform(post("/api/auth/logout")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("You have been logged out.")); + } + + @Test + @DisplayName("POST /api/auth/password-reset/request - Should succeed") + void requestPasswordReset_Success() throws Exception { + // Given + PasswordResetRequest request = new PasswordResetRequest("user@example.com"); + doNothing().when(authService).requestPasswordReset(anyString()); + + // When & Then + mockMvc.perform(post("/api/auth/password-reset/request") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("A password reset email has been requested. Please check your email.")); + } + + @Test + @DisplayName("POST /api/auth/password-reset/confirm - Should succeed") + void confirmPasswordReset_Success() throws Exception { + // Given + PasswordResetConfirmRequest request = new PasswordResetConfirmRequest("valid-token", "newPassword123!"); + doNothing().when(authService).confirmPasswordReset(anyString(), anyString()); + + // When & Then + mockMvc.perform(post("/api/auth/password-reset/confirm") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("로그아웃 되었습니다.")); + .andExpect(jsonPath("$.message").value("Password changed successfully.")); } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java b/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java index 306492d..ace759b 100644 --- a/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java @@ -1,6 +1,5 @@ package com.joycrew.backend.controller; -import com.fasterxml.jackson.databind.ObjectMapper; import com.joycrew.backend.dto.EmployeeQueryResponse; import com.joycrew.backend.dto.PagedEmployeeResponse; import com.joycrew.backend.security.EmployeeDetailsService; @@ -26,62 +25,29 @@ class EmployeeQueryControllerTest { @Autowired private MockMvc mockMvc; - @Autowired private ObjectMapper objectMapper; - @MockBean private EmployeeQueryService employeeQueryService; @MockBean private JwtUtil jwtUtil; @MockBean private EmployeeDetailsService employeeDetailsService; @Test - @DisplayName("GET /api/employee/query - 직원 목록 검색 성공") + @DisplayName("GET /api/employee/query - Should search employees successfully") @WithMockUserPrincipal void searchEmployees_success() throws Exception { + // Given EmployeeQueryResponse mockEmployee = new EmployeeQueryResponse( - 2L, - "https://cdn.joycrew.com/profile/user1.jpg", - "김여은", - "인사팀", - "사원" + 2L, "https://cdn.joycrew.com/profile/user1.jpg", + "Jane Doe", "HR", "Staff" ); PagedEmployeeResponse mockResponse = new PagedEmployeeResponse(List.of(mockEmployee), 0, 1, true); - when(employeeQueryService.getEmployees(anyString(), anyInt(), anyInt(), anyLong())) .thenReturn(mockResponse); + // When & Then mockMvc.perform(get("/api/employee/query") - .param("keyword", "김") - .param("page", "0") - .param("size", "10") + .param("keyword", "Jane") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.employees[0].employeeName").value("김여은")) - .andExpect(jsonPath("$.employees[0].departmentName").value("인사팀")) - .andExpect(jsonPath("$.currentPage").value(0)) - .andExpect(jsonPath("$.totalPages").value(1)) - .andExpect(jsonPath("$.isLastPage").value(true)); - } - - @Test - @DisplayName("GET /api/employee/query - 검색어 없이도 정상 조회") - @WithMockUserPrincipal - void searchEmployees_noKeyword() throws Exception { - EmployeeQueryResponse mockEmployee = new EmployeeQueryResponse( - 2L, - null, - "홍길동", - null, - "주임" - ); - PagedEmployeeResponse mockResponse = new PagedEmployeeResponse(List.of(mockEmployee), 0, 1, true); - - when(employeeQueryService.getEmployees(isNull(), anyInt(), anyInt(), anyLong())) - .thenReturn(mockResponse); - - mockMvc.perform(get("/api/employee/query") - .param("page", "0") - .param("size", "10")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.employees[0].employeeName").value("홍길동")) - .andExpect(jsonPath("$.employees[0].position").value("주임")); + .andExpect(jsonPath("$.employees[0].employeeName").value("Jane Doe")) + .andExpect(jsonPath("$.currentPage").value(0)); } -} +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/GiftPointControllerTest.java b/src/test/java/com/joycrew/backend/controller/GiftPointControllerTest.java index 949a98f..12f6bd3 100644 --- a/src/test/java/com/joycrew/backend/controller/GiftPointControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/GiftPointControllerTest.java @@ -3,6 +3,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.joycrew.backend.dto.GiftPointRequest; import com.joycrew.backend.entity.enums.Tag; +import com.joycrew.backend.security.EmployeeDetailsService; +import com.joycrew.backend.security.JwtUtil; import com.joycrew.backend.security.WithMockUserPrincipal; import com.joycrew.backend.service.GiftPointService; import org.junit.jupiter.api.DisplayName; @@ -12,48 +14,41 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; // <-- 이 import 추가 import java.util.List; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doNothing; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(GiftPointController.class) class GiftPointControllerTest { - @Autowired - private MockMvc mockMvc; - - @MockBean - private GiftPointService giftPointService; - - @Autowired - private ObjectMapper objectMapper; + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @MockBean private GiftPointService giftPointService; + @MockBean private JwtUtil jwtUtil; + @MockBean private EmployeeDetailsService employeeDetailsService; @Test @WithMockUserPrincipal(email = "sender@example.com") - @DisplayName("동료에게 포인트 선물 성공") + @DisplayName("POST /api/gift-points - Should gift points to a colleague successfully") void testGiftPointsSuccess() throws Exception { // given GiftPointRequest request = new GiftPointRequest( - 2L, - 50, - "수고하셨어요!", - List.of(Tag.TEAMWORK, Tag.INNOVATION) + 2L, 50, "Great work!", List.of(Tag.TEAMWORK) ); - - doNothing().when(giftPointService).giftPointsToColleague(any(), any()); + doNothing().when(giftPointService).giftPointsToColleague(anyString(), any(GiftPointRequest.class)); // when & then mockMvc.perform(post("/api/gift-points") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) - .with(SecurityMockMvcRequestPostProcessors.csrf()) - ) + .with(csrf())) .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("포인트를 성공적으로 보냈습니다.")); + .andExpect(jsonPath("$.message").value("Points sent successfully.")); } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/TransactionHistoryControllerTest.java b/src/test/java/com/joycrew/backend/controller/TransactionHistoryControllerTest.java index 2d70f04..2ada93e 100644 --- a/src/test/java/com/joycrew/backend/controller/TransactionHistoryControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/TransactionHistoryControllerTest.java @@ -24,29 +24,21 @@ @WebMvcTest(controllers = TransactionHistoryController.class) class TransactionHistoryControllerTest { - @Autowired - private MockMvc mockMvc; - - @MockBean - private TransactionHistoryService transactionHistoryService; - + @Autowired private MockMvc mockMvc; + @MockBean private TransactionHistoryService transactionHistoryService; @MockBean private JwtUtil jwtUtil; @MockBean private EmployeeDetailsService employeeDetailsService; @Test - @DisplayName("GET /api/transactions - 내 거래 내역 조회 성공") + @DisplayName("GET /api/transactions - Should get my transaction history successfully") @WithMockUserPrincipal(email = "user@joycrew.com") void getMyTransactions_Success() throws Exception { // Given List mockHistory = List.of( TransactionHistoryResponse.builder() - .transactionId(1L) - .type(TransactionType.AWARD_P2P) - .amount(-50) - .counterparty("김동료") - .message("고마워요!") - .transactionDate(LocalDateTime.now()) - .build() + .transactionId(1L).type(TransactionType.AWARD_P2P).amount(-50) + .counterparty("Colleague Name").message("Thanks!") + .transactionDate(LocalDateTime.now()).build() ); when(transactionHistoryService.getTransactionHistory("user@joycrew.com")).thenReturn(mockHistory); @@ -55,7 +47,6 @@ void getMyTransactions_Success() throws Exception { .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].transactionId").value(1L)) - .andExpect(jsonPath("$[0].amount").value(-50)) - .andExpect(jsonPath("$[0].counterparty").value("김동료")); + .andExpect(jsonPath("$[0].counterparty").value("Colleague Name")); } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/UserControllerTest.java b/src/test/java/com/joycrew/backend/controller/UserControllerTest.java index 750110c..56a2b4b 100644 --- a/src/test/java/com/joycrew/backend/controller/UserControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/UserControllerTest.java @@ -5,7 +5,8 @@ import com.joycrew.backend.dto.UserProfileResponse; import com.joycrew.backend.dto.UserProfileUpdateRequest; import com.joycrew.backend.entity.enums.AdminLevel; -import com.joycrew.backend.exception.GlobalExceptionHandler; +import com.joycrew.backend.security.EmployeeDetailsService; +import com.joycrew.backend.security.JwtUtil; import com.joycrew.backend.security.WithMockUserPrincipal; import com.joycrew.backend.service.EmployeeService; import org.junit.jupiter.api.DisplayName; @@ -13,12 +14,10 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.web.servlet.MockMvc; -import java.time.LocalDate; - import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doNothing; @@ -29,69 +28,68 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(controllers = UserController.class) -@Import(GlobalExceptionHandler.class) class UserControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @MockBean private EmployeeService employeeService; + @MockBean private JwtUtil jwtUtil; + @MockBean private EmployeeDetailsService employeeDetailsService; @Test - @DisplayName("GET /api/user/profile - 프로필 조회 성공") + @DisplayName("GET /api/user/profile - Should get profile successfully") @WithMockUserPrincipal void getProfile_Success() throws Exception { // Given UserProfileResponse mockResponse = new UserProfileResponse( - 1L, "테스트유저", "testuser@joycrew.com", - "https://cdn.joycrew.com/profile/testuser.jpg", - 1500, 100, AdminLevel.EMPLOYEE, "개발팀", "사원", - LocalDate.of(1995, 5, 10), // birthday - "서울시 강남구", // address - LocalDate.of(2023, 1, 1) // hireDate + 1L, "Test User", "testuser@joycrew.com", null, + 1500, 100, AdminLevel.EMPLOYEE, "Engineering", "Staff", + null, null, null ); when(employeeService.getUserProfile("testuser@joycrew.com")).thenReturn(mockResponse); // When & Then mockMvc.perform(get("/api/user/profile")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.name").value("테스트유저")) - .andExpect(jsonPath("$.address").value("서울시 강남구")); + .andExpect(jsonPath("$.name").value("Test User")); } @Test - @DisplayName("POST /api/user/password - 비밀번호 변경 성공") + @DisplayName("POST /api/user/password - Should change password successfully") @WithMockUserPrincipal - void forceChangePassword_Success() throws Exception { + void forcePasswordChange_Success() throws Exception { // Given PasswordChangeRequest request = new PasswordChangeRequest("newPassword123!"); doNothing().when(employeeService).forcePasswordChange(eq("testuser@joycrew.com"), any(PasswordChangeRequest.class)); // When & Then mockMvc.perform(post("/api/user/password") - .with(csrf()) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) + .content(objectMapper.writeValueAsString(request)) + .with(csrf())) .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("비밀번호가 성공적으로 변경되었습니다.")); + .andExpect(jsonPath("$.message").value("Password changed successfully.")); } @Test - @DisplayName("PATCH /api/user/profile - 내 정보 수정 성공") + @DisplayName("PATCH /api/user/profile - Should update profile successfully") @WithMockUserPrincipal void updateMyProfile_Success() throws Exception { // Given - UserProfileUpdateRequest request = new UserProfileUpdateRequest( - "새로운 내 이름", "http://new.image.url", null, null, - LocalDate.of(2000, 1, 1), // birthday - "경기도 성남시" // address - ); + UserProfileUpdateRequest requestDto = new UserProfileUpdateRequest("New Name", null, null, null, null, "New Address"); + MockMultipartFile requestPart = new MockMultipartFile("request", "", "application/json", objectMapper.writeValueAsBytes(requestDto)); + MockMultipartFile imagePart = new MockMultipartFile("profileImage", "image.jpg", MediaType.IMAGE_JPEG_VALUE, "image_bytes".getBytes()); // When & Then - mockMvc.perform(patch("/api/user/profile") - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) + mockMvc.perform(multipart("/api/user/profile") + .file(requestPart) + .file(imagePart) + .with(req -> { + req.setMethod("PATCH"); + return req; + }) + .with(csrf())) .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("내 정보가 성공적으로 수정되었습니다.")); + .andExpect(jsonPath("$.message").value("Your information has been updated successfully.")); } -} +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/WalletControllerTest.java b/src/test/java/com/joycrew/backend/controller/WalletControllerTest.java index 8b232ef..13aa413 100644 --- a/src/test/java/com/joycrew/backend/controller/WalletControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/WalletControllerTest.java @@ -20,18 +20,13 @@ @WebMvcTest(controllers = WalletController.class) class WalletControllerTest { - @Autowired - private MockMvc mockMvc; - - @MockBean - private WalletService walletService; - @MockBean - private JwtUtil jwtUtil; - @MockBean - private EmployeeDetailsService employeeDetailsService; + @Autowired private MockMvc mockMvc; + @MockBean private WalletService walletService; + @MockBean private JwtUtil jwtUtil; + @MockBean private EmployeeDetailsService employeeDetailsService; @Test - @DisplayName("GET /api/wallet/point - 포인트 잔액 조회 성공") + @DisplayName("GET /api/wallet/point - Should get point balance successfully") @WithMockUserPrincipal void getWalletPoint_Success() throws Exception { // Given @@ -45,12 +40,4 @@ void getWalletPoint_Success() throws Exception { .andExpect(jsonPath("$.totalBalance").value(1500)) .andExpect(jsonPath("$.giftableBalance").value(100)); } - - @Test - @DisplayName("GET /api/wallet/point - 인증되지 않은 사용자 접근 시 401 반환") - void getWalletPoint_Failure_Unauthenticated() throws Exception { - // When & Then - mockMvc.perform(get("/api/wallet/point")) - .andExpect(status().isUnauthorized()); - } -} +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/security/WithMockUserPrincipalSecurityContextFactory.java b/src/test/java/com/joycrew/backend/security/WithMockUserPrincipalSecurityContextFactory.java index c0f4fef..db7c89e 100644 --- a/src/test/java/com/joycrew/backend/security/WithMockUserPrincipalSecurityContextFactory.java +++ b/src/test/java/com/joycrew/backend/security/WithMockUserPrincipalSecurityContextFactory.java @@ -12,11 +12,12 @@ public class WithMockUserPrincipalSecurityContextFactory implements WithSecurity public SecurityContext createSecurityContext(WithMockUserPrincipal annotation) { SecurityContext context = SecurityContextHolder.createEmptyContext(); Employee mockEmployee = Employee.builder() - .employeeId(1L) + .employeeId(annotation.id()) .email(annotation.email()) - .employeeName("테스트유저") - .role(AdminLevel.EMPLOYEE) + .employeeName("Test User") + .role(AdminLevel.valueOf(annotation.role())) .passwordHash("mockPassword") + .status("ACTIVE") .build(); UserPrincipal principal = new UserPrincipal(mockEmployee); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( @@ -24,4 +25,4 @@ public SecurityContext createSecurityContext(WithMockUserPrincipal annotation) { context.setAuthentication(authentication); return context; } -} +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/AdminEmployeeServiceTest.java b/src/test/java/com/joycrew/backend/service/AdminEmployeeServiceTest.java deleted file mode 100644 index 92a3441..0000000 --- a/src/test/java/com/joycrew/backend/service/AdminEmployeeServiceTest.java +++ /dev/null @@ -1,196 +0,0 @@ -package com.joycrew.backend.service; - -import com.joycrew.backend.dto.AdminEmployeeUpdateRequest; -import com.joycrew.backend.dto.EmployeeRegistrationRequest; -import com.joycrew.backend.entity.Company; -import com.joycrew.backend.entity.Department; -import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.entity.Wallet; -import com.joycrew.backend.entity.enums.AdminLevel; -import com.joycrew.backend.repository.CompanyRepository; -import com.joycrew.backend.repository.DepartmentRepository; -import com.joycrew.backend.repository.EmployeeRepository; -import com.joycrew.backend.repository.WalletRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.mock.web.MockMultipartFile; -import org.springframework.security.crypto.password.PasswordEncoder; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class AdminEmployeeServiceTest { - - @Mock - private EmployeeRepository employeeRepository; - @Mock - private CompanyRepository companyRepository; - @Mock - private DepartmentRepository departmentRepository; - @Mock - private WalletRepository walletRepository; - @Mock - private PasswordEncoder passwordEncoder; - - @InjectMocks - private AdminEmployeeService adminEmployeeService; - - private EmployeeRegistrationRequest request; - private Company mockCompany; - - @BeforeEach - void setUp() { - request = new EmployeeRegistrationRequest( - "테스트유저", - "test@joycrew.com", - "password123!", - "JoyCrew", - "Engineering", - "Developer", - AdminLevel.EMPLOYEE, - null, null, null // birthday, address, hireDate - ); - - mockCompany = Company.builder().companyId(1L).companyName("JoyCrew").build(); - } - - @Test - @DisplayName("[Service] 단일 직원 등록 성공") - void registerEmployee_Success() { - // Given - Employee savedEmployee = Employee.builder().employeeId(1L).email(request.email()).build(); - - when(employeeRepository.findByEmail(request.email())).thenReturn(Optional.empty()); - when(companyRepository.findByCompanyName(request.companyName())).thenReturn(Optional.of(mockCompany)); - when(departmentRepository.findByCompanyAndName(any(), anyString())).thenReturn(Optional.of(mock(Department.class))); - when(passwordEncoder.encode(request.initialPassword())).thenReturn("encodedPassword"); - when(employeeRepository.save(any(Employee.class))).thenReturn(savedEmployee); - - // When - Employee result = adminEmployeeService.registerEmployee(request); - - // Then - assertThat(result).isEqualTo(savedEmployee); - verify(walletRepository, times(1)).save(any(Wallet.class)); - } - - @Test - @DisplayName("[Service] 단일 직원 등록 실패 - 이메일 중복") - void registerEmployee_Failure_EmailAlreadyExists() { - // Given - when(employeeRepository.findByEmail(request.email())).thenReturn(Optional.of(mock(Employee.class))); - - // When & Then - assertThatThrownBy(() -> adminEmployeeService.registerEmployee(request)) - .isInstanceOf(IllegalStateException.class) - .hasMessage("이미 사용 중인 이메일입니다."); - } - - @Test - @DisplayName("[Service] 단일 직원 등록 실패 - 존재하지 않는 회사") - void registerEmployee_Failure_CompanyNotFound() { - // Given - when(employeeRepository.findByEmail(request.email())).thenReturn(Optional.empty()); - when(companyRepository.findByCompanyName(request.companyName())).thenReturn(Optional.empty()); - - // When & Then - assertThatThrownBy(() -> adminEmployeeService.registerEmployee(request)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("존재하지 않는 회사명입니다."); - } - - @Test - @DisplayName("[Service] CSV 파일로 직원 대량 등록 성공") - void registerEmployeesFromCsv_Success() throws IOException { - // Given - String csvContent = "name,email,initialPassword,companyName,departmentName,position,level,birthday,address,hireDate\n" + - "김조이,joy@joycrew.com,joy123,JoyCrew,Engineering,Developer,EMPLOYEE,1995-01-01,서울,2023-01-01\n" + - "박크루,crew@joycrew.com,crew123,JoyCrew,Product,PO,MANAGER,1990-02-02,경기,2022-02-02"; - MockMultipartFile file = new MockMultipartFile("file", "employees.csv", "text/csv", csvContent.getBytes(StandardCharsets.UTF_8)); - - when(employeeRepository.findByEmail(anyString())).thenReturn(Optional.empty()); - when(companyRepository.findByCompanyName(anyString())).thenReturn(Optional.of(mockCompany)); - when(departmentRepository.findByCompanyAndName(any(), anyString())).thenReturn(Optional.of(mock(Department.class))); - - // When - adminEmployeeService.registerEmployeesFromCsv(file); - - // Then - verify(employeeRepository, times(2)).save(any(Employee.class)); - verify(walletRepository, times(2)).save(any(Wallet.class)); - } - - @Test - @DisplayName("[Service] CSV 파일 대량 등록 시 일부 행 실패해도 계속 진행") - void registerEmployeesFromCsv_PartialFailure() throws IOException { - String csvContent = "name,email,initialPassword,companyName,departmentName,position,level,birthday,address,hireDate\n" + - "김조이,joy@joycrew.com,joy123,JoyCrew,Engineering,Developer,EMPLOYEE,1995-01-01,서울,2023-01-01\n" + - "이실패,fail@joycrew.com,fail123,WrongCompany,None,Intern,EMPLOYEE,1999-01-01,부산,2024-01-01\n" + // 실패할 행 - "박크루,crew@joycrew.com,crew123,JoyCrew,Product,PO,MANAGER,1990-02-02,경기,2022-02-02"; - MockMultipartFile file = new MockMultipartFile("file", "employees.csv", "text/csv", csvContent.getBytes(StandardCharsets.UTF_8)); - - when(employeeRepository.findByEmail("joy@joycrew.com")).thenReturn(Optional.empty()); - when(employeeRepository.findByEmail("crew@joycrew.com")).thenReturn(Optional.empty()); - when(companyRepository.findByCompanyName("JoyCrew")).thenReturn(Optional.of(mockCompany)); - when(departmentRepository.findByCompanyAndName(any(), anyString())).thenReturn(Optional.of(mock(Department.class))); - - when(employeeRepository.findByEmail("fail@joycrew.com")).thenReturn(Optional.empty()); - when(companyRepository.findByCompanyName("WrongCompany")).thenReturn(Optional.empty()); - - // When - adminEmployeeService.registerEmployeesFromCsv(file); - - // Then - verify(employeeRepository, times(2)).save(any(Employee.class)); - verify(walletRepository, times(2)).save(any(Wallet.class)); - } - - @Test - @DisplayName("[Service] 직원 정보 업데이트 성공") - void updateEmployee_Success() { - // Given - Long employeeId = 1L; - AdminEmployeeUpdateRequest updateRequest = new AdminEmployeeUpdateRequest("새이름", null, "새직책", null, "INACTIVE"); - Employee mockEmployee = mock(Employee.class); - - when(employeeRepository.findById(employeeId)).thenReturn(Optional.of(mockEmployee)); - - // When - adminEmployeeService.updateEmployee(employeeId, updateRequest); - - // Then - verify(mockEmployee).updateName("새이름"); - verify(mockEmployee).updatePosition("새직책"); - verify(mockEmployee).updateStatus("INACTIVE"); - verify(employeeRepository).save(mockEmployee); - } - - @Test - @DisplayName("[Service] 직원 삭제(비활성화) 성공") - void deleteEmployee_Success() { - // Given - Long employeeId = 1L; - Employee mockEmployee = mock(Employee.class); - when(employeeRepository.findById(employeeId)).thenReturn(Optional.of(mockEmployee)); - - // When - adminEmployeeService.disableEmployee(employeeId); - - // Then - verify(mockEmployee).updateStatus("DELETED"); - verify(employeeRepository).save(mockEmployee); - } -} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/AdminFeaturesIntegrationTest.java b/src/test/java/com/joycrew/backend/service/AdminFeaturesIntegrationTest.java index 2c08e1d..4331f82 100644 --- a/src/test/java/com/joycrew/backend/service/AdminFeaturesIntegrationTest.java +++ b/src/test/java/com/joycrew/backend/service/AdminFeaturesIntegrationTest.java @@ -26,7 +26,9 @@ @Transactional class AdminFeaturesIntegrationTest { - @Autowired private AdminEmployeeService adminEmployeeService; + @Autowired private EmployeeManagementService managementService; + @Autowired private AdminPointService pointService; + @Autowired private EmployeeRepository employeeRepository; @Autowired private WalletRepository walletRepository; @Autowired private CompanyRepository companyRepository; @@ -37,14 +39,14 @@ class AdminFeaturesIntegrationTest { @BeforeEach void setUp() { - company = companyRepository.save(Company.builder().companyName("통합테스트회사").build()); - admin = createAndSaveEmployee("admin@test.com", "관리자", AdminLevel.SUPER_ADMIN, 0); - employee1 = createAndSaveEmployee("emp1@test.com", "직원1", AdminLevel.EMPLOYEE, 100); - employee2 = createAndSaveEmployee("emp2@test.com", "직원2", AdminLevel.EMPLOYEE, 200); + company = companyRepository.save(Company.builder().companyName("Integration Test Company").build()); + admin = createAndSaveEmployee("admin@test.com", "Admin", AdminLevel.SUPER_ADMIN, 0); + employee1 = createAndSaveEmployee("emp1@test.com", "Employee1", AdminLevel.EMPLOYEE, 100); + employee2 = createAndSaveEmployee("emp2@test.com", "Employee2", AdminLevel.EMPLOYEE, 200); } private Employee createAndSaveEmployee(String email, String name, AdminLevel level, int initialPoints) { - Employee emp = Employee.builder().email(email).employeeName(name).role(level).company(company).passwordHash("...").build(); + Employee emp = Employee.builder().email(email).employeeName(name).role(level).company(company).passwordHash("...").status("ACTIVE").build(); employeeRepository.save(emp); Wallet wallet = new Wallet(emp); if (initialPoints > 0) { @@ -55,34 +57,31 @@ private Employee createAndSaveEmployee(String email, String name, AdminLevel lev } @Test - @DisplayName("[Integration] 관리자가 직원들에게 포인트를 성공적으로 분배") + @DisplayName("[Integration] Admin successfully distributes points to employees") void distributePoints_Success() { // Given AdminPointDistributionRequest request = new AdminPointDistributionRequest( List.of(employee1.getEmployeeId(), employee2.getEmployeeId()), 500, - "보너스 지급", + "Bonus Payout", TransactionType.ADMIN_ADJUSTMENT ); // When - adminEmployeeService.distributePoints(request, admin); + pointService.distributePoints(request, admin); // Then Wallet wallet1 = walletRepository.findByEmployee_EmployeeId(employee1.getEmployeeId()).get(); Wallet wallet2 = walletRepository.findByEmployee_EmployeeId(employee2.getEmployeeId()).get(); assertThat(wallet1.getBalance()).isEqualTo(100 + 500); assertThat(wallet2.getBalance()).isEqualTo(200 + 500); - assertThat(transactionRepository.findAll()).anyMatch(tx -> - tx.getReceiver().equals(employee1) && tx.getPointAmount() == 500 - ); } @Test - @DisplayName("[Integration] 관리자가 직원의 상태를 성공적으로 DELETED로 변경 (소프트 삭제)") - void deleteEmployee_Success() { + @DisplayName("[Integration] Admin successfully deactivates an employee (soft delete)") + void deactivateEmployee_Success() { // When - adminEmployeeService.disableEmployee(employee1.getEmployeeId()); + managementService.deactivateEmployee(employee1.getEmployeeId()); // Then Employee deletedEmployee = employeeRepository.findById(employee1.getEmployeeId()).get(); @@ -90,16 +89,16 @@ void deleteEmployee_Success() { } @Test - @DisplayName("[Integration] 관리자가 직원의 직책을 성공적으로 업데이트") + @DisplayName("[Integration] Admin successfully updates an employee's position") void updateEmployee_Success() { // Given - AdminEmployeeUpdateRequest request = new AdminEmployeeUpdateRequest(null, null, "선임 연구원", null, null); + AdminEmployeeUpdateRequest request = new AdminEmployeeUpdateRequest(null, null, "Senior Researcher", null, null); // When - adminEmployeeService.updateEmployee(employee1.getEmployeeId(), request); + managementService.updateEmployee(employee1.getEmployeeId(), request); // Then Employee updatedEmployee = employeeRepository.findById(employee1.getEmployeeId()).get(); - assertThat(updatedEmployee.getPosition()).isEqualTo("선임 연구원"); + assertThat(updatedEmployee.getPosition()).isEqualTo("Senior Researcher"); } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java index ba96255..9468181 100644 --- a/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java +++ b/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java @@ -1,6 +1,5 @@ package com.joycrew.backend.service; -import com.joycrew.backend.JoyCrewBackendApplication; import com.joycrew.backend.dto.EmployeeRegistrationRequest; import com.joycrew.backend.dto.LoginRequest; import com.joycrew.backend.dto.LoginResponse; @@ -21,47 +20,37 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -@SpringBootTest(classes = JoyCrewBackendApplication.class) +@SpringBootTest @ActiveProfiles("dev") @Transactional class AuthServiceIntegrationTest { - @Autowired - private AuthService authService; - @Autowired - private EmployeeRepository employeeRepository; - @Autowired - private JwtUtil jwtUtil; - @Autowired - private CompanyRepository companyRepository; + @Autowired private AuthService authService; + @Autowired private EmployeeRepository employeeRepository; + @Autowired private JwtUtil jwtUtil; + @Autowired private CompanyRepository companyRepository; + @Autowired private EmployeeRegistrationService registrationService; private String testEmail = "integration@joycrew.com"; private String testPassword = "integrationPass123!"; - private String testName = "통합테스트유저"; + private String testName = "IntegrationTestUser"; private Company defaultCompany; - @Autowired - private AdminEmployeeService adminEmployeeService; @BeforeEach void setUp() { - defaultCompany = companyRepository.save(Company.builder().companyName("테스트컴퍼니").build()); + defaultCompany = companyRepository.save(Company.builder().companyName("Test Company").build()); employeeRepository.findByEmail(testEmail).ifPresent(employeeRepository::delete); EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( - testName, - testEmail, - testPassword, - defaultCompany.getCompanyName(), - null, - "사원", - AdminLevel.EMPLOYEE, - null, null, null // birthday, address, hireDate + testName, testEmail, testPassword, + defaultCompany.getCompanyName(), null, "Staff", + AdminLevel.EMPLOYEE, null, null, null ); - adminEmployeeService.registerEmployee(request); + registrationService.registerEmployee(request); } @Test - @DisplayName("통합 테스트: 로그인 성공 시 JWT 토큰과 사용자 정보 반환") + @DisplayName("[Integration] Login success returns JWT and user info") void login_Integration_Success() { // Given LoginRequest request = new LoginRequest(testEmail, testPassword); @@ -72,18 +61,12 @@ void login_Integration_Success() { // Then assertThat(response).isNotNull(); assertThat(response.accessToken()).isNotBlank(); - assertThat(response.message()).isEqualTo("로그인 성공"); + assertThat(response.message()).isEqualTo("Login successful"); assertThat(response.email()).isEqualTo(testEmail); - assertThat(response.userId()).isEqualTo(employeeRepository.findByEmail(testEmail).get().getEmployeeId()); - assertThat(response.name()).isEqualTo(testName); - assertThat(response.role()).isEqualTo(AdminLevel.EMPLOYEE); - - String extractedEmail = jwtUtil.getEmailFromToken(response.accessToken()); - assertThat(extractedEmail).isEqualTo(testEmail); } @Test - @DisplayName("통합 테스트: 로그인 실패 - 존재하지 않는 이메일") + @DisplayName("[Integration] Login failure - Non-existent email") void login_Integration_Failure_EmailNotFound() { // Given LoginRequest request = new LoginRequest("nonexistent@joycrew.com", "anypassword"); @@ -94,7 +77,7 @@ void login_Integration_Failure_EmailNotFound() { } @Test - @DisplayName("통합 테스트: 로그인 실패 - 비밀번호 불일치") + @DisplayName("[Integration] Login failure - Wrong password") void login_Integration_Failure_WrongPassword() { // Given LoginRequest request = new LoginRequest(testEmail, "wrongpassword"); @@ -103,4 +86,4 @@ void login_Integration_Failure_WrongPassword() { assertThatThrownBy(() -> authService.login(request)) .isInstanceOf(BadCredentialsException.class); } -} +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/AuthServiceTest.java b/src/test/java/com/joycrew/backend/service/AuthServiceTest.java index 4118301..3551a1c 100644 --- a/src/test/java/com/joycrew/backend/service/AuthServiceTest.java +++ b/src/test/java/com/joycrew/backend/service/AuthServiceTest.java @@ -5,6 +5,7 @@ import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.Wallet; import com.joycrew.backend.entity.enums.AdminLevel; +import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.WalletRepository; import com.joycrew.backend.security.JwtUtil; import com.joycrew.backend.security.UserPrincipal; @@ -15,55 +16,59 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.quality.Strictness; -import org.mockito.junit.jupiter.MockitoSettings; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) -@MockitoSettings(strictness = Strictness.LENIENT) class AuthServiceTest { - @Mock - private JwtUtil jwtUtil; - @Mock - private AuthenticationManager authenticationManager; - @Mock - private WalletRepository walletRepository; + @Mock private JwtUtil jwtUtil; + @Mock private AuthenticationManager authenticationManager; + @Mock private WalletRepository walletRepository; + @Mock private EmployeeRepository employeeRepository; + @Mock private PasswordEncoder passwordEncoder; + @Mock private EmailService emailService; @InjectMocks private AuthService authService; private Employee testEmployee; private LoginRequest testLoginRequest; - private String testToken = "mocked.jwt.token"; + private final String testToken = "mocked.jwt.token"; @BeforeEach void setUp() { + ReflectionTestUtils.setField(authService, "passwordResetExpirationMs", 900000L); + testEmployee = Employee.builder() .employeeId(1L) .email("test@joycrew.com") .passwordHash("encodedPassword") - .employeeName("테스트유저") + .employeeName("Test User") .role(AdminLevel.EMPLOYEE) .status("ACTIVE") + .profileImageUrl("http://example.com/profile.jpg") .build(); testLoginRequest = new LoginRequest("test@joycrew.com", "password123"); } @Test - @DisplayName("로그인 성공 시 JWT 토큰과 사용자 정보 반환") + @DisplayName("[Unit] Login success should return JWT and user info") void login_Success() { // Given UserPrincipal principal = new UserPrincipal(testEmployee); @@ -73,7 +78,6 @@ void login_Success() { Wallet mockWallet = mock(Wallet.class); when(mockWallet.getBalance()).thenReturn(1000); - when(mockWallet.getGiftablePoint()).thenReturn(500); when(walletRepository.findByEmployee_EmployeeId(anyLong())).thenReturn(Optional.of(mockWallet)); when(jwtUtil.generateToken(anyString())).thenReturn(testToken); @@ -84,20 +88,18 @@ void login_Success() { // Then assertThat(response).isNotNull(); assertThat(response.accessToken()).isEqualTo(testToken); - assertThat(response.message()).isEqualTo("로그인 성공"); + assertThat(response.message()).isEqualTo("Login successful"); // 수정된 부분 assertThat(response.userId()).isEqualTo(testEmployee.getEmployeeId()); assertThat(response.email()).isEqualTo(testEmployee.getEmail()); - assertThat(response.role()).isEqualTo(testEmployee.getRole()); - // assertThat(response.totalPoint()).isEqualTo(1000); // DTO에 totalPoint 필드가 있다면 추가 - // assertThat(response.profileImageUrl()).isEqualTo(testEmployee.getProfileImageUrl()); // DTO에 profileImageUrl 필드가 있다면 추가 + assertThat(response.totalPoint()).isEqualTo(1000); + assertThat(response.profileImageUrl()).isEqualTo(testEmployee.getProfileImageUrl()); - verify(authenticationManager, times(1)).authenticate(any(UsernamePasswordAuthenticationToken.class)); - verify(jwtUtil, times(1)).generateToken(testEmployee.getEmail()); - verify(walletRepository, times(1)).findByEmployee_EmployeeId(testEmployee.getEmployeeId()); + verify(authenticationManager).authenticate(any(UsernamePasswordAuthenticationToken.class)); + verify(jwtUtil).generateToken(testEmployee.getEmail()); } @Test - @DisplayName("로그인 실패 - 자격 증명 오류 (BadCredentialsException)") + @DisplayName("[Unit] Login failure should throw BadCredentialsException") void login_Failure_WrongPassword() { // Given when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) @@ -106,14 +108,10 @@ void login_Failure_WrongPassword() { // When & Then assertThatThrownBy(() -> authService.login(testLoginRequest)) .isInstanceOf(BadCredentialsException.class); - - verify(authenticationManager, times(1)).authenticate(any(UsernamePasswordAuthenticationToken.class)); - // WalletRepository는 호출되지 않음 - verify(walletRepository, never()).findByEmployee_EmployeeId(anyLong()); } @Test - @DisplayName("로그인 실패 - 사용자 없음 (UsernameNotFoundException)") + @DisplayName("[Unit] Login failure should throw UsernameNotFoundException") void login_Failure_UserNotFound() { // Given when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) @@ -122,8 +120,5 @@ void login_Failure_UserNotFound() { // When & Then assertThatThrownBy(() -> authService.login(testLoginRequest)) .isInstanceOf(UsernameNotFoundException.class); - - verify(authenticationManager, times(1)).authenticate(any(UsernamePasswordAuthenticationToken.class)); - verify(walletRepository, never()).findByEmployee_EmployeeId(anyLong()); } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/EmailServiceTest.java b/src/test/java/com/joycrew/backend/service/EmailServiceTest.java index 9f0fd74..1a8112b 100644 --- a/src/test/java/com/joycrew/backend/service/EmailServiceTest.java +++ b/src/test/java/com/joycrew/backend/service/EmailServiceTest.java @@ -1,5 +1,6 @@ package com.joycrew.backend.service; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -9,6 +10,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.times; @@ -23,13 +25,18 @@ class EmailServiceTest { @InjectMocks private EmailService emailService; + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(emailService, "frontendUrlBase", "https://test.joycrew.co.kr"); + } + @Test - @DisplayName("[Service] 비밀번호 재설정 이메일 발송 - MailSender 호출 검증") + @DisplayName("[Unit] Send password reset email - Verify MailSender call") void sendPasswordResetEmail_Success() { // Given String toEmail = "test@joycrew.com"; String token = "test-token"; - String expectedFrontendUrl = "https://joycrew.co.kr/reset-password?token=" + token; + String expectedResetUrl = "https://test.joycrew.co.kr/reset-password?token=" + token; // When emailService.sendPasswordResetEmail(toEmail, token); @@ -40,7 +47,7 @@ void sendPasswordResetEmail_Success() { SimpleMailMessage sentMessage = messageCaptor.getValue(); assertThat(sentMessage.getTo()).contains(toEmail); - assertThat(sentMessage.getSubject()).isEqualTo("[JoyCrew] 비밀번호 재설정 안내"); - assertThat(sentMessage.getText()).contains(expectedFrontendUrl); + assertThat(sentMessage.getSubject()).isEqualTo("[JoyCrew] Password Reset Instructions"); + assertThat(sentMessage.getText()).contains(expectedResetUrl); } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/EmployeeQueryServiceTest.java b/src/test/java/com/joycrew/backend/service/EmployeeQueryServiceTest.java index 28a10f2..fc85248 100644 --- a/src/test/java/com/joycrew/backend/service/EmployeeQueryServiceTest.java +++ b/src/test/java/com/joycrew/backend/service/EmployeeQueryServiceTest.java @@ -1,7 +1,9 @@ package com.joycrew.backend.service; +import com.joycrew.backend.dto.EmployeeQueryResponse; import com.joycrew.backend.dto.PagedEmployeeResponse; import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.service.mapper.EmployeeMapper; import jakarta.persistence.EntityManager; import jakarta.persistence.TypedQuery; import org.junit.jupiter.api.DisplayName; @@ -14,6 +16,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; @@ -23,12 +26,14 @@ class EmployeeQueryServiceTest { @Mock private EntityManager em; + @Mock + private EmployeeMapper employeeMapper; @InjectMocks private EmployeeQueryService employeeQueryService; @Test - @DisplayName("[Service] 직원 목록 조회 - 페이징 정보와 함께 반환") + @DisplayName("[Unit] Get employee list - Should return with paging information") void getEmployees_Success() { // Given String keyword = "test"; @@ -39,23 +44,30 @@ void getEmployees_Success() { TypedQuery countQuery = mock(TypedQuery.class); TypedQuery dataQuery = mock(TypedQuery.class); Employee mockEmployee = Employee.builder().employeeId(2L).employeeName("Test User").build(); + EmployeeQueryResponse mockDto = new EmployeeQueryResponse(2L, null, "Test User", "Test Dept", "Tester"); + // Mocking for the count query when(em.createQuery(anyString(), eq(Long.class))).thenReturn(countQuery); when(countQuery.setParameter(anyString(), any())).thenReturn(countQuery); when(countQuery.getSingleResult()).thenReturn(1L); + // Mocking for the data query when(em.createQuery(anyString(), eq(Employee.class))).thenReturn(dataQuery); when(dataQuery.setParameter(anyString(), any())).thenReturn(dataQuery); when(dataQuery.setFirstResult(anyInt())).thenReturn(dataQuery); when(dataQuery.setMaxResults(anyInt())).thenReturn(dataQuery); when(dataQuery.getResultList()).thenReturn(List.of(mockEmployee)); + // Mocking the mapper's behavior + when(employeeMapper.toEmployeeQueryResponse(any(Employee.class))).thenReturn(mockDto); + // When PagedEmployeeResponse response = employeeQueryService.getEmployees(keyword, page, size, currentUserId); // Then assertThat(response).isNotNull(); assertThat(response.employees()).hasSize(1); + assertThat(response.employees().get(0).employeeName()).isEqualTo("Test User"); assertThat(response.currentPage()).isEqualTo(page); assertThat(response.totalPages()).isEqualTo(1); assertThat(response.isLastPage()).isTrue(); diff --git a/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java index e9acfc7..f05ed8e 100644 --- a/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java +++ b/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java @@ -26,42 +26,35 @@ @Transactional class EmployeeServiceIntegrationTest { - @Autowired - private EmployeeService employeeService; - @Autowired - private EmployeeRepository employeeRepository; - @Autowired - private WalletRepository walletRepository; - @Autowired - private CompanyRepository companyRepository; - @Autowired - private DepartmentRepository departmentRepository; - @Autowired - private PasswordEncoder passwordEncoder; + @Autowired private EmployeeService employeeService; + @Autowired private EmployeeRepository employeeRepository; + @Autowired private WalletRepository walletRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private DepartmentRepository departmentRepository; + @Autowired private PasswordEncoder passwordEncoder; + @Autowired private EmployeeRegistrationService registrationService; private Company testCompany; private Department testDepartment; - @Autowired - private AdminEmployeeService adminEmployeeService; @BeforeEach void setUp() { - testCompany = companyRepository.save(Company.builder().companyName("테스트 회사").build()); - testDepartment = departmentRepository.save(Department.builder().name("테스트 부서").company(testCompany).build()); + testCompany = companyRepository.save(Company.builder().companyName("Test Company").build()); + testDepartment = departmentRepository.save(Department.builder().name("Test Department").company(testCompany).build()); } @Test - @DisplayName("[Integration] 신규 직원 등록 성공") + @DisplayName("[Integration] Register new employee successfully") void registerEmployee_Success() { // Given EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( - "신규직원", "new.employee@joycrew.com", "password123!", - testCompany.getCompanyName(), testDepartment.getName(), "사원", AdminLevel.EMPLOYEE, - LocalDate.of(1998,1,1), "서울", LocalDate.now() // birthday, address, hireDate + "New Employee", "new.employee@joycrew.com", "password123!", + testCompany.getCompanyName(), testDepartment.getName(), "Staff", AdminLevel.EMPLOYEE, + LocalDate.of(1998, 1, 1), "Seoul", LocalDate.now() ); // When - Employee savedEmployee = adminEmployeeService.registerEmployee(request); + Employee savedEmployee = registrationService.registerEmployee(request); // Then assertThat(savedEmployee.getEmployeeId()).isNotNull(); @@ -70,12 +63,12 @@ void registerEmployee_Success() { } @Test - @DisplayName("[Integration] 직원 비밀번호 변경 성공") + @DisplayName("[Integration] Change employee password successfully") void forcePasswordChange_Success() { // Given Employee employee = employeeRepository.save(Employee.builder() .email("pw.change@joycrew.com") - .employeeName("패스워드변경") + .employeeName("Password Changer") .passwordHash(passwordEncoder.encode("oldPassword")) .company(testCompany) .build()); @@ -89,4 +82,4 @@ void forcePasswordChange_Success() { Employee updatedEmployee = employeeRepository.findByEmail(employee.getEmail()).orElseThrow(); assertThat(passwordEncoder.matches("newPassword123!", updatedEmployee.getPasswordHash())).isTrue(); } -} +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java b/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java index 076ffa1..08c3aa9 100644 --- a/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java +++ b/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java @@ -4,9 +4,11 @@ import com.joycrew.backend.dto.UserProfileResponse; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.AdminLevel; import com.joycrew.backend.exception.UserNotFoundException; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.WalletRepository; +import com.joycrew.backend.service.mapper.EmployeeMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -19,64 +21,69 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class EmployeeServiceTest { - @Mock - private EmployeeRepository employeeRepository; - @Mock - private WalletRepository walletRepository; - @Mock - private PasswordEncoder passwordEncoder; + @Mock private EmployeeRepository employeeRepository; + @Mock private WalletRepository walletRepository; + @Mock private PasswordEncoder passwordEncoder; + @Mock private EmployeeMapper employeeMapper; @InjectMocks private EmployeeService employeeService; @Test - @DisplayName("[Service] 프로필 조회 성공 - 지갑 존재") + @DisplayName("[Unit] Get profile success - Wallet exists") void getUserProfile_Success_WalletExists() { // Given String userEmail = "test@joycrew.com"; - Employee mockEmployee = Employee.builder().employeeId(1L).email(userEmail).employeeName("테스트유저").build(); + Employee mockEmployee = Employee.builder().employeeId(1L).email(userEmail).employeeName("Test User").build(); Wallet mockWallet = new Wallet(mockEmployee); mockWallet.addPoints(200); + UserProfileResponse mockDto = new UserProfileResponse(1L, "Test User", userEmail, null, 200, 200, AdminLevel.EMPLOYEE, null, null, null, null, null); + when(employeeRepository.findByEmail(userEmail)).thenReturn(Optional.of(mockEmployee)); when(walletRepository.findByEmployee_EmployeeId(1L)).thenReturn(Optional.of(mockWallet)); + when(employeeMapper.toUserProfileResponse(any(Employee.class), any(Wallet.class))).thenReturn(mockDto); // When UserProfileResponse response = employeeService.getUserProfile(userEmail); // Then assertThat(response).isNotNull(); - assertThat(response.name()).isEqualTo("테스트유저"); // getName() -> name()으로 수정 - assertThat(response.totalBalance()).isEqualTo(200); // getPointBalance() -> totalBalance()로 수정 + assertThat(response.name()).isEqualTo("Test User"); + assertThat(response.totalBalance()).isEqualTo(200); } @Test - @DisplayName("[Service] 프로필 조회 성공 - 지갑 없음 (기본값 0으로 생성)") + @DisplayName("[Unit] Get profile success - Wallet does not exist (defaults to 0)") void getUserProfile_Success_WalletDoesNotExist() { // Given String userEmail = "test@joycrew.com"; - Employee mockEmployee = Employee.builder().employeeId(1L).email(userEmail).employeeName("테스트유저").build(); + Employee mockEmployee = Employee.builder().employeeId(1L).email(userEmail).employeeName("Test User").build(); + UserProfileResponse mockDto = new UserProfileResponse(1L, "Test User", userEmail, null, 0, 0, AdminLevel.EMPLOYEE, null, null, null, null, null); when(employeeRepository.findByEmail(userEmail)).thenReturn(Optional.of(mockEmployee)); when(walletRepository.findByEmployee_EmployeeId(1L)).thenReturn(Optional.empty()); + when(employeeMapper.toUserProfileResponse(any(Employee.class), any(Wallet.class))).thenReturn(mockDto); + // When UserProfileResponse response = employeeService.getUserProfile(userEmail); // Then assertThat(response).isNotNull(); - assertThat(response.name()).isEqualTo("테스트유저"); + assertThat(response.name()).isEqualTo("Test User"); assertThat(response.totalBalance()).isEqualTo(0); } @Test - @DisplayName("[Service] 비밀번호 변경 성공 - Employee의 changePassword 메서드 호출 검증") + @DisplayName("[Unit] Change password success - Verifies changePassword call") void forcePasswordChange_Success() { // Given String userEmail = "test@joycrew.com"; @@ -92,12 +99,11 @@ void forcePasswordChange_Success() { } @Test - @DisplayName("[Service] 비밀번호 변경 실패 - 사용자를 찾을 수 없음") + @DisplayName("[Unit] Change password failure - User not found") void forcePasswordChange_Failure_UserNotFound() { // Given String userEmail = "notfound@joycrew.com"; PasswordChangeRequest request = new PasswordChangeRequest("newPassword123!"); - when(employeeRepository.findByEmail(anyString())).thenReturn(Optional.empty()); // When & Then diff --git a/src/test/java/com/joycrew/backend/service/GiftPointServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/GiftPointServiceIntegrationTest.java new file mode 100644 index 0000000..b911c18 --- /dev/null +++ b/src/test/java/com/joycrew/backend/service/GiftPointServiceIntegrationTest.java @@ -0,0 +1,66 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.EmployeeRegistrationRequest; +import com.joycrew.backend.dto.GiftPointRequest; +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.AdminLevel; +import com.joycrew.backend.event.RecognitionEvent; +import com.joycrew.backend.repository.CompanyRepository; +import com.joycrew.backend.repository.WalletRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.event.ApplicationEvents; +import org.springframework.test.context.event.RecordApplicationEvents; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Transactional +@RecordApplicationEvents +class GiftPointServiceIntegrationTest { + + @Autowired private GiftPointService giftPointService; + @Autowired private EmployeeRegistrationService registrationService; + @Autowired private CompanyRepository companyRepository; + @Autowired private WalletRepository walletRepository; + @Autowired private ApplicationEvents applicationEvents; + + private Long senderId, receiverId; + private Company company; + + @BeforeEach + void setUp() { + company = companyRepository.save(Company.builder().companyName("Test Inc.").build()); + var senderRequest = new EmployeeRegistrationRequest("Sender", "sender@test.com", "password123!", company.getCompanyName(), null, "Dev", AdminLevel.EMPLOYEE, null, null, null); + var receiverRequest = new EmployeeRegistrationRequest("Receiver", "receiver@test.com", "password123!", company.getCompanyName(), null, "Dev", AdminLevel.EMPLOYEE, null, null, null); + senderId = registrationService.registerEmployee(senderRequest).getEmployeeId(); + receiverId = registrationService.registerEmployee(receiverRequest).getEmployeeId(); + + Wallet senderWallet = walletRepository.findByEmployee_EmployeeId(senderId).orElseThrow(); + senderWallet.addPoints(100); + walletRepository.save(senderWallet); + } + + @Test + @DisplayName("[Integration] Gifting points should publish a RecognitionEvent") + void giftPoints_ShouldPublishEvent() { + // Given + var request = new GiftPointRequest(receiverId, 50, "Event Test", List.of()); + + // When + giftPointService.giftPointsToColleague("sender@test.com", request); + + // Then + long eventCount = applicationEvents.stream(RecognitionEvent.class) + .filter(event -> event.getReceiverId().equals(receiverId) && event.getPoints() == 50) + .count(); + assertThat(eventCount).isEqualTo(1); + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/GiftPointServiceTest.java b/src/test/java/com/joycrew/backend/service/GiftPointServiceTest.java new file mode 100644 index 0000000..90b7ea7 --- /dev/null +++ b/src/test/java/com/joycrew/backend/service/GiftPointServiceTest.java @@ -0,0 +1,82 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.GiftPointRequest; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.exception.InsufficientPointsException; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.RewardPointTransactionRepository; +import com.joycrew.backend.repository.WalletRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class GiftPointServiceTest { + + @Mock private EmployeeRepository employeeRepository; + @Mock private WalletRepository walletRepository; + @Mock private RewardPointTransactionRepository transactionRepository; + @Mock private ApplicationEventPublisher eventPublisher; + @InjectMocks private GiftPointService giftPointService; + + private Employee sender, receiver; + private Wallet senderWallet, receiverWallet; + + @BeforeEach + void setUp() { + sender = Employee.builder().employeeId(1L).build(); + receiver = Employee.builder().employeeId(2L).build(); + senderWallet = new Wallet(sender); + receiverWallet = new Wallet(receiver); + } + + @Test + @DisplayName("[Unit] Gift points successfully") + void giftPoints_Success() { + // Given + senderWallet.addPoints(100); + GiftPointRequest request = new GiftPointRequest(2L, 50, "Thanks!", List.of()); + when(employeeRepository.findByEmail("sender@test.com")).thenReturn(Optional.of(sender)); + when(employeeRepository.findById(2L)).thenReturn(Optional.of(receiver)); + when(walletRepository.findByEmployee_EmployeeId(1L)).thenReturn(Optional.of(senderWallet)); + when(walletRepository.findByEmployee_EmployeeId(2L)).thenReturn(Optional.of(receiverWallet)); + + // When + giftPointService.giftPointsToColleague("sender@test.com", request); + + // Then + verify(transactionRepository, times(1)).save(any()); + verify(eventPublisher, times(1)).publishEvent(any()); + assertThat(senderWallet.getBalance()).isEqualTo(50); + assertThat(receiverWallet.getBalance()).isEqualTo(50); + } + + @Test + @DisplayName("[Unit] Gift points failure - Insufficient points") + void giftPoints_Failure_InsufficientPoints() { + // Given + GiftPointRequest request = new GiftPointRequest(2L, 50, "Thanks!", List.of()); + when(employeeRepository.findByEmail("sender@test.com")).thenReturn(Optional.of(sender)); + when(employeeRepository.findById(2L)).thenReturn(Optional.of(receiver)); + when(walletRepository.findByEmployee_EmployeeId(1L)).thenReturn(Optional.of(senderWallet)); + when(walletRepository.findByEmployee_EmployeeId(2L)).thenReturn(Optional.of(receiverWallet)); + + // When & Then + assertThatThrownBy(() -> giftPointService.giftPointsToColleague("sender@test.com", request)) + .isInstanceOf(InsufficientPointsException.class); + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/TransactionHistoryServiceTest.java b/src/test/java/com/joycrew/backend/service/TransactionHistoryServiceTest.java index 8c7b9cc..8ea8bf7 100644 --- a/src/test/java/com/joycrew/backend/service/TransactionHistoryServiceTest.java +++ b/src/test/java/com/joycrew/backend/service/TransactionHistoryServiceTest.java @@ -32,12 +32,12 @@ class TransactionHistoryServiceTest { private TransactionHistoryService transactionHistoryService; @Test - @DisplayName("[Service] 포인트 거래 내역 조회 성공") + @DisplayName("[Unit] Get transaction history successfully") void getTransactionHistory_Success() { // Given String userEmail = "user@joycrew.com"; - Employee user = Employee.builder().employeeId(1L).employeeName("테스트유저").email(userEmail).build(); - Employee colleague = Employee.builder().employeeId(2L).employeeName("동료").email("colleague@joycrew.com").build(); + Employee user = Employee.builder().employeeId(1L).employeeName("Test User").email(userEmail).build(); + Employee colleague = Employee.builder().employeeId(2L).employeeName("Colleague").email("colleague@joycrew.com").build(); RewardPointTransaction sentTx = RewardPointTransaction.builder() .transactionId(101L).sender(user).receiver(colleague) @@ -61,10 +61,10 @@ void getTransactionHistory_Success() { TransactionHistoryResponse sentResponse = history.get(0); assertThat(sentResponse.amount()).isEqualTo(-50); - assertThat(sentResponse.counterparty()).isEqualTo("동료"); + assertThat(sentResponse.counterparty()).isEqualTo("Colleague"); TransactionHistoryResponse receivedResponse = history.get(1); assertThat(receivedResponse.amount()).isEqualTo(100); - assertThat(receivedResponse.counterparty()).isEqualTo("동료"); + assertThat(receivedResponse.counterparty()).isEqualTo("Colleague"); } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/WalletServiceTest.java b/src/test/java/com/joycrew/backend/service/WalletServiceTest.java index a953ce9..a906eff 100644 --- a/src/test/java/com/joycrew/backend/service/WalletServiceTest.java +++ b/src/test/java/com/joycrew/backend/service/WalletServiceTest.java @@ -6,6 +6,7 @@ import com.joycrew.backend.exception.UserNotFoundException; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.WalletRepository; +import com.joycrew.backend.service.mapper.EmployeeMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -17,6 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -26,21 +28,25 @@ class WalletServiceTest { private WalletRepository walletRepository; @Mock private EmployeeRepository employeeRepository; + @Mock + private EmployeeMapper employeeMapper; @InjectMocks private WalletService walletService; @Test - @DisplayName("[Service] 포인트 잔액 조회 성공") + @DisplayName("[Unit] Get point balance successfully") void getPointBalance_Success() { // Given String userEmail = "test@joycrew.com"; Employee mockEmployee = Employee.builder().employeeId(1L).build(); Wallet mockWallet = new Wallet(mockEmployee); mockWallet.addPoints(500); + PointBalanceResponse mockDto = new PointBalanceResponse(500, 500); when(employeeRepository.findByEmail(userEmail)).thenReturn(Optional.of(mockEmployee)); when(walletRepository.findByEmployee_EmployeeId(1L)).thenReturn(Optional.of(mockWallet)); + when(employeeMapper.toPointBalanceResponse(any(Wallet.class))).thenReturn(mockDto); // Mock the mapper's behavior // When PointBalanceResponse response = walletService.getPointBalance(userEmail); @@ -52,7 +58,7 @@ void getPointBalance_Success() { } @Test - @DisplayName("[Service] 포인트 잔액 조회 실패 - 사용자를 찾을 수 없음") + @DisplayName("[Unit] Get point balance failure - User not found") void getPointBalance_Failure_UserNotFound() { // Given String userEmail = "notfound@joycrew.com"; From 3f67d924861f0bcb88b042c63e881fe1f2ad27d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Fri, 8 Aug 2025 16:41:15 +0900 Subject: [PATCH 084/135] Update application-prod.yml --- src/main/resources/application-prod.yml | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 26c591e..ee76831 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -7,25 +7,13 @@ spring: # In production, the application connects to an external MySQL database. # For security, database credentials are read from environment variables. datasource: - url: ${DB_HOST}?zeroDateTimeBehavior=CONVERT_TO_NULL driver-class-name: com.mysql.cj.jdbc.Driver - username: ${DB_USER} - password: ${DB_PASSWORD} # Updates the schema on application start if it differs from the entities. jpa: hibernate: ddl-auto: update show-sql: false - # Production Gmail credentials are read from environment variables. - mail: - username: ${GMAIL_USERNAME} - password: ${GMAIL_PASSWORD} - -# For security, the production JWT secret key MUST be set via an environment variable. -jwt: - secret: ${JWT_SECRET_KEY} - # In production, logs are written to a file for persistence and analysis. logging: level: @@ -43,4 +31,4 @@ app: aws: s3: # Production S3 bucket name - bucket-name: 'joycrew-prod-bucket' \ No newline at end of file + bucket-name: 'joycrew-prod-bucket' From a549e7479feff78a9c41b129a0ebb914f9d0a3fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Fri, 8 Aug 2025 16:51:30 +0900 Subject: [PATCH 085/135] Update bootstrap.yml --- src/main/resources/bootstrap.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/bootstrap.yml b/src/main/resources/bootstrap.yml index 5689c63..68757d5 100644 --- a/src/main/resources/bootstrap.yml +++ b/src/main/resources/bootstrap.yml @@ -8,4 +8,4 @@ spring: config: # Imports secrets from the specified path in AWS Secrets Manager. # The application must have the appropriate IAM role to access this. - import: "aws-secretsmanager:/secret/joycrew/prod" \ No newline at end of file + import: "aws-secretsmanager:/security" From 8018a948f1da124288c15344c026ae20d227757c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Fri, 8 Aug 2025 18:27:39 +0900 Subject: [PATCH 086/135] Update build.gradle --- build.gradle | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index c07c3cb..40514c5 100644 --- a/build.gradle +++ b/build.gradle @@ -51,6 +51,8 @@ dependencies { implementation 'io.awspring.cloud:spring-cloud-aws-starter-secrets-manager' implementation 'software.amazon.awssdk:s3' + implementation 'org.springframework.cloud:spring-cloud-starter-bootstrap' + // Database Drivers runtimeOnly 'com.mysql:mysql-connector-j' // For production runtimeOnly 'com.h2database:h2' // For development @@ -78,4 +80,4 @@ jar { // Configures the test task to use the JUnit Platform. tasks.named('test') { useJUnitPlatform() -} \ No newline at end of file +} From 5781e41886231602920e6c8ecc028f82c2f7f689 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Fri, 8 Aug 2025 19:44:55 +0900 Subject: [PATCH 087/135] Update bootstrap.yml --- src/main/resources/bootstrap.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/bootstrap.yml b/src/main/resources/bootstrap.yml index 68757d5..9f2447b 100644 --- a/src/main/resources/bootstrap.yml +++ b/src/main/resources/bootstrap.yml @@ -8,4 +8,4 @@ spring: config: # Imports secrets from the specified path in AWS Secrets Manager. # The application must have the appropriate IAM role to access this. - import: "aws-secretsmanager:/security" + import: "aws-secretsmanager:security" From 48266b6c2827572483ec7f8fd8064add47ef5dec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Sat, 9 Aug 2025 13:12:59 +0900 Subject: [PATCH 088/135] Update build.gradle --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 40514c5..5f4ccee 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ // Defines the plugins required for the project. plugins { id 'java' - id 'org.springframework.boot' version '3.3.1' + id 'org.springframework.boot' version '3.2.5' id 'io.spring.dependency-management' version '1.1.5' } From 6c5fa81e948cd33611fa90b6eda5ce4f3d0dddf5 Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Sat, 9 Aug 2025 19:03:21 +0900 Subject: [PATCH 089/135] =?UTF-8?q?release=20:=20Kubernetes=20=EB=93=B1=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 44 ++++++ iam-policy.json | 251 ++++++++++++++++++++++++++++++++++ infra/eks/cluster.yml | 82 +++++++++++ infra/iam/joycrew-policy.json | 19 +++ k8s/deployment.yml | 50 +++++++ k8s/ingress.yml | 34 +++++ k8s/service.yml | 17 +++ k8s/serviceaccount.yml | 11 ++ 8 files changed, 508 insertions(+) create mode 100644 Dockerfile create mode 100644 iam-policy.json create mode 100644 infra/eks/cluster.yml create mode 100644 infra/iam/joycrew-policy.json create mode 100644 k8s/deployment.yml create mode 100644 k8s/ingress.yml create mode 100644 k8s/service.yml create mode 100644 k8s/serviceaccount.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..acf975a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +# =================================================================== +# Stage 1: Build Stage +# =================================================================== +FROM amazoncorretto:17-alpine-jdk AS builder + +WORKDIR /workspace + +COPY gradlew . +COPY build.gradle . +COPY settings.gradle . +COPY gradle ./gradle +COPY src ./src + +RUN chmod +x ./gradlew && ./gradlew build -x test + +RUN mv /workspace/build/libs/*[!plain].jar /workspace/build/libs/app.jar + +# =================================================================== +# Stage 2: Final Runtime Stage +# =================================================================== +FROM amazoncorretto:17-alpine + +# Set the working directory for the application +WORKDIR /app + +# Create a dedicated, non-root user and group for enhanced security. +RUN addgroup -S joycrew && adduser -S joycrew -G joycrew +USER joycrew + +# Copy the executable .jar file from the builder stage. +COPY --from=builder /workspace/build/libs/app.jar . + +# Set the active Spring profile to 'prod' +ENV SPRING_PROFILES_ACTIVE=prod + +# Document the port that the container exposes at runtime +EXPOSE 8082 + +# Health check to ensure the application is running and healthy. +HEALTHCHECK --interval=30s --timeout=10s --retries=3 \ + CMD curl -f http://localhost:8082/actuator/health || exit 1 + +# The command to run when the container starts +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/iam-policy.json b/iam-policy.json new file mode 100644 index 0000000..761d0e7 --- /dev/null +++ b/iam-policy.json @@ -0,0 +1,251 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "iam:CreateServiceLinkedRole" + ], + "Resource": "*", + "Condition": { + "StringEquals": { + "iam:AWSServiceName": "elasticloadbalancing.amazonaws.com" + } + } + }, + { + "Effect": "Allow", + "Action": [ + "ec2:DescribeAccountAttributes", + "ec2:DescribeAddresses", + "ec2:DescribeAvailabilityZones", + "ec2:DescribeInternetGateways", + "ec2:DescribeVpcs", + "ec2:DescribeVpcPeeringConnections", + "ec2:DescribeSubnets", + "ec2:DescribeSecurityGroups", + "ec2:DescribeInstances", + "ec2:DescribeNetworkInterfaces", + "ec2:DescribeTags", + "ec2:GetCoipPoolUsage", + "ec2:DescribeCoipPools", + "ec2:GetSecurityGroupsForVpc", + "ec2:DescribeIpamPools", + "ec2:DescribeRouteTables", + "elasticloadbalancing:DescribeLoadBalancers", + "elasticloadbalancing:DescribeLoadBalancerAttributes", + "elasticloadbalancing:DescribeListeners", + "elasticloadbalancing:DescribeListenerCertificates", + "elasticloadbalancing:DescribeSSLPolicies", + "elasticloadbalancing:DescribeRules", + "elasticloadbalancing:DescribeTargetGroups", + "elasticloadbalancing:DescribeTargetGroupAttributes", + "elasticloadbalancing:DescribeTargetHealth", + "elasticloadbalancing:DescribeTags", + "elasticloadbalancing:DescribeTrustStores", + "elasticloadbalancing:DescribeListenerAttributes", + "elasticloadbalancing:DescribeCapacityReservation" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "cognito-idp:DescribeUserPoolClient", + "acm:ListCertificates", + "acm:DescribeCertificate", + "iam:ListServerCertificates", + "iam:GetServerCertificate", + "waf-regional:GetWebACL", + "waf-regional:GetWebACLForResource", + "waf-regional:AssociateWebACL", + "waf-regional:DisassociateWebACL", + "wafv2:GetWebACL", + "wafv2:GetWebACLForResource", + "wafv2:AssociateWebACL", + "wafv2:DisassociateWebACL", + "shield:GetSubscriptionState", + "shield:DescribeProtection", + "shield:CreateProtection", + "shield:DeleteProtection" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "ec2:AuthorizeSecurityGroupIngress", + "ec2:RevokeSecurityGroupIngress" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "ec2:CreateSecurityGroup" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "ec2:CreateTags" + ], + "Resource": "arn:aws:ec2:*:*:security-group/*", + "Condition": { + "StringEquals": { + "ec2:CreateAction": "CreateSecurityGroup" + }, + "Null": { + "aws:RequestTag/elbv2.k8s.aws/cluster": "false" + } + } + }, + { + "Effect": "Allow", + "Action": [ + "ec2:CreateTags", + "ec2:DeleteTags" + ], + "Resource": "arn:aws:ec2:*:*:security-group/*", + "Condition": { + "Null": { + "aws:RequestTag/elbv2.k8s.aws/cluster": "true", + "aws:ResourceTag/elbv2.k8s.aws/cluster": "false" + } + } + }, + { + "Effect": "Allow", + "Action": [ + "ec2:AuthorizeSecurityGroupIngress", + "ec2:RevokeSecurityGroupIngress", + "ec2:DeleteSecurityGroup" + ], + "Resource": "*", + "Condition": { + "Null": { + "aws:ResourceTag/elbv2.k8s.aws/cluster": "false" + } + } + }, + { + "Effect": "Allow", + "Action": [ + "elasticloadbalancing:CreateLoadBalancer", + "elasticloadbalancing:CreateTargetGroup" + ], + "Resource": "*", + "Condition": { + "Null": { + "aws:RequestTag/elbv2.k8s.aws/cluster": "false" + } + } + }, + { + "Effect": "Allow", + "Action": [ + "elasticloadbalancing:CreateListener", + "elasticloadbalancing:DeleteListener", + "elasticloadbalancing:CreateRule", + "elasticloadbalancing:DeleteRule" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "elasticloadbalancing:AddTags", + "elasticloadbalancing:RemoveTags" + ], + "Resource": [ + "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*", + "arn:aws:elasticloadbalancing:*:*:loadbalancer/net/*/*", + "arn:aws:elasticloadbalancing:*:*:loadbalancer/app/*/*" + ], + "Condition": { + "Null": { + "aws:RequestTag/elbv2.k8s.aws/cluster": "true", + "aws:ResourceTag/elbv2.k8s.aws/cluster": "false" + } + } + }, + { + "Effect": "Allow", + "Action": [ + "elasticloadbalancing:AddTags", + "elasticloadbalancing:RemoveTags" + ], + "Resource": [ + "arn:aws:elasticloadbalancing:*:*:listener/net/*/*/*", + "arn:aws:elasticloadbalancing:*:*:listener/app/*/*/*", + "arn:aws:elasticloadbalancing:*:*:listener-rule/net/*/*/*", + "arn:aws:elasticloadbalancing:*:*:listener-rule/app/*/*/*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "elasticloadbalancing:ModifyLoadBalancerAttributes", + "elasticloadbalancing:SetIpAddressType", + "elasticloadbalancing:SetSecurityGroups", + "elasticloadbalancing:SetSubnets", + "elasticloadbalancing:DeleteLoadBalancer", + "elasticloadbalancing:ModifyTargetGroup", + "elasticloadbalancing:ModifyTargetGroupAttributes", + "elasticloadbalancing:DeleteTargetGroup", + "elasticloadbalancing:ModifyListenerAttributes", + "elasticloadbalancing:ModifyCapacityReservation", + "elasticloadbalancing:ModifyIpPools" + ], + "Resource": "*", + "Condition": { + "Null": { + "aws:ResourceTag/elbv2.k8s.aws/cluster": "false" + } + } + }, + { + "Effect": "Allow", + "Action": [ + "elasticloadbalancing:AddTags" + ], + "Resource": [ + "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*", + "arn:aws:elasticloadbalancing:*:*:loadbalancer/net/*/*", + "arn:aws:elasticloadbalancing:*:*:loadbalancer/app/*/*" + ], + "Condition": { + "StringEquals": { + "elasticloadbalancing:CreateAction": [ + "CreateTargetGroup", + "CreateLoadBalancer" + ] + }, + "Null": { + "aws:RequestTag/elbv2.k8s.aws/cluster": "false" + } + } + }, + { + "Effect": "Allow", + "Action": [ + "elasticloadbalancing:RegisterTargets", + "elasticloadbalancing:DeregisterTargets" + ], + "Resource": "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*" + }, + { + "Effect": "Allow", + "Action": [ + "elasticloadbalancing:SetWebAcl", + "elasticloadbalancing:ModifyListener", + "elasticloadbalancing:AddListenerCertificates", + "elasticloadbalancing:RemoveListenerCertificates", + "elasticloadbalancing:ModifyRule", + "elasticloadbalancing:SetRulePriorities" + ], + "Resource": "*" + } + ] +} diff --git a/infra/eks/cluster.yml b/infra/eks/cluster.yml new file mode 100644 index 0000000..25085fc --- /dev/null +++ b/infra/eks/cluster.yml @@ -0,0 +1,82 @@ + # =================================================================== + # EKS Cluster Configuration for JoyCrew Backend + # =================================================================== + apiVersion: eksctl.io/v1alpha5 + kind: ClusterConfig + + metadata: + # The name of the EKS cluster + name: joycrew-cluster + # The AWS region where the cluster will be created + region: ap-northeast-2 + # The desired Kubernetes version (consider using 1.29 or 1.30 for latest features) + version: "1.29" + + # Enable IAM Roles for Service Accounts (IRSA) for secure pod-to-AWS service authentication + iam: + withOIDC: true + + # VPC configuration (optional: eksctl will create a new VPC if not specified) + vpc: + # CIDR block for the VPC + cidr: 10.0.0.0/16 + + # Configuration for managed node groups + managedNodeGroups: + - name: app-workers + # Instance type - consider t3.large for better performance in production + instanceType: t3.medium + # Node group scaling configuration + desiredCapacity: 2 + minSize: 1 + maxSize: 4 + # Use private subnets for better security + privateNetworking: true + + # SSH access configuration + ssh: + allow: true + publicKeyName: joycrew-deploy-key + + # Node labels for workload scheduling + labels: + role: application + environment: production + + # Volume configuration + volumeSize: 20 + volumeType: gp3 + + # IAM policies for worker nodes + iam: + attachPolicyARNs: + - arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy + - arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy + - arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly + # Add CloudWatch policy if you want container insights + - arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy + + # Manage essential EKS add-ons + addons: + - name: vpc-cni + version: latest + configurationValues: |- + enableNetworkPolicy: "true" + - name: coredns + version: latest + - name: kube-proxy + version: latest + - name: aws-ebs-csi-driver + version: latest + wellKnownPolicies: + ebsCSIController: true + + # CloudWatch logging configuration + cloudWatch: + clusterLogging: + enableTypes: + - api + - audit + - authenticator + - controllerManager + - scheduler \ No newline at end of file diff --git a/infra/iam/joycrew-policy.json b/infra/iam/joycrew-policy.json new file mode 100644 index 0000000..748add5 --- /dev/null +++ b/infra/iam/joycrew-policy.json @@ -0,0 +1,19 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject" + ], + "Resource": "arn:aws:s3:::joycrew-prod-bucket/*" + }, + { + "Effect": "Allow", + "Action": "secretsmanager:GetSecretValue", + "Resource": "arn:aws:secretsmanager:ap-northeast-2:566105751077:secret:security-OxJtZr" + } + ] +} \ No newline at end of file diff --git a/k8s/deployment.yml b/k8s/deployment.yml new file mode 100644 index 0000000..f964024 --- /dev/null +++ b/k8s/deployment.yml @@ -0,0 +1,50 @@ +# Defines the desired state for the application pods. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: joycrew-backend-deployment + labels: + app: joycrew-backend +spec: + # Runs 2 replicas of the pod for high availability. + replicas: 2 + selector: + matchLabels: + app: joycrew-backend + template: + metadata: + labels: + app: joycrew-backend + spec: + # Use a dedicated service account for the application pods. + # This is crucial for securely granting AWS permissions via IRSA. + serviceAccountName: joycrew-service-account + containers: + - name: backend-container + # ❗️ IMPORTANT: Use the correct URI of your Docker image pushed to ECR. + image: "566105751077.dkr.ecr.ap-northeast-2.amazonaws.com/joycrew-backend:latest" + ports: + - containerPort: 8082 + # Define resource requests and limits for better scheduling and stability. + resources: + requests: + memory: "512Mi" + cpu: "250m" + limits: + memory: "1Gi" + cpu: "500m" + # Readiness probe checks if the container is ready to accept traffic. + readinessProbe: + httpGet: + path: /actuator/health + port: 8082 + initialDelaySeconds: 30 + periodSeconds: 10 + # Liveness probe checks if the container is still running correctly. + # If it fails, Kubernetes will restart the container. + livenessProbe: + httpGet: + path: /actuator/health + port: 8082 + initialDelaySeconds: 60 + periodSeconds: 20 \ No newline at end of file diff --git a/k8s/ingress.yml b/k8s/ingress.yml new file mode 100644 index 0000000..dfbf582 --- /dev/null +++ b/k8s/ingress.yml @@ -0,0 +1,34 @@ +# /k8s/ingress.yml +# Defines how external traffic reaches the service inside the cluster. +# This configuration uses the AWS Load Balancer Controller to provision an ALB. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: joycrew-backend-ingress + annotations: + # Creates an internet-facing load balancer. + alb.ingress.kubernetes.io/scheme: internet-facing + # Routes traffic directly to Pod IPs for better performance. + alb.ingress.kubernetes.io/target-type: ip + # Specifies the ARN of the ACM certificate for HTTPS. + alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:ap-northeast-2:566105751077:certificate/d5930928-10dc-4f4d-9cc4-67770a94522b + # Configures the ALB to listen on port 443 (HTTPS). + alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]' + # Redirects HTTP traffic to HTTPS. + alb.ingress.kubernetes.io/ssl-redirect: '443' +spec: + # Specifies that the 'alb' ingress controller should handle this Ingress. (Modern way) + ingressClassName: alb + rules: + - host: api.joycrew.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + # Forwards traffic to the 'joycrew-backend-service'. + name: joycrew-backend-service + port: + # Forwards to the service's port 80. + number: 80 \ No newline at end of file diff --git a/k8s/service.yml b/k8s/service.yml new file mode 100644 index 0000000..b5204c7 --- /dev/null +++ b/k8s/service.yml @@ -0,0 +1,17 @@ +# Creates a stable internal endpoint for the backend pods. +apiVersion: v1 +kind: Service +metadata: + name: joycrew-backend-service +spec: + # Selects pods with the label 'app: joycrew-backend'. + selector: + app: joycrew-backend + ports: + - protocol: TCP + # The service listens on port 80. + port: 80 + # It forwards traffic to the container's port 8082. + targetPort: 8082 + # Creates an internal IP address, making the service reachable only from within the cluster. + type: ClusterIP \ No newline at end of file diff --git a/k8s/serviceaccount.yml b/k8s/serviceaccount.yml new file mode 100644 index 0000000..138a154 --- /dev/null +++ b/k8s/serviceaccount.yml @@ -0,0 +1,11 @@ +# Defines a dedicated identity for the application pods within the cluster. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: joycrew-service-account + # Annotations for IAM Roles for Service Accounts (IRSA). + # This links the Kubernetes service account to an AWS IAM role. + # ❗️ IMPORTANT: Replace 'YOUR_IAM_ROLE_ARN' with the ARN of the IAM role + # that has permissions for S3 and Secrets Manager. + annotations: + eks.amazonaws.com/role-arn: "YOUR_IAM_ROLE_ARN" \ No newline at end of file From 9c76cbc93130288ac29045c47bd8b296964c1cfb Mon Sep 17 00:00:00 2001 From: yeoEun Date: Mon, 11 Aug 2025 18:11:15 +0900 Subject: [PATCH 090/135] feat: get-products --- .../backend/config/SecurityConfig.java | 2 + .../backend/controller/ProductController.java | 166 ++++++++++++++++++ .../backend/dto/PagedProductResponse.java | 41 +++++ .../joycrew/backend/dto/ProductResponse.java | 43 +++++ .../com/joycrew/backend/entity/Product.java | 56 ++++++ .../backend/entity/enums/Category.java | 18 ++ .../backend/repository/ProductRepository.java | 11 ++ .../backend/service/ProductService.java | 41 +++++ src/main/resources/bootstrap.yml | 11 -- 9 files changed, 378 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/joycrew/backend/controller/ProductController.java create mode 100644 src/main/java/com/joycrew/backend/dto/PagedProductResponse.java create mode 100644 src/main/java/com/joycrew/backend/dto/ProductResponse.java create mode 100644 src/main/java/com/joycrew/backend/entity/Product.java create mode 100644 src/main/java/com/joycrew/backend/entity/enums/Category.java create mode 100644 src/main/java/com/joycrew/backend/repository/ProductRepository.java create mode 100644 src/main/java/com/joycrew/backend/service/ProductService.java delete mode 100644 src/main/resources/bootstrap.yml diff --git a/src/main/java/com/joycrew/backend/config/SecurityConfig.java b/src/main/java/com/joycrew/backend/config/SecurityConfig.java index 2f53871..7b07943 100644 --- a/src/main/java/com/joycrew/backend/config/SecurityConfig.java +++ b/src/main/java/com/joycrew/backend/config/SecurityConfig.java @@ -1,5 +1,6 @@ package com.joycrew.backend.config; +import org.springframework.http.HttpMethod; import com.fasterxml.jackson.databind.ObjectMapper; import com.joycrew.backend.dto.ErrorResponse; import com.joycrew.backend.entity.enums.AdminLevel; @@ -52,6 +53,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/swagger-ui/**", "/swagger-ui.html" ).permitAll() + .requestMatchers(HttpMethod.GET, "/api/crawl/**").permitAll() .requestMatchers("/api/admin/**").hasRole(AdminLevel.SUPER_ADMIN.name()) .anyRequest().authenticated() ) diff --git a/src/main/java/com/joycrew/backend/controller/ProductController.java b/src/main/java/com/joycrew/backend/controller/ProductController.java new file mode 100644 index 0000000..5dca291 --- /dev/null +++ b/src/main/java/com/joycrew/backend/controller/ProductController.java @@ -0,0 +1,166 @@ +package com.joycrew.backend.controller; + +import com.joycrew.backend.dto.PagedProductResponse; +import com.joycrew.backend.dto.ProductResponse; +import com.joycrew.backend.entity.enums.Category; +import com.joycrew.backend.service.ProductService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Products", description = "APIs for querying products") +@RestController +@RequestMapping("/api/products") +@RequiredArgsConstructor +public class ProductController { + + private final ProductService productService; + + @Operation( + summary = "Get all products (paged)", + description = "Fetches all products with pagination.", + parameters = { + @Parameter(name = "page", description = "Page number (0-based)", example = "0"), + @Parameter(name = "size", description = "Items per page", example = "20") + }, + responses = { + @ApiResponse( + responseCode = "200", + description = "Successfully retrieved products.", + content = @Content( + schema = @Schema(implementation = PagedProductResponse.class), + examples = @ExampleObject( + value = """ + { + "content": [ + { + "id": 1, + "keyword": "BEAUTY", + "rankOrder": 1, + "name": "Smartphone", + "thumbnailUrl": "https://example.com/image.jpg", + "price": 499, + "detailUrl": "https://example.com/product/1", + "itemId": "12345", + "registeredAt": "2025-08-11T10:00:00" + } + ], + "page": 0, + "size": 20, + "totalElements": 123, + "totalPages": 7, + "hasNext": true, + "hasPrevious": false + } + """ + ) + ) + ) + } + ) + @GetMapping + public ResponseEntity getAllProducts( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return ResponseEntity.ok(productService.getAllProducts(page, size)); + } + + @Operation( + summary = "Get product by ID", + description = "Fetches a single product by its ID.", + responses = { + @ApiResponse( + responseCode = "200", + description = "Successfully retrieved the product.", + content = @Content( + schema = @Schema(implementation = ProductResponse.class), + examples = @ExampleObject( + value = """ + { + "id": 1, + "keyword": "BEAUTY", + "rankOrder": 1, + "name": "Smartphone", + "thumbnailUrl": "https://example.com/image.jpg", + "price": 499, + "detailUrl": "https://example.com/product/1", + "itemId": "12345", + "registeredAt": "2025-08-11T10:00:00" + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "Product not found") + } + ) + @GetMapping("/{id}") + public ResponseEntity getProductById( + @Parameter(description = "Product ID", example = "1") + @PathVariable Long id + ) { + ProductResponse product = productService.getProductById(id); + return (product != null) ? ResponseEntity.ok(product) : ResponseEntity.notFound().build(); + } + + @Operation( + summary = "Get products by category (paged)", + description = "Fetches products by category (keyword) with pagination.", + parameters = { + @Parameter(name = "category", description = "Product category (keyword)", example = "BEAUTY"), + @Parameter(name = "page", description = "Page number (0-based)", example = "0"), + @Parameter(name = "size", description = "Items per page", example = "20") + }, + responses = { + @ApiResponse( + responseCode = "200", + description = "Successfully retrieved products by category.", + content = @Content( + schema = @Schema(implementation = PagedProductResponse.class), + examples = @ExampleObject( + value = """ + { + "content": [ + { + "id": 1, + "keyword": "BEAUTY", + "rankOrder": 1, + "name": "Smartphone", + "thumbnailUrl": "https://example.com/image.jpg", + "price": 499, + "detailUrl": "https://example.com/product/1", + "itemId": "12345", + "registeredAt": "2025-08-11T10:00:00" + } + ], + "page": 0, + "size": 20, + "totalElements": 45, + "totalPages": 3, + "hasNext": true, + "hasPrevious": false + } + """ + ) + ) + ), + @ApiResponse(responseCode = "400", description = "Invalid category") + } + ) + @GetMapping("/category/{category}") + public ResponseEntity getProductsByCategory( + @PathVariable Category category, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return ResponseEntity.ok(productService.getProductsByCategory(category, page, size)); + } +} diff --git a/src/main/java/com/joycrew/backend/dto/PagedProductResponse.java b/src/main/java/com/joycrew/backend/dto/PagedProductResponse.java new file mode 100644 index 0000000..ba98963 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/PagedProductResponse.java @@ -0,0 +1,41 @@ +package com.joycrew.backend.dto; + +import com.joycrew.backend.entity.Product; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "Paged product response") +public record PagedProductResponse( + @ArraySchema(arraySchema = @Schema(description = "Content list"), + schema = @Schema(implementation = ProductResponse.class)) + List content, + @Schema(description = "Current page (0-based)", example = "0") + int page, + @Schema(description = "Page size", example = "20") + int size, + @Schema(description = "Total elements", example = "123") + long totalElements, + @Schema(description = "Total pages", example = "7") + int totalPages, + @Schema(description = "Has next page", example = "true") + boolean hasNext, + @Schema(description = "Has previous page", example = "false") + boolean hasPrevious +) { + public static PagedProductResponse from(org.springframework.data.domain.Page pageData) { + List mapped = pageData.getContent().stream() + .map(ProductResponse::from) + .toList(); + return new PagedProductResponse( + mapped, + pageData.getNumber(), + pageData.getSize(), + pageData.getTotalElements(), + pageData.getTotalPages(), + pageData.hasNext(), + pageData.hasPrevious() + ); + } +} diff --git a/src/main/java/com/joycrew/backend/dto/ProductResponse.java b/src/main/java/com/joycrew/backend/dto/ProductResponse.java new file mode 100644 index 0000000..7b0fa08 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/ProductResponse.java @@ -0,0 +1,43 @@ +package com.joycrew.backend.dto; + +import com.joycrew.backend.entity.Product; +import com.joycrew.backend.entity.enums.Category; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; + +@Schema(description = "Product summary DTO") +public record ProductResponse( + @Schema(description = "Product ID", example = "1") + Long id, + @Schema(description = "Category (keyword)", example = "BEAUTY") + Category keyword, + @Schema(description = "Rank order", example = "1") + Integer rankOrder, + @Schema(description = "Product name", example = "Smartphone") + String name, + @Schema(description = "Thumbnail URL", example = "https://example.com/image.jpg") + String thumbnailUrl, + @Schema(description = "Price", example = "499") + Integer price, + @Schema(description = "Detail URL", example = "https://example.com/product/1") + String detailUrl, + @Schema(description = "Item ID", example = "12345") + String itemId, + @Schema(description = "Registered time", example = "2025-08-11T10:00:00") + LocalDateTime registeredAt +) { + public static ProductResponse from(Product p) { + return new ProductResponse( + p.getId(), + p.getKeyword(), + p.getRankOrder(), + p.getName(), + p.getThumbnailUrl(), + p.getPrice(), + p.getDetailUrl(), + p.getItemId(), + p.getRegisteredAt() + ); + } +} diff --git a/src/main/java/com/joycrew/backend/entity/Product.java b/src/main/java/com/joycrew/backend/entity/Product.java new file mode 100644 index 0000000..4cc2628 --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/Product.java @@ -0,0 +1,56 @@ +package com.joycrew.backend.entity; + +import com.joycrew.backend.entity.enums.Category; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "product") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Product { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Schema(description = "상품의 고유 ID", example = "1") + private Long id; + + @Column(nullable = false, length = 50) + @Enumerated(EnumType.STRING) + @Schema(description = "상품의 카테고리", example = "BEAUTY") + private Category keyword; + + @Column(nullable = false) + @Schema(description = "상품 순위", example = "1") + private Integer rankOrder; + + @Column(nullable = false, length = 1000) + @Schema(description = "상품명", example = "Smartphone") + private String name; + + @Column(nullable = true, length = 2000) + @Schema(description = "상품 썸네일 URL", example = "https://example.com/image.jpg") + private String thumbnailUrl; + + @Column(nullable = false) + @Schema(description = "상품 가격", example = "499") + private Integer price; + + @Column(nullable = false, length = 2000) + @Schema(description = "상품 상세 URL", example = "https://example.com/product/1") + private String detailUrl; + + @Column(nullable = false, length = 64) + @Schema(description = "상품 고유 아이템 ID", example = "12345") + private String itemId; + + @Column(nullable = false) + @Schema(description = "상품 등록 시간", example = "2025-08-11T10:00:00") + private LocalDateTime registeredAt; +} diff --git a/src/main/java/com/joycrew/backend/entity/enums/Category.java b/src/main/java/com/joycrew/backend/entity/enums/Category.java new file mode 100644 index 0000000..0d78a5d --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/enums/Category.java @@ -0,0 +1,18 @@ +package com.joycrew.backend.entity.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Category { + BEAUTY("뷰티"), + HOUSEHOLD("생활용품"), + HOME_INTERIOR("홈인테리어"), + STATIONERY_OFFICE("문구/오피스"), + WOMEN_CLOTHING("여성복"), + MEN_CLOTHING("남성복"), + HEALTH_SUPPLEMENTS("헬스/건강식품"); + + private final String kr; // 실제 쿠팡 검색에 사용할 한글 키워드 +} diff --git a/src/main/java/com/joycrew/backend/repository/ProductRepository.java b/src/main/java/com/joycrew/backend/repository/ProductRepository.java new file mode 100644 index 0000000..889602a --- /dev/null +++ b/src/main/java/com/joycrew/backend/repository/ProductRepository.java @@ -0,0 +1,11 @@ +package com.joycrew.backend.repository; + +import com.joycrew.backend.entity.Product; +import com.joycrew.backend.entity.enums.Category; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductRepository extends JpaRepository { + Page findByKeyword(Category keyword, Pageable pageable); +} diff --git a/src/main/java/com/joycrew/backend/service/ProductService.java b/src/main/java/com/joycrew/backend/service/ProductService.java new file mode 100644 index 0000000..b2bb505 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/ProductService.java @@ -0,0 +1,41 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.PagedProductResponse; +import com.joycrew.backend.dto.ProductResponse; +import com.joycrew.backend.entity.Product; +import com.joycrew.backend.entity.enums.Category; +import com.joycrew.backend.repository.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class ProductService { + + private final ProductRepository productRepository; + + // Get all products (paged) + public PagedProductResponse getAllProducts(int page, int size) { + PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id")); + Page result = productRepository.findAll(pageable); + return PagedProductResponse.from(result); + } + + // Get product by ID (single) + public ProductResponse getProductById(Long id) { + Optional product = productRepository.findById(id); + return product.map(ProductResponse::from).orElse(null); + } + + // Get products by category (paged) + public PagedProductResponse getProductsByCategory(Category category, int page, int size) { + PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "rankOrder").and(Sort.by(Sort.Direction.DESC, "id"))); + Page result = productRepository.findByKeyword(category, pageable); + return PagedProductResponse.from(result); + } +} diff --git a/src/main/resources/bootstrap.yml b/src/main/resources/bootstrap.yml deleted file mode 100644 index 9f2447b..0000000 --- a/src/main/resources/bootstrap.yml +++ /dev/null @@ -1,11 +0,0 @@ -# =================================================================== -# SPRING BOOTSTRAP CONFIGURATION -# -# This file is loaded before application.yml and is used to -# import configuration from external sources like AWS Secrets Manager. -# =================================================================== -spring: - config: - # Imports secrets from the specified path in AWS Secrets Manager. - # The application must have the appropriate IAM role to access this. - import: "aws-secretsmanager:security" From 24c959101d99859abe7e42f974f65e322d64c323 Mon Sep 17 00:00:00 2001 From: yeoEun Date: Mon, 11 Aug 2025 20:22:07 +0900 Subject: [PATCH 091/135] feat: order-place/cancel --- .../backend/config/SecurityConfig.java | 16 +- .../backend/controller/OrderController.java | 156 ++++++++++++++++++ .../backend/controller/ProductController.java | 25 ++- .../backend/dto/CreateOrderRequest.java | 11 ++ .../joycrew/backend/dto/ErrorResponse.java | 17 +- .../joycrew/backend/dto/OrderResponse.java | 53 ++++++ .../backend/dto/PagedOrderResponse.java | 23 +++ .../com/joycrew/backend/entity/Order.java | 63 +++++++ .../com/joycrew/backend/entity/Wallet.java | 22 ++- .../backend/entity/enums/OrderStatus.java | 8 + .../exception/GlobalExceptionHandler.java | 57 ++----- .../backend/repository/OrderRepository.java | 15 ++ .../backend/repository/WalletRepository.java | 2 +- .../joycrew/backend/service/OrderService.java | 103 ++++++++++++ 14 files changed, 516 insertions(+), 55 deletions(-) create mode 100644 src/main/java/com/joycrew/backend/controller/OrderController.java create mode 100644 src/main/java/com/joycrew/backend/dto/CreateOrderRequest.java create mode 100644 src/main/java/com/joycrew/backend/dto/OrderResponse.java create mode 100644 src/main/java/com/joycrew/backend/dto/PagedOrderResponse.java create mode 100644 src/main/java/com/joycrew/backend/entity/Order.java create mode 100644 src/main/java/com/joycrew/backend/entity/enums/OrderStatus.java create mode 100644 src/main/java/com/joycrew/backend/repository/OrderRepository.java create mode 100644 src/main/java/com/joycrew/backend/service/OrderService.java diff --git a/src/main/java/com/joycrew/backend/config/SecurityConfig.java b/src/main/java/com/joycrew/backend/config/SecurityConfig.java index 7b07943..d642745 100644 --- a/src/main/java/com/joycrew/backend/config/SecurityConfig.java +++ b/src/main/java/com/joycrew/backend/config/SecurityConfig.java @@ -25,6 +25,8 @@ import org.springframework.security.authentication.ProviderManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import java.time.LocalDateTime; + @Configuration @EnableWebSecurity @RequiredArgsConstructor @@ -62,7 +64,12 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType("application/json;charset=UTF-8"); String jsonResponse = objectMapper.writeValueAsString( - new ErrorResponse("UNAUTHENTICATED", "Authentication is required. Please log in.") + new ErrorResponse( + "UNAUTHENTICATED", + "Authentication is required. Please log in.", + LocalDateTime.now(), + request.getRequestURI() + ) ); response.getWriter().write(jsonResponse); }) @@ -70,7 +77,12 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti response.setStatus(HttpStatus.FORBIDDEN.value()); response.setContentType("application/json;charset=UTF-8"); String jsonResponse = objectMapper.writeValueAsString( - new ErrorResponse("ACCESS_DENIED", "You do not have permission to access this resource.") + new ErrorResponse( + "ACCESS_DENIED", + "You do not have permission to access this resource.", + LocalDateTime.now(), + request.getRequestURI() + ) ); response.getWriter().write(jsonResponse); }) diff --git a/src/main/java/com/joycrew/backend/controller/OrderController.java b/src/main/java/com/joycrew/backend/controller/OrderController.java new file mode 100644 index 0000000..8dcc8b3 --- /dev/null +++ b/src/main/java/com/joycrew/backend/controller/OrderController.java @@ -0,0 +1,156 @@ +package com.joycrew.backend.controller; + +import com.joycrew.backend.dto.CreateOrderRequest; +import com.joycrew.backend.dto.ErrorResponse; +import com.joycrew.backend.dto.OrderResponse; +import com.joycrew.backend.dto.PagedOrderResponse; +import com.joycrew.backend.security.UserPrincipal; +import com.joycrew.backend.service.OrderService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Orders", description = "APIs for purchasing products and tracking orders") +@RestController +@RequestMapping("/api/orders") +@RequiredArgsConstructor +public class OrderController { + + private final OrderService orderService; + + @Operation( + summary = "Create an order", + description = "Creates an order for the current user and deducts points from the linked wallet.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Order created successfully.", + content = @Content( + schema = @Schema(implementation = OrderResponse.class), + examples = @ExampleObject( + value = """ + { + "orderId": 1001, + "employeeId": 1, + "productId": 101, + "productName": "Smartphone", + "productItemId": "12345", + "productUnitPrice": 499, + "quantity": 2, + "totalPrice": 998, + "status": "PLACED", + "orderedAt": "2025-08-11T10:00:00", + "shippedAt": null, + "deliveredAt": null + } + """ + ) + ) + ) + } + ) + @PostMapping + public ResponseEntity createOrder( + @AuthenticationPrincipal UserPrincipal principal, + @RequestBody CreateOrderRequest request + ) { + Long employeeId = principal.getEmployee().getEmployeeId(); + return ResponseEntity.ok(orderService.createOrder(employeeId, request)); + } + + @Operation( + summary = "Get my orders (paged)", + description = "Retrieves the current user's own orders only.", + parameters = { + @Parameter(name = "page", description = "Page number (0-based)", example = "0"), + @Parameter(name = "size", description = "Items per page", example = "20") + } + ) + @GetMapping + public ResponseEntity getMyOrders( + @AuthenticationPrincipal UserPrincipal principal, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Long employeeId = principal.getEmployee().getEmployeeId(); + return ResponseEntity.ok(orderService.getMyOrders(employeeId, page, size)); + } + + @Operation( + summary = "Get my order detail", + description = "Retrieves a specific order of the current user." + ) + @GetMapping("/{orderId}") + public ResponseEntity getMyOrderDetail( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long orderId + ) { + Long employeeId = principal.getEmployee().getEmployeeId(); + return ResponseEntity.ok(orderService.getMyOrderDetail(employeeId, orderId)); + } + + @Operation( + summary = "Cancel my order (only if not shipped)", + description = "Cancels the current user's order and refunds points if the order has not been shipped yet.", + responses = { + @ApiResponse( + responseCode = "200", + description = "Order canceled successfully.", + content = @Content( + schema = @Schema(implementation = OrderResponse.class), + examples = @ExampleObject( + value = """ + { + "orderId": 1001, + "employeeId": 1, + "productId": 101, + "productName": "Smartphone", + "productItemId": "12345", + "productUnitPrice": 499, + "quantity": 2, + "totalPrice": 998, + "status": "CANCELED", + "orderedAt": "2025-08-11T10:00:00", + "shippedAt": null, + "deliveredAt": null + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "400", + description = "Order cannot be canceled after it has been shipped.", + content = @Content( + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "code": "ORDER_CANNOT_CANCEL", + "message": "Order cannot be canceled after it has been shipped.", + "timestamp": "2025-08-11T11:00:00", + "path": "/api/orders/1001/cancel" + } + """ + ) + ) + ) + } + ) + @PatchMapping("/{orderId}/cancel") + public ResponseEntity cancelMyOrder( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long orderId + ) { + Long employeeId = principal.getEmployee().getEmployeeId(); + return ResponseEntity.ok(orderService.cancelMyOrder(employeeId, orderId)); + } +} diff --git a/src/main/java/com/joycrew/backend/controller/ProductController.java b/src/main/java/com/joycrew/backend/controller/ProductController.java index 5dca291..6ba02f5 100644 --- a/src/main/java/com/joycrew/backend/controller/ProductController.java +++ b/src/main/java/com/joycrew/backend/controller/ProductController.java @@ -1,5 +1,6 @@ package com.joycrew.backend.controller; +import com.joycrew.backend.dto.ErrorResponse; import com.joycrew.backend.dto.PagedProductResponse; import com.joycrew.backend.dto.ProductResponse; import com.joycrew.backend.entity.enums.Category; @@ -99,7 +100,11 @@ public ResponseEntity getAllProducts( ) ) ), - @ApiResponse(responseCode = "404", description = "Product not found") + @ApiResponse( + responseCode = "404", + description = "Product not found", + content = @Content // empty body for 404 + ) } ) @GetMapping("/{id}") @@ -152,7 +157,23 @@ public ResponseEntity getProductById( ) ) ), - @ApiResponse(responseCode = "400", description = "Invalid category") + @ApiResponse( + responseCode = "400", + description = "Invalid category", + content = @Content( + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ + { + "code": "INVALID_CATEGORY", + "message": "Category value is invalid.", + "timestamp": "2025-08-11T10:45:00", + "path": "/api/products/category/FOO" + } + """ + ) + ) + ) } ) @GetMapping("/category/{category}") diff --git a/src/main/java/com/joycrew/backend/dto/CreateOrderRequest.java b/src/main/java/com/joycrew/backend/dto/CreateOrderRequest.java new file mode 100644 index 0000000..c8d28dc --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/CreateOrderRequest.java @@ -0,0 +1,11 @@ +package com.joycrew.backend.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Create order request") +public record CreateOrderRequest( + @Schema(description = "Product ID", example = "101") + Long productId, + @Schema(description = "Quantity", example = "2") + Integer quantity +) { } diff --git a/src/main/java/com/joycrew/backend/dto/ErrorResponse.java b/src/main/java/com/joycrew/backend/dto/ErrorResponse.java index 5be4b2c..08b288f 100644 --- a/src/main/java/com/joycrew/backend/dto/ErrorResponse.java +++ b/src/main/java/com/joycrew/backend/dto/ErrorResponse.java @@ -1,4 +1,17 @@ package com.joycrew.backend.dto; -public record ErrorResponse(String code, String message) { -} \ No newline at end of file +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; + +@Schema(description = "Error response") +public record ErrorResponse( + @Schema(description = "Error code", example = "INSUFFICIENT_POINTS") + String code, + @Schema(description = "Error message", example = "Not enough points to complete the purchase.") + String message, + @Schema(description = "Timestamp", example = "2025-08-11T10:45:00") + LocalDateTime timestamp, + @Schema(description = "Request path", example = "/api/orders") + String path +) { } diff --git a/src/main/java/com/joycrew/backend/dto/OrderResponse.java b/src/main/java/com/joycrew/backend/dto/OrderResponse.java new file mode 100644 index 0000000..ec7d0fc --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/OrderResponse.java @@ -0,0 +1,53 @@ +package com.joycrew.backend.dto; + +import com.joycrew.backend.entity.Order; +import com.joycrew.backend.entity.enums.OrderStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "Order response") +public record OrderResponse( + @Schema(description = "Order ID", example = "1001") + Long orderId, + @Schema(description = "Employee ID", example = "1") + Long employeeId, + @Schema(description = "Product ID", example = "101") + Long productId, + @Schema(description = "Product name", example = "Smartphone") + String productName, + @Schema(description = "Product item ID", example = "12345") + String productItemId, + @Schema(description = "Unit price", example = "499") + Integer productUnitPrice, + @Schema(description = "Quantity", example = "2") + Integer quantity, + @Schema(description = "Total price", example = "998") + Integer totalPrice, + @Schema(description = "Status", example = "PLACED") + OrderStatus status, + @Schema(description = "Ordered at", example = "2025-08-11T10:00:00") + LocalDateTime orderedAt, + @Schema(description = "Shipped at", example = "2025-08-12T09:00:00") + LocalDateTime shippedAt, + @Schema(description = "Delivered at", example = "2025-08-13T18:30:00") + LocalDateTime deliveredAt +) { + public static OrderResponse from(Order o) { + return new OrderResponse( + o.getOrderId(), + o.getEmployee().getEmployeeId(), + o.getProductId(), + o.getProductName(), + o.getProductItemId(), + o.getProductUnitPrice(), + o.getQuantity(), + o.getTotalPrice(), + o.getStatus(), + o.getOrderedAt(), + o.getShippedAt(), + o.getDeliveredAt() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/PagedOrderResponse.java b/src/main/java/com/joycrew/backend/dto/PagedOrderResponse.java new file mode 100644 index 0000000..e5e6e22 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/PagedOrderResponse.java @@ -0,0 +1,23 @@ +package com.joycrew.backend.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "Paged order response") +public record PagedOrderResponse( + @Schema(description = "Content list") + List content, + @Schema(description = "Current page (0-based)", example = "0") + int page, + @Schema(description = "Page size", example = "20") + int size, + @Schema(description = "Total elements", example = "12") + long totalElements, + @Schema(description = "Total pages", example = "2") + int totalPages, + @Schema(description = "Has next page", example = "true") + boolean hasNext, + @Schema(description = "Has previous page", example = "false") + boolean hasPrevious +) { } diff --git a/src/main/java/com/joycrew/backend/entity/Order.java b/src/main/java/com/joycrew/backend/entity/Order.java new file mode 100644 index 0000000..03a682d --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/Order.java @@ -0,0 +1,63 @@ +package com.joycrew.backend.entity; + +import com.joycrew.backend.entity.enums.OrderStatus; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "orders", indexes = { + @Index(name = "idx_orders_employee", columnList = "employee_id"), + @Index(name = "idx_orders_status", columnList = "status") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Order { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long orderId; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "employee_id") + private Employee employee; + + // Product snapshot at the time of order + @Column(nullable = false) + private Long productId; + + @Column(nullable = false, length = 1000) + private String productName; + + @Column(nullable = false, length = 64) + private String productItemId; + + @Column(nullable = false) + private Integer productUnitPrice; + + @Column(nullable = false) + private Integer quantity; + + @Column(nullable = false) + private Integer totalPrice; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private OrderStatus status; + + @Column(nullable = false) + private LocalDateTime orderedAt; + + private LocalDateTime shippedAt; + private LocalDateTime deliveredAt; + + @PrePersist + protected void onCreate() { + if (this.orderedAt == null) this.orderedAt = LocalDateTime.now(); + if (this.status == null) this.status = OrderStatus.PLACED; + } +} diff --git a/src/main/java/com/joycrew/backend/entity/Wallet.java b/src/main/java/com/joycrew/backend/entity/Wallet.java index 1f4c916..f8e238b 100644 --- a/src/main/java/com/joycrew/backend/entity/Wallet.java +++ b/src/main/java/com/joycrew/backend/entity/Wallet.java @@ -20,10 +20,13 @@ public class Wallet { @Column(nullable = false) private Integer balance; + @Column(nullable = false) private Integer giftablePoint; + @Column(nullable = false) private LocalDateTime createdAt; + @Column(nullable = false) private LocalDateTime updatedAt; @@ -52,19 +55,24 @@ public void spendPoints(int amount) { this.giftablePoint -= amount; } + // Refund purchase points back to wallet + public void refundPoints(int amount) { + if (amount < 0) { + throw new IllegalArgumentException("Refund amount cannot be negative."); + } + this.balance += amount; + this.giftablePoint += amount; + } + @PrePersist protected void onCreate() { this.createdAt = this.updatedAt = LocalDateTime.now(); - if (this.balance == null) { - this.balance = 0; - } - if (this.giftablePoint == null) { - this.giftablePoint = 0; - } + if (this.balance == null) this.balance = 0; + if (this.giftablePoint == null) this.giftablePoint = 0; } @PreUpdate protected void onUpdate() { this.updatedAt = LocalDateTime.now(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/entity/enums/OrderStatus.java b/src/main/java/com/joycrew/backend/entity/enums/OrderStatus.java new file mode 100644 index 0000000..12ec71e --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/enums/OrderStatus.java @@ -0,0 +1,8 @@ +package com.joycrew.backend.entity.enums; + +public enum OrderStatus { + PLACED, // 주문 생성(결제 완료) + SHIPPED, // 배송 중 + DELIVERED, // 배송 완료 + CANCELED // 취소(선택) +} diff --git a/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java b/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java index ae93fa6..16d483e 100644 --- a/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java @@ -1,56 +1,31 @@ package com.joycrew.backend.exception; import com.joycrew.backend.dto.ErrorResponse; -import lombok.extern.slf4j.Slf4j; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.bind.annotation.*; -import java.util.stream.Collectors; +import java.time.LocalDateTime; +import java.util.NoSuchElementException; -@Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(InsufficientPointsException.class) - public ResponseEntity handleBusinessException(InsufficientPointsException ex) { - log.warn("Business logic violation: {}", ex.getMessage()); - ErrorResponse response = new ErrorResponse("BUSINESS_LOGIC_ERROR", ex.getMessage()); - return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleInsufficientPoints(InsufficientPointsException ex, HttpServletRequest req) { + return new ErrorResponse("INSUFFICIENT_POINTS", ex.getMessage(), LocalDateTime.now(), req.getRequestURI()); } - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity handleValidationExceptions(MethodArgumentNotValidException ex) { - String allErrors = ex.getBindingResult().getAllErrors().stream() - .map(error -> error.getDefaultMessage()) - .collect(Collectors.joining(", ")); - log.warn("Validation failed: {}", allErrors); - ErrorResponse response = new ErrorResponse("VALIDATION_FAILED", allErrors); - return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + @ExceptionHandler(NoSuchElementException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public ErrorResponse handleNoSuchElement(NoSuchElementException ex, HttpServletRequest req) { + return new ErrorResponse("NOT_FOUND", ex.getMessage(), LocalDateTime.now(), req.getRequestURI()); } - @ExceptionHandler({BadCredentialsException.class, UsernameNotFoundException.class}) - public ResponseEntity handleAuthenticationException(Exception ex) { - log.warn("Authentication failed: {}", ex.getMessage()); - ErrorResponse response = new ErrorResponse("AUTHENTICATION_FAILED", "The email or password provided is incorrect."); - return new ResponseEntity<>(response, HttpStatus.UNAUTHORIZED); + @ExceptionHandler(IllegalStateException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleIllegalState(IllegalStateException ex, HttpServletRequest req) { + return new ErrorResponse("ORDER_CANNOT_CANCEL", ex.getMessage(), LocalDateTime.now(), req.getRequestURI()); } - - @ExceptionHandler(UserNotFoundException.class) - public ResponseEntity handleNotFoundException(UserNotFoundException ex) { - log.warn("Resource not found: {}", ex.getMessage()); - ErrorResponse response = new ErrorResponse("RESOURCE_NOT_FOUND", ex.getMessage()); - return new ResponseEntity<>(response, HttpStatus.NOT_FOUND); - } - - @ExceptionHandler(Exception.class) - public ResponseEntity handleAllUncaughtException(Exception ex) { - log.error("Unhandled internal server error occurred", ex); - ErrorResponse response = new ErrorResponse("INTERNAL_SERVER_ERROR", "An internal server error occurred. Please contact an administrator."); - return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); - } -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/repository/OrderRepository.java b/src/main/java/com/joycrew/backend/repository/OrderRepository.java new file mode 100644 index 0000000..f95af16 --- /dev/null +++ b/src/main/java/com/joycrew/backend/repository/OrderRepository.java @@ -0,0 +1,15 @@ +package com.joycrew.backend.repository; + +import com.joycrew.backend.entity.Order; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface OrderRepository extends JpaRepository { + + Page findByEmployee_EmployeeId(Long employeeId, Pageable pageable); + + Optional findByOrderIdAndEmployee_EmployeeId(Long orderId, Long employeeId); +} diff --git a/src/main/java/com/joycrew/backend/repository/WalletRepository.java b/src/main/java/com/joycrew/backend/repository/WalletRepository.java index 8400560..e9bf7e3 100644 --- a/src/main/java/com/joycrew/backend/repository/WalletRepository.java +++ b/src/main/java/com/joycrew/backend/repository/WalletRepository.java @@ -7,4 +7,4 @@ public interface WalletRepository extends JpaRepository { Optional findByEmployee_EmployeeId(Long employeeId); -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/service/OrderService.java b/src/main/java/com/joycrew/backend/service/OrderService.java new file mode 100644 index 0000000..fb326fa --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/OrderService.java @@ -0,0 +1,103 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.CreateOrderRequest; +import com.joycrew.backend.dto.OrderResponse; +import com.joycrew.backend.dto.PagedOrderResponse; +import com.joycrew.backend.entity.*; +import com.joycrew.backend.entity.enums.OrderStatus; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.OrderRepository; +import com.joycrew.backend.repository.ProductRepository; +import com.joycrew.backend.repository.WalletRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.NoSuchElementException; + +@Service +@RequiredArgsConstructor +public class OrderService { + + private final OrderRepository orderRepository; + private final ProductRepository productRepository; + private final WalletRepository walletRepository; + private final EmployeeRepository employeeRepository; + + @Transactional + public OrderResponse createOrder(Long employeeId, CreateOrderRequest req) { + Product product = productRepository.findById(req.productId()) + .orElseThrow(() -> new NoSuchElementException("Product not found")); + + Employee employee = employeeRepository.findById(employeeId) + .orElseThrow(() -> new NoSuchElementException("Employee not found")); + + Wallet wallet = walletRepository.findByEmployee_EmployeeId(employeeId) + .orElseThrow(() -> new NoSuchElementException("Wallet not found")); + + int qty = (req.quantity() == null || req.quantity() <= 0) ? 1 : req.quantity(); + int total = product.getPrice() * qty; + + wallet.spendPoints(total); + + Order order = Order.builder() + .employee(employee) + .productId(product.getId()) + .productName(product.getName()) + .productItemId(product.getItemId()) + .productUnitPrice(product.getPrice()) + .quantity(qty) + .totalPrice(total) + .status(OrderStatus.PLACED) + .orderedAt(LocalDateTime.now()) + .build(); + + Order saved = orderRepository.save(order); + return OrderResponse.from(saved); + } + + @Transactional(readOnly = true) + public PagedOrderResponse getMyOrders(Long employeeId, int page, int size) { + PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "orderedAt")); + Page p = orderRepository.findByEmployee_EmployeeId(employeeId, pageable); + return new PagedOrderResponse( + p.getContent().stream().map(OrderResponse::from).toList(), + p.getNumber(), p.getSize(), p.getTotalElements(), p.getTotalPages(), + p.hasNext(), p.hasPrevious() + ); + } + + @Transactional(readOnly = true) + public OrderResponse getMyOrderDetail(Long employeeId, Long orderId) { + Order order = orderRepository.findByOrderIdAndEmployee_EmployeeId(orderId, employeeId) + .orElseThrow(() -> new NoSuchElementException("Order not found")); + return OrderResponse.from(order); + } + + // Cancel my order if not shipped yet + @Transactional + public OrderResponse cancelMyOrder(Long employeeId, Long orderId) { + Order order = orderRepository.findByOrderIdAndEmployee_EmployeeId(orderId, employeeId) + .orElseThrow(() -> new NoSuchElementException("Order not found")); + + // already shipped or delivered cannot cancel + if (order.getStatus() == OrderStatus.SHIPPED || order.getStatus() == OrderStatus.DELIVERED) { + throw new IllegalStateException("Order cannot be canceled after it has been shipped."); + } + if (order.getStatus() == OrderStatus.CANCELED) { + // idempotent-ish: 이미 취소된 주문 + return OrderResponse.from(order); + } + + // refund + Wallet wallet = order.getEmployee().getWallet(); + wallet.refundPoints(order.getTotalPrice()); + + // update status + order.setStatus(OrderStatus.CANCELED); + + return OrderResponse.from(order); + } +} From fc5612733af77c07b3d05e5dc273720d3095c3f9 Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Mon, 11 Aug 2025 20:33:00 +0900 Subject: [PATCH 092/135] =?UTF-8?q?release=20:=20Kubernetes=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=EC=9A=A9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cd.yml | 84 ---------- .github/workflows/ci-cd.yml | 89 +++++++++++ .github/workflows/ci.yml | 44 ------ infra/eks/cluster.yml | 145 +++++++++--------- k8s/deployment.yml | 5 +- k8s/ingress.yml | 2 +- k8s/serviceaccount.yml | 4 +- .../backend/JoyCrewBackendApplication.java | 11 ++ .../controller/AdminEmployeeController.java | 13 +- .../backend/dto/AdminPointBudgetResponse.java | 18 +++ .../dto/AdminPointDistributionRequest.java | 12 +- .../backend/dto/PointDistributionDetail.java | 13 ++ .../com/joycrew/backend/entity/Company.java | 11 ++ .../service/AdminDashboardService.java | 48 ++++++ .../backend/service/AdminPointService.java | 60 ++++++-- .../service/TransactionHistoryService.java | 20 ++- src/main/resources/application-prod.yml | 4 +- src/main/resources/bootstrap.yml | 11 -- 18 files changed, 347 insertions(+), 247 deletions(-) delete mode 100644 .github/workflows/cd.yml create mode 100644 .github/workflows/ci-cd.yml delete mode 100644 .github/workflows/ci.yml create mode 100644 src/main/java/com/joycrew/backend/dto/AdminPointBudgetResponse.java create mode 100644 src/main/java/com/joycrew/backend/dto/PointDistributionDetail.java create mode 100644 src/main/java/com/joycrew/backend/service/AdminDashboardService.java delete mode 100644 src/main/resources/bootstrap.yml diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml deleted file mode 100644 index 436ba20..0000000 --- a/.github/workflows/cd.yml +++ /dev/null @@ -1,84 +0,0 @@ -# Workflow name -name: Java Spring Boot CD - -# This workflow runs on pushes to the 'main' branch. -on: - push: - branches: - - main - -jobs: - deploy: - # The type of runner that the job will run on. - runs-on: ubuntu-latest - - steps: - # Step 1: Checks out the repository code. - - name: Checkout repository - uses: actions/checkout@v4 - - # Step 2: Sets up JDK 17 for building the project. - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - distribution: 'temurin' - java-version: '17' - - # Step 3: Builds the executable JAR file using the 'prod' profile. - - name: Build JAR for Production - run: | - chmod +x gradlew - ./gradlew clean assemble -Pspring.profiles.active=prod - - # Step 4: Finds the path to the generated JAR file. - - name: Locate and Prepare JAR artifact - id: get_jar_path - run: | - JAR_FILE=$(find build/libs -name "*.jar" | head -n 1) - if [ -z "$JAR_FILE" ]; then - echo "Error: No executable JAR file found!" - exit 1 - fi - echo "jar_path=$JAR_FILE" >> $GITHUB_OUTPUT - - # Step 5: Adds the EC2 server's host key to the runner's known_hosts file. - # This prevents interactive prompts during SSH/SCP connections. - - name: Add EC2 Host Key to known_hosts - run: | - mkdir -p ~/.ssh - echo "${{ secrets.SSH_HOST_KEYS }}" > ~/.ssh/known_hosts - chmod 600 ~/.ssh/known_hosts - - # Step 6: Securely copies the JAR file from the GitHub runner to the EC2 server. - - name: Transfer JAR to EC2 Server - uses: appleboy/scp-action@master - with: - host: ${{ secrets.SSH_HOST }} - username: ${{ secrets.SSH_USER }} - key: ${{ secrets.SSH_KEY }} - source: ${{ steps.get_jar_path.outputs.jar_path }} - target: ~/app/build/libs/ - strip_components: 2 - - # Step 7: Connects to the EC2 server via SSH and executes deployment commands. - - name: Deploy and Restart Application on EC2 - uses: appleboy/ssh-action@master - with: - host: ${{ secrets.SSH_HOST }} - username: ${{ secrets.SSH_USER }} - key: ${{ secrets.SSH_KEY }} - script: | - set -eux # Exit on error, print commands - - echo "Stopping existing JoyCrew backend service..." - sudo systemctl stop joycrew-backend || true - - DEPLOY_DIR="/home/${{ secrets.SSH_USER }}/app/build/libs" - JAR_NAME=$(basename "${{ steps.get_jar_path.outputs.jar_path }}") - sudo chmod +x "$DEPLOY_DIR/$JAR_NAME" - - echo "Starting JoyCrew backend service..." - sudo systemctl start joycrew-backend - - echo "Checking service status..." - sudo systemctl status joycrew-backend --no-pager || true \ No newline at end of file diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 0000000..8660625 --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,89 @@ +# Workflow name +name: JoyCrew Backend CI/CD + +# Triggers the workflow on pushes to the develop and main branches +on: + push: + branches: + - develop + - main + +# Environment variables available to all jobs +env: + AWS_REGION: ap-northeast-2 + ECR_REPOSITORY: joycrew-backend + EKS_CLUSTER_NAME: joycrew-cluster + +jobs: + # =================================================================== + # CI Job: Runs on pushes to 'develop' to build and test the code. + # =================================================================== + build-and-test: + name: Build and Test + # Only run this job for the 'develop' branch + if: github.ref == 'refs/heads/develop' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build and run tests with Gradle + run: ./gradlew build + + # =================================================================== + # CD Job: Runs on pushes to 'main' to build a Docker image and deploy to EKS. + # =================================================================== + deploy: + name: Build, Push, and Deploy + # Only run this job for the 'main' branch + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + + # These permissions are needed to interact with GitHub's OIDC Token endpoint. + permissions: + id-token: write + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + # Use an IAM role for authentication instead of long-lived access keys + role-to-assume: ${{ secrets.AWS_IAM_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Build, tag, and push image to Amazon ECR + id: build-image + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + IMAGE_TAG: ${{ github.sha }} + run: | + # Build the Docker image for the amd64 architecture (for EKS nodes) + docker buildx build --platform linux/amd64 -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG --push . + echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT + + - name: Configure kubectl for EKS + run: | + aws eks update-kubeconfig --name ${{ env.EKS_CLUSTER_NAME }} --region ${{ env.AWS_REGION }} + + - name: Deploy to Amazon EKS + run: | + # Update the Kubernetes deployment with the new image version + kubectl set image deployment/joycrew-backend-deployment backend-container=${{ steps.build-image.outputs.image }} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index ba5c9a6..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,44 +0,0 @@ -# Workflow name -name: Java Spring Boot CI - -# This workflow runs on pushes to the 'develop' branch. -on: - push: - branches: - - develop - -jobs: - build: - # The type of runner that the job will run on. - runs-on: ubuntu-latest - - steps: - # Step 1: Checks out your repository under $GITHUB_WORKSPACE, so your job can access it. - - name: Checkout repository - uses: actions/checkout@v4 - - # Step 2: Sets up Java Development Kit (JDK) 17. - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' - # Caches Gradle dependencies to speed up subsequent builds. - cache: gradle - - # Step 3: Grants execute permissions to the Gradle wrapper script. - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - # Step 4: Builds the project and runs tests using the Gradle wrapper. - - name: Build and Test with Gradle - run: ./gradlew build - - # Step 5: Uploads the generated JAR file as a build artifact. - - name: Upload JAR artifact - uses: actions/upload-artifact@v4 - with: - name: joycrew-backend-jar - path: build/libs/*.jar - # Artifacts will be available for 1 day. - retention-days: 1 \ No newline at end of file diff --git a/infra/eks/cluster.yml b/infra/eks/cluster.yml index 25085fc..1e19dda 100644 --- a/infra/eks/cluster.yml +++ b/infra/eks/cluster.yml @@ -1,82 +1,75 @@ - # =================================================================== - # EKS Cluster Configuration for JoyCrew Backend - # =================================================================== - apiVersion: eksctl.io/v1alpha5 - kind: ClusterConfig +# =================================================================== +# EKS Cluster Configuration for JoyCrew Backend +# This version is configured to use your EXISTING VPC and subnets. +# =================================================================== +apiVersion: eksctl.io/v1alpha5 +kind: ClusterConfig - metadata: - # The name of the EKS cluster - name: joycrew-cluster - # The AWS region where the cluster will be created - region: ap-northeast-2 - # The desired Kubernetes version (consider using 1.29 or 1.30 for latest features) - version: "1.29" +metadata: + name: joycrew-cluster + region: ap-northeast-2 + version: "1.29" - # Enable IAM Roles for Service Accounts (IRSA) for secure pod-to-AWS service authentication - iam: - withOIDC: true +# Enable IAM Roles for Service Accounts (IRSA) +iam: + withOIDC: true - # VPC configuration (optional: eksctl will create a new VPC if not specified) - vpc: - # CIDR block for the VPC - cidr: 10.0.0.0/16 +vpc: + id: "vpc-067968d8c7f0de694" + subnets: + # The worker nodes will be placed in the private subnets. + private: + ap-northeast-2b: { id: "subnet-0b1c4ed704fe8809c" } + ap-northeast-2d: { id: "subnet-00394d1c845cc1187" } + public: + ap-northeast-2a: { id: "subnet-03e9e38db1457d819" } + ap-northeast-2c: { id: "subnet-0283caa043409b846" } - # Configuration for managed node groups - managedNodeGroups: - - name: app-workers - # Instance type - consider t3.large for better performance in production - instanceType: t3.medium - # Node group scaling configuration - desiredCapacity: 2 - minSize: 1 - maxSize: 4 - # Use private subnets for better security - privateNetworking: true +# Configuration for managed node groups +managedNodeGroups: + - name: app-workers + instanceType: t3.small + desiredCapacity: 2 + minSize: 1 + maxSize: 4 + # 'privateNetworking' is not needed when subnets are specified above. + # eksctl will automatically use the private subnets for the nodes. + ssh: + allow: true + publicKeyName: joycrew-deploy-key + labels: + role: application + environment: production + volumeSize: 20 + volumeType: gp3 + iam: + attachPolicyARNs: + - arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy + - arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy + - arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly + - arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy - # SSH access configuration - ssh: - allow: true - publicKeyName: joycrew-deploy-key +# Manage essential EKS add-ons +addons: + - name: vpc-cni + version: latest + configurationValues: |- + enableNetworkPolicy: "true" + - name: coredns + version: latest + - name: kube-proxy + version: latest + - name: aws-ebs-csi-driver + version: latest + wellKnownPolicies: + ebsCSIController: true - # Node labels for workload scheduling - labels: - role: application - environment: production - - # Volume configuration - volumeSize: 20 - volumeType: gp3 - - # IAM policies for worker nodes - iam: - attachPolicyARNs: - - arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy - - arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy - - arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly - # Add CloudWatch policy if you want container insights - - arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy - - # Manage essential EKS add-ons - addons: - - name: vpc-cni - version: latest - configurationValues: |- - enableNetworkPolicy: "true" - - name: coredns - version: latest - - name: kube-proxy - version: latest - - name: aws-ebs-csi-driver - version: latest - wellKnownPolicies: - ebsCSIController: true - - # CloudWatch logging configuration - cloudWatch: - clusterLogging: - enableTypes: - - api - - audit - - authenticator - - controllerManager - - scheduler \ No newline at end of file +# CloudWatch logging configuration +cloudWatch: + clusterLogging: + enableTypes: + - api + - audit + - authenticator + - controllerManager + - scheduler \ No newline at end of file diff --git a/k8s/deployment.yml b/k8s/deployment.yml index f964024..cbf9907 100644 --- a/k8s/deployment.yml +++ b/k8s/deployment.yml @@ -21,14 +21,13 @@ spec: serviceAccountName: joycrew-service-account containers: - name: backend-container - # ❗️ IMPORTANT: Use the correct URI of your Docker image pushed to ECR. - image: "566105751077.dkr.ecr.ap-northeast-2.amazonaws.com/joycrew-backend:latest" + image: "566105751077.dkr.ecr.ap-northeast-2.amazonaws.com/joycrew-backend:a761bbb-1754808557" ports: - containerPort: 8082 # Define resource requests and limits for better scheduling and stability. resources: requests: - memory: "512Mi" + memory: "256Mi" cpu: "250m" limits: memory: "1Gi" diff --git a/k8s/ingress.yml b/k8s/ingress.yml index dfbf582..a7a9824 100644 --- a/k8s/ingress.yml +++ b/k8s/ingress.yml @@ -20,7 +20,7 @@ spec: # Specifies that the 'alb' ingress controller should handle this Ingress. (Modern way) ingressClassName: alb rules: - - host: api.joycrew.com + - host: api.joycrew.co.kr http: paths: - path: / diff --git a/k8s/serviceaccount.yml b/k8s/serviceaccount.yml index 138a154..3866247 100644 --- a/k8s/serviceaccount.yml +++ b/k8s/serviceaccount.yml @@ -5,7 +5,5 @@ metadata: name: joycrew-service-account # Annotations for IAM Roles for Service Accounts (IRSA). # This links the Kubernetes service account to an AWS IAM role. - # ❗️ IMPORTANT: Replace 'YOUR_IAM_ROLE_ARN' with the ARN of the IAM role - # that has permissions for S3 and Secrets Manager. annotations: - eks.amazonaws.com/role-arn: "YOUR_IAM_ROLE_ARN" \ No newline at end of file + eks.amazonaws.com/role-arn: "arn:aws:iam::566105751077:role/eksctl-joycrew-cluster-addon-iamserviceaccoun-Role1-65tzCzsgly5T" \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/JoyCrewBackendApplication.java b/src/main/java/com/joycrew/backend/JoyCrewBackendApplication.java index 26095d7..fc51ce8 100644 --- a/src/main/java/com/joycrew/backend/JoyCrewBackendApplication.java +++ b/src/main/java/com/joycrew/backend/JoyCrewBackendApplication.java @@ -1,8 +1,11 @@ package com.joycrew.backend; +import jakarta.annotation.PostConstruct; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import java.util.TimeZone; + @SpringBootApplication public class JoyCrewBackendApplication { @@ -10,4 +13,12 @@ public static void main(String[] args) { SpringApplication.run(JoyCrewBackendApplication.class, args); } + /** + * Set the default timezone for the application to Korea Standard Time (KST). + * This ensures all date and time operations are based on KST. + */ + @PostConstruct + public void init() { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } } diff --git a/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java b/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java index db1ceb3..d620a76 100644 --- a/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java +++ b/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java @@ -2,9 +2,7 @@ import com.joycrew.backend.dto.*; import com.joycrew.backend.security.UserPrincipal; -import com.joycrew.backend.service.AdminPointService; -import com.joycrew.backend.service.EmployeeManagementService; -import com.joycrew.backend.service.EmployeeRegistrationService; +import com.joycrew.backend.service.*; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; @@ -31,6 +29,7 @@ public class AdminEmployeeController { private final EmployeeRegistrationService registrationService; private final EmployeeManagementService managementService; private final AdminPointService pointService; + private final AdminDashboardService adminDashboardService; @Operation( summary = "Register a single employee", @@ -130,4 +129,12 @@ public ResponseEntity distributePoints( pointService.distributePoints(request, principal.getEmployee()); return ResponseEntity.ok(new SuccessResponse("Point distribution process completed successfully.")); } + + @Operation(summary = "Get admin's personal and company point balance", description = "Gets the admin's personal wallet balance and the total budget of the company they belong to.", security = @SecurityRequirement(name = "Authorization")) + @GetMapping("/points/balance") + public ResponseEntity getAdminAndCompanyBalance( + @AuthenticationPrincipal UserPrincipal principal) { + AdminPointBudgetResponse response = adminDashboardService.getAdminAndCompanyBalance(principal.getUsername()); + return ResponseEntity.ok(response); + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/AdminPointBudgetResponse.java b/src/main/java/com/joycrew/backend/dto/AdminPointBudgetResponse.java new file mode 100644 index 0000000..331608e --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/AdminPointBudgetResponse.java @@ -0,0 +1,18 @@ +package com.joycrew.backend.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * DTO for responding with the admin's personal wallet balance and the company's total point budget. + */ +@Schema(description = "Admin's point budget and personal balance response DTO") +public record AdminPointBudgetResponse( + @Schema(description = "The total point budget available for the entire company") + Double companyTotalBalance, + + @Schema(description = "The admin's personal total point balance") + Integer adminPersonalTotalBalance, + + @Schema(description = "The admin's personal giftable point balance") + Integer adminPersonalGiftableBalance +) {} diff --git a/src/main/java/com/joycrew/backend/dto/AdminPointDistributionRequest.java b/src/main/java/com/joycrew/backend/dto/AdminPointDistributionRequest.java index 50f5879..80e9a1a 100644 --- a/src/main/java/com/joycrew/backend/dto/AdminPointDistributionRequest.java +++ b/src/main/java/com/joycrew/backend/dto/AdminPointDistributionRequest.java @@ -1,16 +1,18 @@ package com.joycrew.backend.dto; import com.joycrew.backend.entity.enums.TransactionType; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + import java.util.List; public record AdminPointDistributionRequest( - @NotEmpty(message = "Employee ID list cannot be empty.") - List employeeIds, - - @NotNull(message = "Points are required.") - int points, + @NotEmpty(message = "Distribution list cannot be empty.") + @Size(min = 1, message = "At least one employee must be selected.") + @Valid + List distributions, @NotNull(message = "Message is required.") String message, diff --git a/src/main/java/com/joycrew/backend/dto/PointDistributionDetail.java b/src/main/java/com/joycrew/backend/dto/PointDistributionDetail.java new file mode 100644 index 0000000..f13791a --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/PointDistributionDetail.java @@ -0,0 +1,13 @@ +package com.joycrew.backend.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +public record PointDistributionDetail( + @NotNull(message = "Employee ID is required.") + Long employeeId, + + @NotNull(message = "Points are required.") + @Min(value = 1, message = "Points must be at least 1.") + Integer points +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/Company.java b/src/main/java/com/joycrew/backend/entity/Company.java index b781dbf..83fbf70 100644 --- a/src/main/java/com/joycrew/backend/entity/Company.java +++ b/src/main/java/com/joycrew/backend/entity/Company.java @@ -1,5 +1,6 @@ package com.joycrew.backend.entity; +import com.joycrew.backend.exception.InsufficientPointsException; import jakarta.persistence.*; import lombok.*; @@ -56,6 +57,16 @@ public void addBudget(double amount) { this.totalCompanyBalance += amount; } + public void spendBudget(double amount) { + if (amount < 0) { + throw new IllegalArgumentException("Amount to spend cannot be negative."); + } + if (this.totalCompanyBalance < amount) { + throw new InsufficientPointsException("The company does not have enough budget to distribute the points."); + } + this.totalCompanyBalance -= amount; + } + @PrePersist protected void onCreate() { this.createdAt = this.updatedAt = LocalDateTime.now(); diff --git a/src/main/java/com/joycrew/backend/service/AdminDashboardService.java b/src/main/java/com/joycrew/backend/service/AdminDashboardService.java new file mode 100644 index 0000000..5738e7d --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/AdminDashboardService.java @@ -0,0 +1,48 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.AdminPointBudgetResponse; +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.exception.UserNotFoundException; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.WalletRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminDashboardService { + + private final EmployeeRepository employeeRepository; + private final WalletRepository walletRepository; + + /** + * Fetches both the company's total point budget and the admin's personal wallet balance. + * @param adminEmail The email of the currently logged-in administrator. + * @return A DTO containing both company and personal point balances. + */ + public AdminPointBudgetResponse getAdminAndCompanyBalance(String adminEmail) { + // 1. Fetch the admin employee and their associated company + Employee admin = employeeRepository.findByEmail(adminEmail) + .orElseThrow(() -> new UserNotFoundException("Admin user not found.")); + + Company company = admin.getCompany(); + if (company == null) { + throw new IllegalStateException("Admin is not associated with any company."); + } + + // 2. Fetch the admin's personal wallet + Wallet adminWallet = walletRepository.findByEmployee_EmployeeId(admin.getEmployeeId()) + .orElse(new Wallet(admin)); // If no wallet, create a new one with 0 points + + // 3. Create and return the combined response DTO + return new AdminPointBudgetResponse( + company.getTotalCompanyBalance(), + adminWallet.getBalance(), + adminWallet.getGiftablePoint() + ); + } +} diff --git a/src/main/java/com/joycrew/backend/service/AdminPointService.java b/src/main/java/com/joycrew/backend/service/AdminPointService.java index 436c074..4e61e2c 100644 --- a/src/main/java/com/joycrew/backend/service/AdminPointService.java +++ b/src/main/java/com/joycrew/backend/service/AdminPointService.java @@ -1,10 +1,13 @@ package com.joycrew.backend.service; import com.joycrew.backend.dto.AdminPointDistributionRequest; +import com.joycrew.backend.dto.PointDistributionDetail; +import com.joycrew.backend.entity.Company; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.RewardPointTransaction; import com.joycrew.backend.entity.Wallet; import com.joycrew.backend.exception.UserNotFoundException; +import com.joycrew.backend.repository.CompanyRepository; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.RewardPointTransactionRepository; import com.joycrew.backend.repository.WalletRepository; @@ -13,6 +16,9 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -22,31 +28,55 @@ public class AdminPointService { private final EmployeeRepository employeeRepository; private final WalletRepository walletRepository; private final RewardPointTransactionRepository transactionRepository; + private final CompanyRepository companyRepository; public void distributePoints(AdminPointDistributionRequest request, Employee admin) { - List employees = employeeRepository.findAllById(request.employeeIds()); - if (employees.size() != request.employeeIds().size()) { + int netPointsChange = request.distributions().stream() + .mapToInt(PointDistributionDetail::points) + .sum(); + + Company company = admin.getCompany(); + if (netPointsChange > 0) { + company.spendBudget(netPointsChange); + } else if (netPointsChange < 0) { + company.addBudget(Math.abs(netPointsChange)); + } + companyRepository.save(company); + + List employeeIds = request.distributions().stream() + .map(PointDistributionDetail::employeeId) + .toList(); + + Map employeeMap = employeeRepository.findAllById(employeeIds).stream() + .collect(Collectors.toMap(Employee::getEmployeeId, Function.identity())); + + if (employeeMap.size() != employeeIds.size()) { throw new UserNotFoundException("Could not find some of the requested employees. Please verify the IDs."); } - for (Employee employee : employees) { + for (PointDistributionDetail detail : request.distributions()) { + Employee employee = employeeMap.get(detail.employeeId()); Wallet wallet = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) .orElseThrow(() -> new IllegalStateException("Wallet not found for employee: " + employee.getEmployeeName())); - if (request.points() > 0) { - wallet.addPoints(request.points()); - } else { - wallet.spendPoints(Math.abs(request.points())); + int pointsToProcess = detail.points(); + + if (pointsToProcess > 0) { + wallet.addPoints(pointsToProcess); + } else if (pointsToProcess < 0) { + wallet.spendPoints(Math.abs(pointsToProcess)); } - RewardPointTransaction transaction = RewardPointTransaction.builder() - .sender(admin) - .receiver(employee) - .pointAmount(request.points()) - .message(request.message()) - .type(request.type()) - .build(); - transactionRepository.save(transaction); + if (pointsToProcess != 0) { + RewardPointTransaction transaction = RewardPointTransaction.builder() + .sender(admin) + .receiver(employee) + .pointAmount(pointsToProcess) + .message(request.message()) + .type(request.type()) + .build(); + transactionRepository.save(transaction); + } } } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java b/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java index 944dcb0..b42222e 100644 --- a/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java +++ b/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java @@ -30,7 +30,25 @@ public List getTransactionHistory(String userEmail) .map(tx -> { boolean isSender = user.equals(tx.getSender()); int amount = isSender ? -tx.getPointAmount() : tx.getPointAmount(); - String counterparty = isSender ? tx.getReceiver().getEmployeeName() : (tx.getSender() != null ? tx.getSender().getEmployeeName() : "System"); + + String counterparty; + switch (tx.getType()) { + case REDEEM_ITEM: + counterparty = tx.getMessage() != null ? tx.getMessage() : "상품 구매"; + break; + case AWARD_P2P: + case AWARD_MANAGER_SPOT: + case ADMIN_ADJUSTMENT: + counterparty = isSender + ? tx.getReceiver().getEmployeeName() + : (tx.getSender() != null ? tx.getSender().getEmployeeName() : "System"); + break; + case AWARD_AUTOMATED: + case EXPIRE_POINTS: + default: + counterparty = "System"; + break; + } return TransactionHistoryResponse.builder() .transactionId(tx.getTransactionId()) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index ee76831..748b7b8 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -4,6 +4,8 @@ # Properties that are active when the 'prod' profile is enabled. # =================================================================== spring: + config: + import: optional:aws-secretsmanager:security # In production, the application connects to an external MySQL database. # For security, database credentials are read from environment variables. datasource: @@ -25,7 +27,7 @@ logging: # The frontend service URL for the production environment. app: - frontend-url: https://joycrew.co.kr + frontend-url: https://www.joycrew.co.kr # AWS specific settings for production aws: diff --git a/src/main/resources/bootstrap.yml b/src/main/resources/bootstrap.yml deleted file mode 100644 index 9f2447b..0000000 --- a/src/main/resources/bootstrap.yml +++ /dev/null @@ -1,11 +0,0 @@ -# =================================================================== -# SPRING BOOTSTRAP CONFIGURATION -# -# This file is loaded before application.yml and is used to -# import configuration from external sources like AWS Secrets Manager. -# =================================================================== -spring: - config: - # Imports secrets from the specified path in AWS Secrets Manager. - # The application must have the appropriate IAM role to access this. - import: "aws-secretsmanager:security" From a2724b356584b491e76ec9b53dae3704e81178a1 Mon Sep 17 00:00:00 2001 From: yeoEun Date: Mon, 11 Aug 2025 21:45:38 +0900 Subject: [PATCH 093/135] feat: recentproduct-view --- .../backend/JoyCrewBackendApplication.java | 2 + .../RecentProductViewController.java | 48 ++++++++++++ .../dto/AdminPointDistributionRequest.java | 10 ++- .../dto/RecentViewedProductResponse.java | 18 +++++ .../backend/entity/RecentProductView.java | 43 +++++++++++ .../RecentProductViewRepository.java | 20 +++++ .../scheduler/RecentViewCleanupJob.java | 26 +++++++ .../backend/service/AdminPointService.java | 3 +- .../service/RecentProductViewService.java | 74 +++++++++++++++++++ src/main/resources/application.yml | 8 +- 10 files changed, 248 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/joycrew/backend/controller/RecentProductViewController.java create mode 100644 src/main/java/com/joycrew/backend/dto/RecentViewedProductResponse.java create mode 100644 src/main/java/com/joycrew/backend/entity/RecentProductView.java create mode 100644 src/main/java/com/joycrew/backend/repository/RecentProductViewRepository.java create mode 100644 src/main/java/com/joycrew/backend/scheduler/RecentViewCleanupJob.java create mode 100644 src/main/java/com/joycrew/backend/service/RecentProductViewService.java diff --git a/src/main/java/com/joycrew/backend/JoyCrewBackendApplication.java b/src/main/java/com/joycrew/backend/JoyCrewBackendApplication.java index fc51ce8..c73acf2 100644 --- a/src/main/java/com/joycrew/backend/JoyCrewBackendApplication.java +++ b/src/main/java/com/joycrew/backend/JoyCrewBackendApplication.java @@ -3,10 +3,12 @@ import jakarta.annotation.PostConstruct; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; import java.util.TimeZone; @SpringBootApplication +@EnableScheduling public class JoyCrewBackendApplication { public static void main(String[] args) { diff --git a/src/main/java/com/joycrew/backend/controller/RecentProductViewController.java b/src/main/java/com/joycrew/backend/controller/RecentProductViewController.java new file mode 100644 index 0000000..c928e6e --- /dev/null +++ b/src/main/java/com/joycrew/backend/controller/RecentProductViewController.java @@ -0,0 +1,48 @@ +package com.joycrew.backend.controller; + +import com.joycrew.backend.dto.RecentViewedProductResponse; +import com.joycrew.backend.security.UserPrincipal; +import com.joycrew.backend.service.RecentProductViewService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "Recent Views", description = "APIs for recording and retrieving recently viewed products") +@RestController +@RequestMapping("/api/recent-views") +@RequiredArgsConstructor +public class RecentProductViewController { + + private final RecentProductViewService recentProductViewService; + + @Operation(summary = "Record a recent view", description = "Records a product as recently viewed by the current user.") + @PostMapping("/{productId}") + public ResponseEntity recordView( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long productId + ) { + Long employeeId = principal.getEmployee().getEmployeeId(); + recentProductViewService.recordView(employeeId, productId); + return ResponseEntity.ok().build(); + } + + @Operation( + summary = "Get recent views", + description = "Returns the current user's recently viewed products within the last 3 months.", + parameters = @Parameter(name = "limit", description = "Max items to return (default 20, max 100)", example = "20") + ) + @GetMapping + public ResponseEntity> getRecentViews( + @AuthenticationPrincipal UserPrincipal principal, + @RequestParam(required = false) Integer limit + ) { + Long employeeId = principal.getEmployee().getEmployeeId(); + return ResponseEntity.ok(recentProductViewService.getRecentViews(employeeId, limit)); + } +} diff --git a/src/main/java/com/joycrew/backend/dto/AdminPointDistributionRequest.java b/src/main/java/com/joycrew/backend/dto/AdminPointDistributionRequest.java index 80e9a1a..31f6686 100644 --- a/src/main/java/com/joycrew/backend/dto/AdminPointDistributionRequest.java +++ b/src/main/java/com/joycrew/backend/dto/AdminPointDistributionRequest.java @@ -1,11 +1,11 @@ package com.joycrew.backend.dto; import com.joycrew.backend.entity.enums.TransactionType; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; - import java.util.List; public record AdminPointDistributionRequest( @@ -18,5 +18,11 @@ public record AdminPointDistributionRequest( String message, @NotNull(message = "Transaction type is required.") + @Schema( + description = "Fixed to AWARD_MANAGER_SPOT for admin bulk distributions", + allowableValues = {"AWARD_MANAGER_SPOT"}, + example = "AWARD_MANAGER_SPOT", + defaultValue = "AWARD_MANAGER_SPOT" + ) TransactionType type -) {} \ No newline at end of file +) { } diff --git a/src/main/java/com/joycrew/backend/dto/RecentViewedProductResponse.java b/src/main/java/com/joycrew/backend/dto/RecentViewedProductResponse.java new file mode 100644 index 0000000..c4186bc --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/RecentViewedProductResponse.java @@ -0,0 +1,18 @@ +package com.joycrew.backend.dto; + +import com.joycrew.backend.entity.Product; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; + +@Schema(description = "Recently viewed product item") +public record RecentViewedProductResponse( + @Schema(description = "Product") + ProductResponse product, + @Schema(description = "Viewed at", example = "2025-08-11T12:34:56") + LocalDateTime viewedAt +) { + public static RecentViewedProductResponse of(Product product, LocalDateTime viewedAt) { + return new RecentViewedProductResponse(ProductResponse.from(product), viewedAt); + } +} diff --git a/src/main/java/com/joycrew/backend/entity/RecentProductView.java b/src/main/java/com/joycrew/backend/entity/RecentProductView.java new file mode 100644 index 0000000..4665d06 --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/RecentProductView.java @@ -0,0 +1,43 @@ +package com.joycrew.backend.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "recent_product_view", + uniqueConstraints = @UniqueConstraint(name = "uq_employee_product", columnNames = {"employee_id", "product_id"}), + indexes = { + @Index(name = "idx_rpv_employee_viewed", columnList = "employee_id, viewedAt"), + @Index(name = "idx_rpv_viewed", columnList = "viewedAt") + }) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class RecentProductView { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // who viewed + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "employee_id") + private Employee employee; + + // which product + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "product_id") + private Product product; + + @Column(nullable = false) + private LocalDateTime viewedAt; + + @PrePersist + public void onCreate() { + if (this.viewedAt == null) this.viewedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/joycrew/backend/repository/RecentProductViewRepository.java b/src/main/java/com/joycrew/backend/repository/RecentProductViewRepository.java new file mode 100644 index 0000000..5f3563d --- /dev/null +++ b/src/main/java/com/joycrew/backend/repository/RecentProductViewRepository.java @@ -0,0 +1,20 @@ +package com.joycrew.backend.repository; + +import com.joycrew.backend.entity.RecentProductView; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface RecentProductViewRepository extends JpaRepository { + + Optional findByEmployee_EmployeeIdAndProduct_Id(Long employeeId, Long productId); + + List findByEmployee_EmployeeIdAndViewedAtAfterOrderByViewedAtDesc( + Long employeeId, LocalDateTime threshold, Pageable pageable + ); + + long deleteByViewedAtBefore(LocalDateTime threshold); +} diff --git a/src/main/java/com/joycrew/backend/scheduler/RecentViewCleanupJob.java b/src/main/java/com/joycrew/backend/scheduler/RecentViewCleanupJob.java new file mode 100644 index 0000000..db20444 --- /dev/null +++ b/src/main/java/com/joycrew/backend/scheduler/RecentViewCleanupJob.java @@ -0,0 +1,26 @@ +package com.joycrew.backend.scheduler; + +import com.joycrew.backend.service.RecentProductViewService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.scheduling.annotation.Scheduled; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RecentViewCleanupJob { + + private final RecentProductViewService recentProductViewService; + + // 매일 새벽 03:00 (서울 시간대) + @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul") + public void cleanupOldRecentViews() { + long deleted = recentProductViewService.cleanupOldViews(); + if (deleted > 0) { + log.info("Cleaned up {} recent product views older than 3 months.", deleted); + } else { + log.debug("No recent product views to clean."); + } + } +} diff --git a/src/main/java/com/joycrew/backend/service/AdminPointService.java b/src/main/java/com/joycrew/backend/service/AdminPointService.java index 4e61e2c..458ae37 100644 --- a/src/main/java/com/joycrew/backend/service/AdminPointService.java +++ b/src/main/java/com/joycrew/backend/service/AdminPointService.java @@ -6,6 +6,7 @@ import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.RewardPointTransaction; import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.TransactionType; import com.joycrew.backend.exception.UserNotFoundException; import com.joycrew.backend.repository.CompanyRepository; import com.joycrew.backend.repository.EmployeeRepository; @@ -73,7 +74,7 @@ public void distributePoints(AdminPointDistributionRequest request, Employee adm .receiver(employee) .pointAmount(pointsToProcess) .message(request.message()) - .type(request.type()) + .type(TransactionType.AWARD_MANAGER_SPOT) .build(); transactionRepository.save(transaction); } diff --git a/src/main/java/com/joycrew/backend/service/RecentProductViewService.java b/src/main/java/com/joycrew/backend/service/RecentProductViewService.java new file mode 100644 index 0000000..624b7af --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/RecentProductViewService.java @@ -0,0 +1,74 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.RecentViewedProductResponse; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Product; +import com.joycrew.backend.entity.RecentProductView; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.ProductRepository; +import com.joycrew.backend.repository.RecentProductViewRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.NoSuchElementException; + +@Service +@RequiredArgsConstructor +public class RecentProductViewService { + + private static final int DEFAULT_LIMIT = 20; + + private final RecentProductViewRepository recentProductViewRepository; + private final EmployeeRepository employeeRepository; + private final ProductRepository productRepository; + + // Upsert viewed record + @Transactional + public void recordView(Long employeeId, Long productId) { + Employee employee = employeeRepository.findById(employeeId) + .orElseThrow(() -> new NoSuchElementException("Employee not found")); + Product product = productRepository.findById(productId) + .orElseThrow(() -> new NoSuchElementException("Product not found")); + + LocalDateTime now = LocalDateTime.now(); + + recentProductViewRepository.findByEmployee_EmployeeIdAndProduct_Id(employeeId, productId) + .ifPresentOrElse(existing -> { + existing.setViewedAt(now); + }, () -> { + RecentProductView view = RecentProductView.builder() + .employee(employee) + .product(product) + .viewedAt(now) + .build(); + recentProductViewRepository.save(view); + }); + } + + @Transactional(readOnly = true) + public List getRecentViews(Long employeeId, Integer limit) { + int size = (limit == null || limit <= 0) ? DEFAULT_LIMIT : Math.min(limit, 100); + LocalDateTime threshold = LocalDateTime.now().minus(3, ChronoUnit.MONTHS); + + var views = recentProductViewRepository + .findByEmployee_EmployeeIdAndViewedAtAfterOrderByViewedAtDesc( + employeeId, threshold, PageRequest.of(0, size) + ); + + return views.stream() + .map(v -> RecentViewedProductResponse.of(v.getProduct(), v.getViewedAt())) + .toList(); + } + + // Called by a scheduler to keep the table small + @Transactional + public long cleanupOldViews() { + LocalDateTime threshold = LocalDateTime.now().minusMonths(3); + return recentProductViewRepository.deleteByViewedAtBefore(threshold); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 40b204f..dc0a155 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -61,4 +61,10 @@ app: aws: region: 'ap-northeast-2' s3: - bucket-name: 'joycrew-dev-bucket' \ No newline at end of file + bucket-name: 'joycrew-dev-bucket' + +#view-cleanup-scheduling +jobs: + recent-view-cleanup: + enabled: true + cron: "0 0 3 * * *" From 5ce95189b35ed41e012a5a346f9c07dfb64d7fae Mon Sep 17 00:00:00 2001 From: yeoEun Date: Tue, 12 Aug 2025 18:00:57 +0900 Subject: [PATCH 094/135] fix: integrationTest-argument-error --- .../AdminEmployeeControllerTest.java | 12 +++- .../service/AdminFeaturesIntegrationTest.java | 11 +++- .../GiftPointServiceIntegrationTest.java | 66 +++++++++++++++---- 3 files changed, 70 insertions(+), 19 deletions(-) diff --git a/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java b/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java index bf66980..0de627b 100644 --- a/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java @@ -4,6 +4,7 @@ import com.joycrew.backend.dto.AdminEmployeeUpdateRequest; import com.joycrew.backend.dto.AdminPointDistributionRequest; import com.joycrew.backend.dto.EmployeeRegistrationRequest; +import com.joycrew.backend.dto.PointDistributionDetail; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.enums.AdminLevel; import com.joycrew.backend.entity.enums.TransactionType; @@ -113,9 +114,16 @@ void deleteEmployee_Success() throws Exception { @WithMockUserPrincipal(role="SUPER_ADMIN") void distributePoints_Success() throws Exception { // Given - AdminPointDistributionRequest request = new AdminPointDistributionRequest( - List.of(1L, 2L), 100, "Bonus", TransactionType.ADMIN_ADJUSTMENT); + List distributions = List.of( + new PointDistributionDetail(1L, 100), + new PointDistributionDetail(2L, 100) + ); + AdminPointDistributionRequest request = new AdminPointDistributionRequest( + distributions, + "Bonus", + TransactionType.AWARD_MANAGER_SPOT + ); // When & Then mockMvc.perform(post("/api/admin/employees/points/distribute") .contentType(MediaType.APPLICATION_JSON) diff --git a/src/test/java/com/joycrew/backend/service/AdminFeaturesIntegrationTest.java b/src/test/java/com/joycrew/backend/service/AdminFeaturesIntegrationTest.java index 4331f82..936edc6 100644 --- a/src/test/java/com/joycrew/backend/service/AdminFeaturesIntegrationTest.java +++ b/src/test/java/com/joycrew/backend/service/AdminFeaturesIntegrationTest.java @@ -2,6 +2,7 @@ import com.joycrew.backend.dto.AdminEmployeeUpdateRequest; import com.joycrew.backend.dto.AdminPointDistributionRequest; +import com.joycrew.backend.dto.PointDistributionDetail; import com.joycrew.backend.entity.Company; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.Wallet; @@ -60,11 +61,15 @@ private Employee createAndSaveEmployee(String email, String name, AdminLevel lev @DisplayName("[Integration] Admin successfully distributes points to employees") void distributePoints_Success() { // Given + List distributions = List.of( + new PointDistributionDetail(employee1.getEmployeeId(), 500), + new PointDistributionDetail(employee2.getEmployeeId(), 500) + ); + AdminPointDistributionRequest request = new AdminPointDistributionRequest( - List.of(employee1.getEmployeeId(), employee2.getEmployeeId()), - 500, + distributions, "Bonus Payout", - TransactionType.ADMIN_ADJUSTMENT + TransactionType.AWARD_MANAGER_SPOT ); // When diff --git a/src/test/java/com/joycrew/backend/service/GiftPointServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/GiftPointServiceIntegrationTest.java index b911c18..c8720fa 100644 --- a/src/test/java/com/joycrew/backend/service/GiftPointServiceIntegrationTest.java +++ b/src/test/java/com/joycrew/backend/service/GiftPointServiceIntegrationTest.java @@ -11,10 +11,11 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.event.ApplicationEvents; -import org.springframework.test.context.event.RecordApplicationEvents; +import org.springframework.context.ApplicationListener; +import org.springframework.context.annotation.Bean; +import org.springframework.test.annotation.Commit; import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -23,14 +24,35 @@ @SpringBootTest @Transactional -@RecordApplicationEvents class GiftPointServiceIntegrationTest { - @Autowired private GiftPointService giftPointService; - @Autowired private EmployeeRegistrationService registrationService; - @Autowired private CompanyRepository companyRepository; - @Autowired private WalletRepository walletRepository; - @Autowired private ApplicationEvents applicationEvents; + @TestConfiguration + static class TestConfig { + @Bean + TestRecognitionEventListener testRecognitionEventListener() { + return new TestRecognitionEventListener(); + } + } + + static class TestRecognitionEventListener implements ApplicationListener { + private final java.util.List events = + new java.util.concurrent.CopyOnWriteArrayList<>(); + + @Override + public void onApplicationEvent(RecognitionEvent event) { + events.add(event); + } + + public java.util.List getEvents() { + return events; + } + } + + @org.springframework.beans.factory.annotation.Autowired private GiftPointService giftPointService; + @org.springframework.beans.factory.annotation.Autowired private EmployeeRegistrationService registrationService; + @org.springframework.beans.factory.annotation.Autowired private CompanyRepository companyRepository; + @org.springframework.beans.factory.annotation.Autowired private WalletRepository walletRepository; + @org.springframework.beans.factory.annotation.Autowired private TestRecognitionEventListener eventListener; private Long senderId, receiverId; private Company company; @@ -38,17 +60,32 @@ class GiftPointServiceIntegrationTest { @BeforeEach void setUp() { company = companyRepository.save(Company.builder().companyName("Test Inc.").build()); - var senderRequest = new EmployeeRegistrationRequest("Sender", "sender@test.com", "password123!", company.getCompanyName(), null, "Dev", AdminLevel.EMPLOYEE, null, null, null); - var receiverRequest = new EmployeeRegistrationRequest("Receiver", "receiver@test.com", "password123!", company.getCompanyName(), null, "Dev", AdminLevel.EMPLOYEE, null, null, null); + + var senderRequest = new EmployeeRegistrationRequest( + "Sender", "sender@test.com", "password123!", + company.getCompanyName(), null, "Dev", AdminLevel.EMPLOYEE, + null, null, null + ); + var receiverRequest = new EmployeeRegistrationRequest( + "Receiver", "receiver@test.com", "password123!", + company.getCompanyName(), null, "Dev", AdminLevel.EMPLOYEE, + null, null, null + ); + senderId = registrationService.registerEmployee(senderRequest).getEmployeeId(); receiverId = registrationService.registerEmployee(receiverRequest).getEmployeeId(); + // 초기 포인트 충전 Wallet senderWallet = walletRepository.findByEmployee_EmployeeId(senderId).orElseThrow(); senderWallet.addPoints(100); walletRepository.save(senderWallet); + + // 이전 테스트 이벤트가 섞이지 않도록 초기화 + eventListener.getEvents().clear(); } @Test + @Commit // 트랜잭션 커밋 후 발행(@TransactionalEventListener AFTER_COMMIT)도 검증 가능하게 @DisplayName("[Integration] Gifting points should publish a RecognitionEvent") void giftPoints_ShouldPublishEvent() { // Given @@ -58,9 +95,10 @@ void giftPoints_ShouldPublishEvent() { giftPointService.giftPointsToColleague("sender@test.com", request); // Then - long eventCount = applicationEvents.stream(RecognitionEvent.class) - .filter(event -> event.getReceiverId().equals(receiverId) && event.getPoints() == 50) + long eventCount = eventListener.getEvents().stream() + .filter(e -> e.getReceiverId().equals(receiverId) && e.getPoints() == 50) .count(); + assertThat(eventCount).isEqualTo(1); } -} \ No newline at end of file +} From 62e5b19a0ae2d88d5597a713dffac98485a1ab89 Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Wed, 13 Aug 2025 00:08:19 +0900 Subject: [PATCH 095/135] =?UTF-8?q?feat=20:=20frontend=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EC=82=AC=ED=95=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 13 +++-- .../controller/StatisticsController.java | 32 ++++++++++++ .../backend/dto/PointStatisticsResponse.java | 17 +++++++ .../backend/entity/enums/Category.java | 12 ++--- .../RewardPointTransactionRepository.java | 3 ++ .../backend/service/StatisticsService.java | 49 +++++++++++++++++++ 6 files changed, 114 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/joycrew/backend/controller/StatisticsController.java create mode 100644 src/main/java/com/joycrew/backend/dto/PointStatisticsResponse.java create mode 100644 src/main/java/com/joycrew/backend/service/StatisticsService.java diff --git a/Dockerfile b/Dockerfile index acf975a..a945ce2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,20 +25,23 @@ WORKDIR /app # Create a dedicated, non-root user and group for enhanced security. RUN addgroup -S joycrew && adduser -S joycrew -G joycrew -USER joycrew + +# Create log directory and set permissions +RUN mkdir -p /var/log/joycrew && chown -R joycrew:joycrew /var/log/joycrew # Copy the executable .jar file from the builder stage. COPY --from=builder /workspace/build/libs/app.jar . +# Change ownership to joycrew user +RUN chown joycrew:joycrew app.jar + +USER joycrew + # Set the active Spring profile to 'prod' ENV SPRING_PROFILES_ACTIVE=prod # Document the port that the container exposes at runtime EXPOSE 8082 -# Health check to ensure the application is running and healthy. -HEALTHCHECK --interval=30s --timeout=10s --retries=3 \ - CMD curl -f http://localhost:8082/actuator/health || exit 1 - # The command to run when the container starts ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/controller/StatisticsController.java b/src/main/java/com/joycrew/backend/controller/StatisticsController.java new file mode 100644 index 0000000..d785dc8 --- /dev/null +++ b/src/main/java/com/joycrew/backend/controller/StatisticsController.java @@ -0,0 +1,32 @@ +package com.joycrew.backend.controller; + +import com.joycrew.backend.dto.PointStatisticsResponse; +import com.joycrew.backend.security.UserPrincipal; +import com.joycrew.backend.service.StatisticsService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Statistics", description = "포인트 및 활동 통계 API") +@RestController +@RequestMapping("/api/statistics") +@RequiredArgsConstructor +public class StatisticsController { + + private final StatisticsService statisticsService; + + @Operation(summary = "내 포인트 통계 조회", description = "로그인된 사용자의 주고받은 포인트 및 태그 통계를 조회합니다.", security = @SecurityRequirement(name = "Authorization")) + @GetMapping("/me") + public ResponseEntity getMyStatistics( + @AuthenticationPrincipal UserPrincipal principal + ) { + PointStatisticsResponse stats = statisticsService.getPointStatistics(principal.getUsername()); + return ResponseEntity.ok(stats); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/PointStatisticsResponse.java b/src/main/java/com/joycrew/backend/dto/PointStatisticsResponse.java new file mode 100644 index 0000000..3cc26bc --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/PointStatisticsResponse.java @@ -0,0 +1,17 @@ +package com.joycrew.backend.dto; + +import com.joycrew.backend.entity.enums.Tag; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.Map; + +@Schema(description = "포인트 및 태그 통계 응답 DTO") +public record PointStatisticsResponse( + @Schema(description = "총 받은 포인트 합계") + Integer totalPointsReceived, + + @Schema(description = "총 보낸 포인트 합계") + Integer totalPointsSent, + + @Schema(description = "받은 태그 종류 및 횟수") + Map tagStatistics +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/enums/Category.java b/src/main/java/com/joycrew/backend/entity/enums/Category.java index 0d78a5d..f795caf 100644 --- a/src/main/java/com/joycrew/backend/entity/enums/Category.java +++ b/src/main/java/com/joycrew/backend/entity/enums/Category.java @@ -7,12 +7,10 @@ @RequiredArgsConstructor public enum Category { BEAUTY("뷰티"), - HOUSEHOLD("생활용품"), - HOME_INTERIOR("홈인테리어"), - STATIONERY_OFFICE("문구/오피스"), - WOMEN_CLOTHING("여성복"), - MEN_CLOTHING("남성복"), - HEALTH_SUPPLEMENTS("헬스/건강식품"); + APPLIANCES("가전"), + FURNITURE("가구"), + CLOTHING("옷"), + FOOD("음식"); private final String kr; // 실제 쿠팡 검색에 사용할 한글 키워드 -} +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/repository/RewardPointTransactionRepository.java b/src/main/java/com/joycrew/backend/repository/RewardPointTransactionRepository.java index 0a304e1..e91b3e0 100644 --- a/src/main/java/com/joycrew/backend/repository/RewardPointTransactionRepository.java +++ b/src/main/java/com/joycrew/backend/repository/RewardPointTransactionRepository.java @@ -12,4 +12,7 @@ public interface RewardPointTransactionRepository extends JpaRepository findBySenderOrReceiverOrderByTransactionDateDesc(Employee sender, Employee receiver); List findAllByOrderByTransactionDateDesc(); + + @EntityGraph(attributePaths = {"sender", "receiver"}) + List findBySenderOrReceiver(Employee sender, Employee receiver); } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/StatisticsService.java b/src/main/java/com/joycrew/backend/service/StatisticsService.java new file mode 100644 index 0000000..3296a59 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/StatisticsService.java @@ -0,0 +1,49 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.PointStatisticsResponse; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.RewardPointTransaction; +import com.joycrew.backend.entity.enums.Tag; +import com.joycrew.backend.exception.UserNotFoundException; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.RewardPointTransactionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class StatisticsService { + + private final EmployeeRepository employeeRepository; + private final RewardPointTransactionRepository transactionRepository; + + public PointStatisticsResponse getPointStatistics(String userEmail) { + Employee user = employeeRepository.findByEmail(userEmail) + .orElseThrow(() -> new UserNotFoundException("User not found with email: " + userEmail)); + + List transactions = transactionRepository.findBySenderOrReceiver(user, user); + + int totalReceived = transactions.stream() + .filter(tx -> user.equals(tx.getReceiver())) + .mapToInt(RewardPointTransaction::getPointAmount) + .sum(); + + int totalSent = transactions.stream() + .filter(tx -> user.equals(tx.getSender())) + .mapToInt(RewardPointTransaction::getPointAmount) + .sum(); + + Map tagStats = transactions.stream() + .filter(tx -> user.equals(tx.getReceiver()) && tx.getTags() != null) + .flatMap(tx -> tx.getTags().stream()) + .collect(Collectors.groupingBy(tag -> tag, Collectors.counting())); + + return new PointStatisticsResponse(totalReceived, totalSent, tagStats); + } +} \ No newline at end of file From dbd3e796faa6586351e67f5f2409c8469ddfaea8 Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Thu, 14 Aug 2025 18:55:23 +0900 Subject: [PATCH 096/135] =?UTF-8?q?release=20:=20=EB=B0=9C=ED=91=9C?= =?UTF-8?q?=EC=9A=A9=20=EB=B0=B0=ED=8F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- k8s/deployment.yml | 4 +- .../backend/JoyCrewBackendApplication.java | 22 +- .../joycrew/backend/config/AsyncConfig.java | 22 +- .../com/joycrew/backend/config/AwsConfig.java | 20 +- .../backend/config/SecurityConfig.java | 185 ++++++------- .../joycrew/backend/config/SwaggerConfig.java | 34 +-- .../controller/AdminEmployeeController.java | 198 +++++++------- .../backend/controller/AuthController.java | 62 ++--- .../controller/EmployeeQueryController.java | 60 ++--- .../controller/GiftPointController.java | 22 +- .../controller/HealthCheckController.java | 8 +- .../backend/controller/OrderController.java | 176 ++++++------- .../backend/controller/ProductController.java | 231 +++++----------- .../RecentProductViewController.java | 48 ++-- .../controller/StatisticsController.java | 20 +- .../TransactionHistoryController.java | 16 +- .../backend/controller/UserController.java | 77 +++--- .../backend/controller/WalletController.java | 18 +- .../dto/AdminEmployeeQueryResponse.java | 43 +-- .../dto/AdminEmployeeUpdateRequest.java | 10 +- .../dto/AdminPagedEmployeeResponse.java | 16 +- .../backend/dto/AdminPointBudgetResponse.java | 12 +- .../dto/AdminPointDistributionRequest.java | 28 +- .../backend/dto/CreateOrderRequest.java | 8 +- .../backend/dto/EmployeeQueryResponse.java | 20 +- .../dto/EmployeeRegistrationRequest.java | 40 +-- .../EmployeeRegistrationSuccessResponse.java | 4 +- .../joycrew/backend/dto/ErrorResponse.java | 16 +- .../joycrew/backend/dto/GiftPointRequest.java | 28 +- .../com/joycrew/backend/dto/LoginRequest.java | 14 +- .../joycrew/backend/dto/LoginResponse.java | 38 +-- .../joycrew/backend/dto/OrderResponse.java | 83 +++--- .../backend/dto/PagedEmployeeResponse.java | 16 +- .../backend/dto/PagedOrderResponse.java | 28 +- .../backend/dto/PagedProductResponse.java | 58 ++-- .../backend/dto/PasswordChangeRequest.java | 8 +- .../dto/PasswordResetConfirmRequest.java | 16 +- .../backend/dto/PasswordResetRequest.java | 8 +- .../backend/dto/PasswordVerifyRequest.java | 11 + .../backend/dto/PointBalanceResponse.java | 4 +- .../backend/dto/PointDistributionDetail.java | 10 +- .../backend/dto/PointStatisticsResponse.java | 21 +- .../joycrew/backend/dto/ProductResponse.java | 62 ++--- .../dto/RecentViewedProductResponse.java | 14 +- .../joycrew/backend/dto/SuccessResponse.java | 10 +- .../dto/TransactionHistoryResponse.java | 21 +- .../backend/dto/UserProfileResponse.java | 25 +- .../backend/dto/UserProfileUpdateRequest.java | 24 +- .../com/joycrew/backend/entity/Company.java | 104 ++++---- .../backend/entity/CompanyAdminAccess.java | 80 +++--- .../joycrew/backend/entity/Department.java | 92 +++---- .../com/joycrew/backend/entity/Employee.java | 247 +++++++++--------- .../com/joycrew/backend/entity/Order.java | 66 ++--- .../com/joycrew/backend/entity/Product.java | 74 +++--- .../backend/entity/RecentProductView.java | 44 ++-- .../entity/RewardPointTransaction.java | 60 ++--- .../com/joycrew/backend/entity/Wallet.java | 119 +++++---- .../backend/entity/enums/AccessStatus.java | 6 +- .../backend/entity/enums/AdminLevel.java | 8 +- .../backend/entity/enums/Category.java | 12 +- .../backend/entity/enums/OrderStatus.java | 8 +- .../com/joycrew/backend/entity/enums/Tag.java | 16 +- .../backend/entity/enums/TransactionType.java | 12 +- .../backend/event/NotificationListener.java | 26 +- .../backend/event/RecognitionEvent.java | 22 +- .../exception/GlobalExceptionHandler.java | 43 +-- .../InsufficientPointsException.java | 6 +- .../exception/UserNotFoundException.java | 6 +- .../backend/repository/CompanyRepository.java | 2 +- .../repository/DepartmentRepository.java | 4 +- .../repository/EmployeeQueryRepository.java | 70 ++++- .../repository/EmployeeRepository.java | 13 +- .../backend/repository/OrderRepository.java | 4 +- .../backend/repository/ProductRepository.java | 28 +- .../RecentProductViewRepository.java | 10 +- .../RewardPointTransactionRepository.java | 10 +- .../backend/repository/WalletRepository.java | 2 +- .../scheduler/RecentViewCleanupJob.java | 20 +- .../security/EmployeeDetailsService.java | 16 +- .../security/JwtAuthenticationFilter.java | 66 ++--- .../com/joycrew/backend/security/JwtUtil.java | 70 ++--- .../backend/security/UserPrincipal.java | 80 +++--- .../service/AdminDashboardService.java | 50 ++-- .../backend/service/AdminPointService.java | 88 +++---- .../joycrew/backend/service/AuthService.java | 155 +++++------ .../joycrew/backend/service/EmailService.java | 34 +-- .../service/EmployeeManagementService.java | 146 +++++------ .../backend/service/EmployeeQueryService.java | 86 +++--- .../service/EmployeeRegistrationService.java | 195 +++++++------- .../backend/service/EmployeeService.java | 90 ++++--- .../backend/service/GiftPointService.java | 74 +++--- .../joycrew/backend/service/OrderService.java | 194 ++++++++------ .../backend/service/ProductQueryService.java | 69 +++++ .../backend/service/ProductService.java | 41 --- .../service/RecentProductViewService.java | 84 +++--- .../backend/service/S3FileStorageService.java | 66 ++--- .../backend/service/StatisticsService.java | 72 +++-- .../service/TransactionHistoryService.java | 87 +++--- .../backend/service/WalletService.java | 20 +- .../service/mapper/EmployeeMapper.java | 92 +++---- src/main/resources/application-dev.yml | 4 +- src/main/resources/application-prod.yml | 9 + src/main/resources/application.yml | 9 +- .../JoyCrewBackendApplicationTests.java | 6 +- .../config/TestUserDetailsService.java | 50 ++-- .../AdminEmployeeControllerTest.java | 16 +- .../controller/AuthControllerTest.java | 148 +++++------ .../EmployeeQueryControllerTest.java | 52 ++-- .../controller/GiftPointControllerTest.java | 49 ++-- .../controller/ProductControllerTest.java | 74 ++++++ .../controller/StatisticsControllerTest.java | 62 +++++ .../TransactionHistoryControllerTest.java | 50 ++-- .../controller/UserControllerTest.java | 117 +++++---- .../controller/WalletControllerTest.java | 40 +-- .../security/WithMockUserPrincipal.java | 9 +- ...ckUserPrincipalSecurityContextFactory.java | 34 +-- .../service/AdminFeaturesIntegrationTest.java | 159 +++++------ .../service/AuthServiceIntegrationTest.java | 102 ++++---- .../backend/service/AuthServiceTest.java | 170 ++++++------ .../backend/service/EmailServiceTest.java | 62 ++--- .../service/EmployeeQueryServiceTest.java | 87 +++--- .../EmployeeServiceIntegrationTest.java | 94 +++---- .../backend/service/EmployeeServiceTest.java | 164 ++++++------ .../GiftPointServiceIntegrationTest.java | 148 ++++++----- .../backend/service/GiftPointServiceTest.java | 90 +++---- .../TransactionHistoryServiceTest.java | 80 +++--- .../backend/service/WalletServiceTest.java | 74 +++--- 128 files changed, 3680 insertions(+), 3290 deletions(-) create mode 100644 src/main/java/com/joycrew/backend/dto/PasswordVerifyRequest.java create mode 100644 src/main/java/com/joycrew/backend/service/ProductQueryService.java delete mode 100644 src/main/java/com/joycrew/backend/service/ProductService.java create mode 100644 src/test/java/com/joycrew/backend/controller/ProductControllerTest.java create mode 100644 src/test/java/com/joycrew/backend/controller/StatisticsControllerTest.java diff --git a/.gitignore b/.gitignore index d05d99e..ee9a037 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,7 @@ bin/ # Logs & Temporary Files # # Log files, temporary files, and local databases. -# =================================================================== +# ================================================================== *.log *.log.* *.class @@ -53,6 +53,7 @@ bin/ *.rar *.tar *.gz +*.sql *.sqlite *.db hs_err_pid*.log diff --git a/k8s/deployment.yml b/k8s/deployment.yml index cbf9907..bc6551a 100644 --- a/k8s/deployment.yml +++ b/k8s/deployment.yml @@ -27,8 +27,8 @@ spec: # Define resource requests and limits for better scheduling and stability. resources: requests: - memory: "256Mi" - cpu: "250m" + memory: "128Mi" + cpu: "150m" limits: memory: "1Gi" cpu: "500m" diff --git a/src/main/java/com/joycrew/backend/JoyCrewBackendApplication.java b/src/main/java/com/joycrew/backend/JoyCrewBackendApplication.java index c73acf2..295c096 100644 --- a/src/main/java/com/joycrew/backend/JoyCrewBackendApplication.java +++ b/src/main/java/com/joycrew/backend/JoyCrewBackendApplication.java @@ -11,16 +11,16 @@ @EnableScheduling public class JoyCrewBackendApplication { - public static void main(String[] args) { - SpringApplication.run(JoyCrewBackendApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(JoyCrewBackendApplication.class, args); + } - /** - * Set the default timezone for the application to Korea Standard Time (KST). - * This ensures all date and time operations are based on KST. - */ - @PostConstruct - public void init() { - TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); - } + /** + * Set the default timezone for the application to Korea Standard Time (KST). + * This ensures all date and time operations are based on KST. + */ + @PostConstruct + public void init() { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } } diff --git a/src/main/java/com/joycrew/backend/config/AsyncConfig.java b/src/main/java/com/joycrew/backend/config/AsyncConfig.java index c479487..3861565 100644 --- a/src/main/java/com/joycrew/backend/config/AsyncConfig.java +++ b/src/main/java/com/joycrew/backend/config/AsyncConfig.java @@ -11,14 +11,14 @@ @EnableAsync public class AsyncConfig { - @Bean(name = "taskExecutor") - public Executor taskExecutor() { - ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(5); - executor.setMaxPoolSize(10); - executor.setQueueCapacity(25); - executor.setThreadNamePrefix("Async-"); - executor.initialize(); - return executor; - } -} \ No newline at end of file + @Bean(name = "taskExecutor") + public Executor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(10); + executor.setQueueCapacity(25); + executor.setThreadNamePrefix("Async-"); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/com/joycrew/backend/config/AwsConfig.java b/src/main/java/com/joycrew/backend/config/AwsConfig.java index 170d54d..ae9e55a 100644 --- a/src/main/java/com/joycrew/backend/config/AwsConfig.java +++ b/src/main/java/com/joycrew/backend/config/AwsConfig.java @@ -10,14 +10,14 @@ @Configuration public class AwsConfig { - @Value("${aws.region}") - private String region; + @Value("${aws.region}") + private String region; - @Bean - public S3Client s3Client() { - return S3Client.builder() - .region(Region.of(region)) - .credentialsProvider(DefaultCredentialsProvider.create()) - .build(); - } -} \ No newline at end of file + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(DefaultCredentialsProvider.create()) + .build(); + } +} diff --git a/src/main/java/com/joycrew/backend/config/SecurityConfig.java b/src/main/java/com/joycrew/backend/config/SecurityConfig.java index d642745..c5abad7 100644 --- a/src/main/java/com/joycrew/backend/config/SecurityConfig.java +++ b/src/main/java/com/joycrew/backend/config/SecurityConfig.java @@ -1,29 +1,29 @@ package com.joycrew.backend.config; -import org.springframework.http.HttpMethod; import com.fasterxml.jackson.databind.ObjectMapper; import com.joycrew.backend.dto.ErrorResponse; import com.joycrew.backend.entity.enums.AdminLevel; +import com.joycrew.backend.security.EmployeeDetailsService; import com.joycrew.backend.security.JwtAuthenticationFilter; import com.joycrew.backend.security.JwtUtil; -import com.joycrew.backend.security.EmployeeDetailsService; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.web.SecurityFilterChain; 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.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.CorsFilter; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.ProviderManager; -import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import java.time.LocalDateTime; @@ -32,95 +32,96 @@ @RequiredArgsConstructor public class SecurityConfig { - private final JwtUtil jwtUtil; - private final EmployeeDetailsService employeeDetailsService; - private final ObjectMapper objectMapper; - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .cors(cors -> {}) - .csrf(csrf -> csrf.disable()) - .headers(headers -> headers - .frameOptions(frameOptions -> frameOptions.disable())) - .authorizeHttpRequests(auth -> auth - .requestMatchers( - "/", - "/h2-console/**", - "/api/auth/login", + private final JwtUtil jwtUtil; + private final EmployeeDetailsService employeeDetailsService; + private final ObjectMapper objectMapper; - "/api/auth/password-reset/request", - "/api/auth/password-reset/confirm", - "/v3/api-docs/**", - "/swagger-ui/**", - "/swagger-ui.html" - ).permitAll() - .requestMatchers(HttpMethod.GET, "/api/crawl/**").permitAll() - .requestMatchers("/api/admin/**").hasRole(AdminLevel.SUPER_ADMIN.name()) - .anyRequest().authenticated() - ) - .exceptionHandling(exceptions -> exceptions - .authenticationEntryPoint((request, response, authException) -> { - response.setStatus(HttpStatus.UNAUTHORIZED.value()); - response.setContentType("application/json;charset=UTF-8"); - String jsonResponse = objectMapper.writeValueAsString( - new ErrorResponse( - "UNAUTHENTICATED", - "Authentication is required. Please log in.", - LocalDateTime.now(), - request.getRequestURI() - ) - ); - response.getWriter().write(jsonResponse); - }) - .accessDeniedHandler((request, response, accessDeniedException) -> { - response.setStatus(HttpStatus.FORBIDDEN.value()); - response.setContentType("application/json;charset=UTF-8"); - String jsonResponse = objectMapper.writeValueAsString( - new ErrorResponse( - "ACCESS_DENIED", - "You do not have permission to access this resource.", - LocalDateTime.now(), - request.getRequestURI() - ) - ); - response.getWriter().write(jsonResponse); - }) - ) - .sessionManagement(session -> session - .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, employeeDetailsService), - UsernamePasswordAuthenticationFilter.class); + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .cors(cors -> {}) + .csrf(csrf -> csrf.disable()) + .headers(headers -> headers + .frameOptions(frameOptions -> frameOptions.disable())) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/", + "/h2-console/**", + "/api/auth/login", + "/api/auth/password-reset/request", + "/api/auth/password-reset/confirm", + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html" + ).permitAll() + .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/crawl/**").permitAll() + .requestMatchers("/api/admin/**").hasRole(AdminLevel.SUPER_ADMIN.name()) + .anyRequest().authenticated() + ) + .exceptionHandling(exceptions -> exceptions + .authenticationEntryPoint((request, response, authException) -> { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType("application/json;charset=UTF-8"); + String jsonResponse = objectMapper.writeValueAsString( + new ErrorResponse( + "UNAUTHENTICATED", + "Authentication is required. Please log in.", + LocalDateTime.now(), + request.getRequestURI() + ) + ); + response.getWriter().write(jsonResponse); + }) + .accessDeniedHandler((request, response, accessDeniedException) -> { + response.setStatus(HttpStatus.FORBIDDEN.value()); + response.setContentType("application/json;charset=UTF-8"); + String jsonResponse = objectMapper.writeValueAsString( + new ErrorResponse( + "ACCESS_DENIED", + "You do not have permission to access this resource.", + LocalDateTime.now(), + request.getRequestURI() + ) + ); + response.getWriter().write(jsonResponse); + }) + ) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, employeeDetailsService), + UsernamePasswordAuthenticationFilter.class); - return http.build(); - } + return http.build(); + } - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } - @Bean - public CorsFilter corsFilter() { - CorsConfiguration config = new CorsConfiguration(); - config.addAllowedOriginPattern("http://localhost:3000"); - config.addAllowedOriginPattern("http://localhost:5173"); - config.addAllowedOriginPattern("https://www.joycrew.co.kr"); - config.addAllowedOriginPattern("https://joycrew.co.kr"); - config.addAllowedHeader("*"); - config.addAllowedMethod("*"); - config.setAllowCredentials(true); + @Bean + public CorsFilter corsFilter() { + CorsConfiguration config = new CorsConfiguration(); + config.addAllowedOriginPattern("http://localhost:3000"); + config.addAllowedOriginPattern("http://localhost:5173"); + config.addAllowedOriginPattern("https://www.joycrew.co.kr"); + config.addAllowedOriginPattern("https://joycrew.co.kr"); + config.addAllowedOriginPattern("http://localhost:8082"); + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + config.setAllowCredentials(true); - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", config); - return new CorsFilter(source); - } + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return new CorsFilter(source); + } - @Bean - public AuthenticationManager authenticationManager(PasswordEncoder passwordEncoder) { - DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider(); - authenticationProvider.setUserDetailsService(employeeDetailsService); - authenticationProvider.setPasswordEncoder(passwordEncoder); - return new ProviderManager(authenticationProvider); - } -} \ No newline at end of file + @Bean + public AuthenticationManager authenticationManager(PasswordEncoder passwordEncoder) { + DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider(); + authenticationProvider.setUserDetailsService(employeeDetailsService); + authenticationProvider.setPasswordEncoder(passwordEncoder); + return new ProviderManager(authenticationProvider); + } +} diff --git a/src/main/java/com/joycrew/backend/config/SwaggerConfig.java b/src/main/java/com/joycrew/backend/config/SwaggerConfig.java index a81d782..144d0a1 100644 --- a/src/main/java/com/joycrew/backend/config/SwaggerConfig.java +++ b/src/main/java/com/joycrew/backend/config/SwaggerConfig.java @@ -11,21 +11,21 @@ @Configuration public class SwaggerConfig { - @Bean - public OpenAPI openAPI() { - final String securitySchemeName = "Authorization"; + @Bean + public OpenAPI openAPI() { + final String securitySchemeName = "Authorization"; - return new OpenAPI() - .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) - .components(new Components().addSecuritySchemes(securitySchemeName, - new SecurityScheme() - .name(securitySchemeName) - .type(SecurityScheme.Type.HTTP) - .scheme("bearer") - .bearerFormat("JWT"))) - .info(new Info() - .title("JoyCrew API") - .version("v1.0.0") - .description("JoyCrew Backend API Specification.")); - } -} \ No newline at end of file + return new OpenAPI() + .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) + .components(new Components().addSecuritySchemes(securitySchemeName, + new SecurityScheme() + .name(securitySchemeName) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))) + .info(new Info() + .title("JoyCrew API") + .version("v1.0.0") + .description("JoyCrew Backend API Specification.")); + } +} diff --git a/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java b/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java index d620a76..a29884c 100644 --- a/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java +++ b/src/main/java/com/joycrew/backend/controller/AdminEmployeeController.java @@ -26,115 +26,115 @@ @RequiredArgsConstructor public class AdminEmployeeController { - private final EmployeeRegistrationService registrationService; - private final EmployeeManagementService managementService; - private final AdminPointService pointService; - private final AdminDashboardService adminDashboardService; + private final EmployeeRegistrationService registrationService; + private final EmployeeManagementService managementService; + private final AdminPointService pointService; + private final AdminDashboardService adminDashboardService; - @Operation( - summary = "Register a single employee", - description = "An HR administrator registers a single employee.", - security = @SecurityRequirement(name = "Authorization") - ) - @PostMapping - public ResponseEntity registerEmployee( - @Valid @RequestBody EmployeeRegistrationRequest request - ) { - var created = registrationService.registerEmployee(request); - return ResponseEntity.ok( - new EmployeeRegistrationSuccessResponse("Employee created successfully (ID: " + created.getEmployeeId() + ")") - ); - } + @Operation( + summary = "Register a single employee", + description = "An HR administrator registers a single employee.", + security = @SecurityRequirement(name = "Authorization") + ) + @PostMapping + public ResponseEntity registerEmployee( + @Valid @RequestBody EmployeeRegistrationRequest request + ) { + var created = registrationService.registerEmployee(request); + return ResponseEntity.ok( + new EmployeeRegistrationSuccessResponse("Employee created successfully (ID: " + created.getEmployeeId() + ")") + ); + } - @Operation( - summary = "Bulk register employees via CSV", - description = """ - An HR administrator uploads a CSV file to register multiple employees. - The CSV must include the following headers: - name,email,initialPassword,companyName,departmentName,position,role,birthday,address,hireDate - """, - security = @SecurityRequirement(name = "Authorization") - ) - @PostMapping(value = "/bulk", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity registerEmployeesFromCsv( - @Parameter(description = "CSV file for upload", required = true) - @RequestParam("file") MultipartFile file - ) { - registrationService.registerEmployeesFromCsv(file); - return ResponseEntity.ok(new SuccessResponse("CSV processed and employee registration completed.")); - } + @Operation( + summary = "Bulk register employees via CSV", + description = """ + An HR administrator uploads a CSV file to register multiple employees. + The CSV must include the following headers: + name,email,initialPassword,companyName,departmentName,position,role,birthday,address,hireDate + """, + security = @SecurityRequirement(name = "Authorization") + ) + @PostMapping(value = "/bulk", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity registerEmployeesFromCsv( + @Parameter(description = "CSV file for upload", required = true) + @RequestParam("file") MultipartFile file + ) { + registrationService.registerEmployeesFromCsv(file); + return ResponseEntity.ok(new SuccessResponse("CSV processed and employee registration completed.")); + } - @Operation( - summary = "Search all employees (with filtering)", - description = "An HR administrator can search the employee list by name, email, or department." - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "Employee list retrieved successfully.", content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = AdminPagedEmployeeResponse.class), - examples = @ExampleObject(value = """ + @Operation( + summary = "Search all employees (with filtering)", + description = "An HR administrator can search the employee list by name, email, or department." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Employee list retrieved successfully.", content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = AdminPagedEmployeeResponse.class), + examples = @ExampleObject(value = """ { - "employees": [ + \"employees\": [ { - "employeeId": 1, - "employeeName": "Jane Doe", - "email": "jane.doe@example.com", - "departmentName": "HR", - "position": "Specialist", - "profileImageUrl": "https://cdn.joycrew.com/profile/1.jpg", - "adminLevel": "EMPLOYEE", - "birthday": "1995-05-10", - "address": "123 Teheran-ro, Gangnam-gu, Seoul", - "hireDate": "2023-01-10" + \"employeeId\": 1, + \"employeeName\": \"Jane Doe\", + \"email\": \"jane.doe@example.com\", + \"departmentName\": \"HR\", + \"position\": \"Specialist\", + \"profileImageUrl\": \"https://cdn.joycrew.com/profile/1.jpg\", + \"adminLevel\": \"EMPLOYEE\", + \"birthday\": \"1995-05-10\", + \"address\": \"123 Teheran-ro, Gangnam-gu, Seoul\", + \"hireDate\": \"2023-01-10\" } ], - "currentPage": 0, - "totalPages": 1, - "last": true + \"currentPage\": 0, + \"totalPages\": 1, + \"last\": true } """) - )) - }) - @GetMapping - public ResponseEntity searchEmployees( - @Parameter(description = "Search keyword for name, email, or department") @RequestParam(required = false) String keyword, - @Parameter(description = "Page number (0-based)", example = "0") @RequestParam(defaultValue = "0") int page, - @Parameter(description = "Number of employees per page", example = "10") @RequestParam(defaultValue = "10") int size - ) { - AdminPagedEmployeeResponse result = managementService.searchEmployees(keyword, page, size); - return ResponseEntity.ok(result); - } + )) + }) + @GetMapping + public ResponseEntity searchEmployees( + @Parameter(description = "Search keyword for name, email, or department") @RequestParam(required = false) String keyword, + @Parameter(description = "Page number (0-based)", example = "0") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "Number of employees per page", example = "10") @RequestParam(defaultValue = "10") int size + ) { + AdminPagedEmployeeResponse result = managementService.searchEmployees(keyword, page, size); + return ResponseEntity.ok(result); + } - @Operation(summary = "Update employee information", security = @SecurityRequirement(name = "Authorization")) - @PatchMapping("/{employeeId}") - public ResponseEntity updateEmployee( - @PathVariable Long employeeId, - @RequestBody AdminEmployeeUpdateRequest request) { - managementService.updateEmployee(employeeId, request); - return ResponseEntity.ok(new SuccessResponse("Employee information updated successfully.")); - } + @Operation(summary = "Update employee information", security = @SecurityRequirement(name = "Authorization")) + @PatchMapping("/{employeeId}") + public ResponseEntity updateEmployee( + @PathVariable Long employeeId, + @RequestBody AdminEmployeeUpdateRequest request) { + managementService.updateEmployee(employeeId, request); + return ResponseEntity.ok(new SuccessResponse("Employee information updated successfully.")); + } - @Operation(summary = "Deactivate an employee (soft delete)", security = @SecurityRequirement(name = "Authorization")) - @DeleteMapping("/{employeeId}") - public ResponseEntity deleteEmployee(@PathVariable Long employeeId) { - managementService.deactivateEmployee(employeeId); - return ResponseEntity.ok(new SuccessResponse("Employee successfully deactivated.")); - } + @Operation(summary = "Deactivate an employee (soft delete)", security = @SecurityRequirement(name = "Authorization")) + @DeleteMapping("/{employeeId}") + public ResponseEntity deleteEmployee(@PathVariable Long employeeId) { + managementService.deactivateEmployee(employeeId); + return ResponseEntity.ok(new SuccessResponse("Employee successfully deactivated.")); + } - @Operation(summary = "Distribute or revoke points in bulk", description = "Use a positive value for 'points' to distribute, and a negative value to revoke.", security = @SecurityRequirement(name = "Authorization")) - @PostMapping("/points/distribute") - public ResponseEntity distributePoints( - @Valid @RequestBody AdminPointDistributionRequest request, - @AuthenticationPrincipal UserPrincipal principal) { - pointService.distributePoints(request, principal.getEmployee()); - return ResponseEntity.ok(new SuccessResponse("Point distribution process completed successfully.")); - } + @Operation(summary = "Distribute or revoke points in bulk", description = "Use a positive value for 'points' to distribute, and a negative value to revoke.", security = @SecurityRequirement(name = "Authorization")) + @PostMapping("/points/distribute") + public ResponseEntity distributePoints( + @Valid @RequestBody AdminPointDistributionRequest request, + @AuthenticationPrincipal UserPrincipal principal) { + pointService.distributePoints(request, principal.getEmployee()); + return ResponseEntity.ok(new SuccessResponse("Point distribution process completed successfully.")); + } - @Operation(summary = "Get admin's personal and company point balance", description = "Gets the admin's personal wallet balance and the total budget of the company they belong to.", security = @SecurityRequirement(name = "Authorization")) - @GetMapping("/points/balance") - public ResponseEntity getAdminAndCompanyBalance( - @AuthenticationPrincipal UserPrincipal principal) { - AdminPointBudgetResponse response = adminDashboardService.getAdminAndCompanyBalance(principal.getUsername()); - return ResponseEntity.ok(response); - } -} \ No newline at end of file + @Operation(summary = "Get admin's personal and company point balance", description = "Gets the admin's personal wallet balance and the total budget of the company they belong to.", security = @SecurityRequirement(name = "Authorization")) + @GetMapping("/points/balance") + public ResponseEntity getAdminAndCompanyBalance( + @AuthenticationPrincipal UserPrincipal principal) { + AdminPointBudgetResponse response = adminDashboardService.getAdminAndCompanyBalance(principal.getUsername()); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/joycrew/backend/controller/AuthController.java b/src/main/java/com/joycrew/backend/controller/AuthController.java index aa3a6cc..2650947 100644 --- a/src/main/java/com/joycrew/backend/controller/AuthController.java +++ b/src/main/java/com/joycrew/backend/controller/AuthController.java @@ -17,38 +17,38 @@ @RequiredArgsConstructor public class AuthController { - private final AuthService authService; + private final AuthService authService; - @Operation(summary = "Login") - @ApiResponse(responseCode = "200", description = "Login successful") - @ApiResponse(responseCode = "401", description = "Authentication failed") - @PostMapping("/login") - public ResponseEntity login(@RequestBody @Valid LoginRequest request) { - LoginResponse loginResponse = authService.login(request); - return ResponseEntity.ok(loginResponse); - } + @Operation(summary = "Login") + @ApiResponse(responseCode = "200", description = "Login successful") + @ApiResponse(responseCode = "401", description = "Authentication failed") + @PostMapping("/login") + public ResponseEntity login(@RequestBody @Valid LoginRequest request) { + LoginResponse loginResponse = authService.login(request); + return ResponseEntity.ok(loginResponse); + } - @Operation(summary = "Logout") - @PostMapping("/logout") - public ResponseEntity logout(HttpServletRequest request) { - authService.logout(request); - return ResponseEntity.ok(new SuccessResponse("You have been logged out.")); - } + @Operation(summary = "Logout") + @PostMapping("/logout") + public ResponseEntity logout(HttpServletRequest request) { + authService.logout(request); + return ResponseEntity.ok(new SuccessResponse("You have been logged out.")); + } - @Operation(summary = "Request password reset (sends email)", description = "Sends a magic link to the user's email to reset the password.") - @ApiResponse(responseCode = "200", description = "The request was processed successfully (the response is the same regardless of whether the email exists).") - @PostMapping("/password-reset/request") - public ResponseEntity requestPasswordReset(@RequestBody @Valid PasswordResetRequest request) { - authService.requestPasswordReset(request.email()); - return ResponseEntity.ok(new SuccessResponse("A password reset email has been requested. Please check your email.")); - } + @Operation(summary = "Request password reset (sends email)", description = "Sends a magic link to the user's email to reset the password.") + @ApiResponse(responseCode = "200", description = "The request was processed successfully (the response is the same regardless of whether the email exists).") + @PostMapping("/password-reset/request") + public ResponseEntity requestPasswordReset(@RequestBody @Valid PasswordResetRequest request) { + authService.requestPasswordReset(request.email()); + return ResponseEntity.ok(new SuccessResponse("A password reset email has been requested. Please check your email.")); + } - @Operation(summary = "Confirm password reset", description = "Finalizes the password change using the token from the email and the new password.") - @ApiResponse(responseCode = "200", description = "Password changed successfully.") - @ApiResponse(responseCode = "400", description = "The token is invalid or has expired.") - @PostMapping("/password-reset/confirm") - public ResponseEntity confirmPasswordReset(@RequestBody @Valid PasswordResetConfirmRequest request) { - authService.confirmPasswordReset(request.token(), request.newPassword()); - return ResponseEntity.ok(new SuccessResponse("Password changed successfully.")); - } -} \ No newline at end of file + @Operation(summary = "Confirm password reset", description = "Finalizes the password change using the token from the email and the new password.") + @ApiResponse(responseCode = "200", description = "Password changed successfully.") + @ApiResponse(responseCode = "400", description = "The token is invalid or has expired.") + @PostMapping("/password-reset/confirm") + public ResponseEntity confirmPasswordReset(@RequestBody @Valid PasswordResetConfirmRequest request) { + authService.confirmPasswordReset(request.token(), request.newPassword()); + return ResponseEntity.ok(new SuccessResponse("Password changed successfully.")); + } +} diff --git a/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java b/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java index 3130822..2238b6c 100644 --- a/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java +++ b/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java @@ -20,35 +20,35 @@ @Tag(name = "Employee Query", description = "API for searching employees") public class EmployeeQueryController { - private final EmployeeQueryService employeeQueryService; + private final EmployeeQueryService employeeQueryService; - @Operation( - summary = "Search employee list", - description = "Performs a unified search by name, email, or department. The current user is excluded from the search results.", - parameters = { - @Parameter(name = "keyword", description = "Search keyword", example = "John"), - @Parameter(name = "page", description = "Page number (0-based)", example = "0"), - @Parameter(name = "size", description = "Items per page", example = "20") - }, - responses = { - @ApiResponse( - responseCode = "200", - description = "Employee list retrieved successfully", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = PagedEmployeeResponse.class) - ) - ) - } - ) - @GetMapping - public ResponseEntity searchEmployees( - @RequestParam(required = false) String keyword, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, - @AuthenticationPrincipal UserPrincipal principal - ) { - PagedEmployeeResponse response = employeeQueryService.getEmployees(keyword, page, size, principal.getEmployee().getEmployeeId()); - return ResponseEntity.ok(response); - } + @Operation( + summary = "Search employee list", + description = "Performs a unified search by name, email, or department. The current user is excluded from the search results.", + parameters = { + @Parameter(name = "keyword", description = "Search keyword", example = "John"), + @Parameter(name = "page", description = "Page number (0-based)", example = "0"), + @Parameter(name = "size", description = "Items per page", example = "20") + }, + responses = { + @ApiResponse( + responseCode = "200", + description = "Employee list retrieved successfully", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = PagedEmployeeResponse.class) + ) + ) + } + ) + @GetMapping + public ResponseEntity searchEmployees( + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @AuthenticationPrincipal UserPrincipal principal + ) { + PagedEmployeeResponse response = employeeQueryService.getEmployees(keyword, page, size, principal.getEmployee().getEmployeeId()); + return ResponseEntity.ok(response); + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/controller/GiftPointController.java b/src/main/java/com/joycrew/backend/controller/GiftPointController.java index 714e46c..8e61084 100644 --- a/src/main/java/com/joycrew/backend/controller/GiftPointController.java +++ b/src/main/java/com/joycrew/backend/controller/GiftPointController.java @@ -19,15 +19,15 @@ @RequiredArgsConstructor public class GiftPointController { - private final GiftPointService giftPointService; + private final GiftPointService giftPointService; - @Operation(summary = "Gift points to a colleague", security = @SecurityRequirement(name = "Authorization")) - @PostMapping - public ResponseEntity giftPoints( - @AuthenticationPrincipal UserPrincipal principal, - @Valid @RequestBody GiftPointRequest request - ) { - giftPointService.giftPointsToColleague(principal.getUsername(), request); - return ResponseEntity.ok(new SuccessResponse("Points sent successfully.")); - } -} \ No newline at end of file + @Operation(summary = "Gift points to a colleague", security = @SecurityRequirement(name = "Authorization")) + @PostMapping + public ResponseEntity giftPoints( + @AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody GiftPointRequest request + ) { + giftPointService.giftPointsToColleague(principal.getUsername(), request); + return ResponseEntity.ok(new SuccessResponse("Points sent successfully.")); + } +} diff --git a/src/main/java/com/joycrew/backend/controller/HealthCheckController.java b/src/main/java/com/joycrew/backend/controller/HealthCheckController.java index 2362dcb..dfe90f1 100644 --- a/src/main/java/com/joycrew/backend/controller/HealthCheckController.java +++ b/src/main/java/com/joycrew/backend/controller/HealthCheckController.java @@ -7,8 +7,8 @@ @RestController public class HealthCheckController { - @GetMapping("/") - public ResponseEntity healthCheck() { - return ResponseEntity.ok("JoyCrew Backend is running!"); - } + @GetMapping("/") + public ResponseEntity healthCheck() { + return ResponseEntity.ok("JoyCrew Backend is running!"); + } } diff --git a/src/main/java/com/joycrew/backend/controller/OrderController.java b/src/main/java/com/joycrew/backend/controller/OrderController.java index 8dcc8b3..688584b 100644 --- a/src/main/java/com/joycrew/backend/controller/OrderController.java +++ b/src/main/java/com/joycrew/backend/controller/OrderController.java @@ -24,19 +24,19 @@ @RequiredArgsConstructor public class OrderController { - private final OrderService orderService; + private final OrderService orderService; - @Operation( - summary = "Create an order", - description = "Creates an order for the current user and deducts points from the linked wallet.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "Order created successfully.", - content = @Content( - schema = @Schema(implementation = OrderResponse.class), - examples = @ExampleObject( - value = """ + @Operation( + summary = "Create an order", + description = "Creates an order for the current user and deducts points from the linked wallet.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Order created successfully.", + content = @Content( + schema = @Schema(implementation = OrderResponse.class), + examples = @ExampleObject( + value = """ { "orderId": 1001, "employeeId": 1, @@ -52,62 +52,62 @@ public class OrderController { "deliveredAt": null } """ - ) - ) - ) - } - ) - @PostMapping - public ResponseEntity createOrder( - @AuthenticationPrincipal UserPrincipal principal, - @RequestBody CreateOrderRequest request - ) { - Long employeeId = principal.getEmployee().getEmployeeId(); - return ResponseEntity.ok(orderService.createOrder(employeeId, request)); - } + ) + ) + ) + } + ) + @PostMapping + public ResponseEntity createOrder( + @AuthenticationPrincipal UserPrincipal principal, + @RequestBody CreateOrderRequest request + ) { + Long employeeId = principal.getEmployee().getEmployeeId(); + return ResponseEntity.ok(orderService.createOrder(employeeId, request)); + } - @Operation( - summary = "Get my orders (paged)", - description = "Retrieves the current user's own orders only.", - parameters = { - @Parameter(name = "page", description = "Page number (0-based)", example = "0"), - @Parameter(name = "size", description = "Items per page", example = "20") - } - ) - @GetMapping - public ResponseEntity getMyOrders( - @AuthenticationPrincipal UserPrincipal principal, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size - ) { - Long employeeId = principal.getEmployee().getEmployeeId(); - return ResponseEntity.ok(orderService.getMyOrders(employeeId, page, size)); - } + @Operation( + summary = "Get my orders (paged)", + description = "Retrieves the current user's own orders only.", + parameters = { + @Parameter(name = "page", description = "Page number (0-based)", example = "0"), + @Parameter(name = "size", description = "Items per page", example = "20") + } + ) + @GetMapping + public ResponseEntity getMyOrders( + @AuthenticationPrincipal UserPrincipal principal, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Long employeeId = principal.getEmployee().getEmployeeId(); + return ResponseEntity.ok(orderService.getMyOrders(employeeId, page, size)); + } - @Operation( - summary = "Get my order detail", - description = "Retrieves a specific order of the current user." - ) - @GetMapping("/{orderId}") - public ResponseEntity getMyOrderDetail( - @AuthenticationPrincipal UserPrincipal principal, - @PathVariable Long orderId - ) { - Long employeeId = principal.getEmployee().getEmployeeId(); - return ResponseEntity.ok(orderService.getMyOrderDetail(employeeId, orderId)); - } + @Operation( + summary = "Get my order detail", + description = "Retrieves a specific order of the current user." + ) + @GetMapping("/{orderId}") + public ResponseEntity getMyOrderDetail( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long orderId + ) { + Long employeeId = principal.getEmployee().getEmployeeId(); + return ResponseEntity.ok(orderService.getMyOrderDetail(employeeId, orderId)); + } - @Operation( - summary = "Cancel my order (only if not shipped)", - description = "Cancels the current user's order and refunds points if the order has not been shipped yet.", - responses = { - @ApiResponse( - responseCode = "200", - description = "Order canceled successfully.", - content = @Content( - schema = @Schema(implementation = OrderResponse.class), - examples = @ExampleObject( - value = """ + @Operation( + summary = "Cancel my order (only if not shipped)", + description = "Cancels the current user's order and refunds points if the order has not been shipped yet.", + responses = { + @ApiResponse( + responseCode = "200", + description = "Order canceled successfully.", + content = @Content( + schema = @Schema(implementation = OrderResponse.class), + examples = @ExampleObject( + value = """ { "orderId": 1001, "employeeId": 1, @@ -123,16 +123,16 @@ public ResponseEntity getMyOrderDetail( "deliveredAt": null } """ - ) - ) - ), - @ApiResponse( - responseCode = "400", - description = "Order cannot be canceled after it has been shipped.", - content = @Content( - schema = @Schema(implementation = ErrorResponse.class), - examples = @ExampleObject( - value = """ + ) + ) + ), + @ApiResponse( + responseCode = "400", + description = "Order cannot be canceled after it has been shipped.", + content = @Content( + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + value = """ { "code": "ORDER_CANNOT_CANCEL", "message": "Order cannot be canceled after it has been shipped.", @@ -140,17 +140,17 @@ public ResponseEntity getMyOrderDetail( "path": "/api/orders/1001/cancel" } """ - ) - ) - ) - } - ) - @PatchMapping("/{orderId}/cancel") - public ResponseEntity cancelMyOrder( - @AuthenticationPrincipal UserPrincipal principal, - @PathVariable Long orderId - ) { - Long employeeId = principal.getEmployee().getEmployeeId(); - return ResponseEntity.ok(orderService.cancelMyOrder(employeeId, orderId)); - } + ) + ) + ) + } + ) + @PatchMapping("/{orderId}/cancel") + public ResponseEntity cancelMyOrder( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long orderId + ) { + Long employeeId = principal.getEmployee().getEmployeeId(); + return ResponseEntity.ok(orderService.cancelMyOrder(employeeId, orderId)); + } } diff --git a/src/main/java/com/joycrew/backend/controller/ProductController.java b/src/main/java/com/joycrew/backend/controller/ProductController.java index 6ba02f5..f0fb1fc 100644 --- a/src/main/java/com/joycrew/backend/controller/ProductController.java +++ b/src/main/java/com/joycrew/backend/controller/ProductController.java @@ -1,16 +1,11 @@ package com.joycrew.backend.controller; -import com.joycrew.backend.dto.ErrorResponse; import com.joycrew.backend.dto.PagedProductResponse; import com.joycrew.backend.dto.ProductResponse; import com.joycrew.backend.entity.enums.Category; -import com.joycrew.backend.service.ProductService; +import com.joycrew.backend.service.ProductQueryService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.ExampleObject; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -22,166 +17,72 @@ @RequiredArgsConstructor public class ProductController { - private final ProductService productService; + private final ProductQueryService productQueryService; - @Operation( - summary = "Get all products (paged)", - description = "Fetches all products with pagination.", - parameters = { - @Parameter(name = "page", description = "Page number (0-based)", example = "0"), - @Parameter(name = "size", description = "Items per page", example = "20") - }, - responses = { - @ApiResponse( - responseCode = "200", - description = "Successfully retrieved products.", - content = @Content( - schema = @Schema(implementation = PagedProductResponse.class), - examples = @ExampleObject( - value = """ - { - "content": [ - { - "id": 1, - "keyword": "BEAUTY", - "rankOrder": 1, - "name": "Smartphone", - "thumbnailUrl": "https://example.com/image.jpg", - "price": 499, - "detailUrl": "https://example.com/product/1", - "itemId": "12345", - "registeredAt": "2025-08-11T10:00:00" - } - ], - "page": 0, - "size": 20, - "totalElements": 123, - "totalPages": 7, - "hasNext": true, - "hasPrevious": false - } - """ - ) - ) - ) - } - ) - @GetMapping - public ResponseEntity getAllProducts( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size - ) { - return ResponseEntity.ok(productService.getAllProducts(page, size)); - } + @Operation( + summary = "Search products with filters (paged)", + description = "Search products by a query term (name or item ID). You can also filter by category. If the query is empty, it lists products from the specified category or all products if no category is given.", + parameters = { + @Parameter(name = "q", description = "Search query (optional)", example = "Keyboard"), + @Parameter(name = "category", description = "Product category to filter by (optional)", example = "APPLIANCES"), + @Parameter(name = "page", description = "Page number (0-based)", example = "0"), + @Parameter(name = "size", description = "Items per page", example = "20") + } + ) + @GetMapping("/search") + public ResponseEntity searchProducts( + @RequestParam(name = "q", required = false) String q, + @RequestParam(name = "category", required = false) Category category, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return ResponseEntity.ok(productQueryService.searchProducts(q, category, page, size)); + } - @Operation( - summary = "Get product by ID", - description = "Fetches a single product by its ID.", - responses = { - @ApiResponse( - responseCode = "200", - description = "Successfully retrieved the product.", - content = @Content( - schema = @Schema(implementation = ProductResponse.class), - examples = @ExampleObject( - value = """ - { - "id": 1, - "keyword": "BEAUTY", - "rankOrder": 1, - "name": "Smartphone", - "thumbnailUrl": "https://example.com/image.jpg", - "price": 499, - "detailUrl": "https://example.com/product/1", - "itemId": "12345", - "registeredAt": "2025-08-11T10:00:00" - } - """ - ) - ) - ), - @ApiResponse( - responseCode = "404", - description = "Product not found", - content = @Content // empty body for 404 - ) - } - ) - @GetMapping("/{id}") - public ResponseEntity getProductById( - @Parameter(description = "Product ID", example = "1") - @PathVariable Long id - ) { - ProductResponse product = productService.getProductById(id); - return (product != null) ? ResponseEntity.ok(product) : ResponseEntity.notFound().build(); - } + @Operation( + summary = "Get all products (paged)", + description = "Fetches all products with pagination.", + parameters = { + @Parameter(name = "page", description = "Page number (0-based)", example = "0"), + @Parameter(name = "size", description = "Items per page", example = "20") + } + ) + @GetMapping + public ResponseEntity getAllProducts( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return ResponseEntity.ok(productQueryService.getAllProducts(page, size)); + } - @Operation( - summary = "Get products by category (paged)", - description = "Fetches products by category (keyword) with pagination.", - parameters = { - @Parameter(name = "category", description = "Product category (keyword)", example = "BEAUTY"), - @Parameter(name = "page", description = "Page number (0-based)", example = "0"), - @Parameter(name = "size", description = "Items per page", example = "20") - }, - responses = { - @ApiResponse( - responseCode = "200", - description = "Successfully retrieved products by category.", - content = @Content( - schema = @Schema(implementation = PagedProductResponse.class), - examples = @ExampleObject( - value = """ - { - "content": [ - { - "id": 1, - "keyword": "BEAUTY", - "rankOrder": 1, - "name": "Smartphone", - "thumbnailUrl": "https://example.com/image.jpg", - "price": 499, - "detailUrl": "https://example.com/product/1", - "itemId": "12345", - "registeredAt": "2025-08-11T10:00:00" - } - ], - "page": 0, - "size": 20, - "totalElements": 45, - "totalPages": 3, - "hasNext": true, - "hasPrevious": false - } - """ - ) - ) - ), - @ApiResponse( - responseCode = "400", - description = "Invalid category", - content = @Content( - schema = @Schema(implementation = ErrorResponse.class), - examples = @ExampleObject( - value = """ - { - "code": "INVALID_CATEGORY", - "message": "Category value is invalid.", - "timestamp": "2025-08-11T10:45:00", - "path": "/api/products/category/FOO" - } - """ - ) - ) - ) - } - ) - @GetMapping("/category/{category}") - public ResponseEntity getProductsByCategory( - @PathVariable Category category, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size - ) { - return ResponseEntity.ok(productService.getProductsByCategory(category, page, size)); - } + @Operation( + summary = "Get product by ID", + description = "Fetches a single product by its ID." + ) + @GetMapping("/{id}") + public ResponseEntity getProductById( + @Parameter(description = "Product ID", example = "1") + @PathVariable Long id + ) { + ProductResponse product = productQueryService.getProductById(id); + return (product != null) ? ResponseEntity.ok(product) : ResponseEntity.notFound().build(); + } + + @Operation( + summary = "Get products by category (paged)", + description = "Fetches products by category (keyword) with pagination.", + parameters = { + @Parameter(name = "category", description = "Product category (keyword)", example = "BEAUTY"), + @Parameter(name = "page", description = "Page number (0-based)", example = "0"), + @Parameter(name = "size", description = "Items per page", example = "20") + } + ) + @GetMapping("/category/{category}") + public ResponseEntity getProductsByCategory( + @PathVariable Category category, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return ResponseEntity.ok(productQueryService.getProductsByCategory(category, page, size)); + } } diff --git a/src/main/java/com/joycrew/backend/controller/RecentProductViewController.java b/src/main/java/com/joycrew/backend/controller/RecentProductViewController.java index c928e6e..68ded13 100644 --- a/src/main/java/com/joycrew/backend/controller/RecentProductViewController.java +++ b/src/main/java/com/joycrew/backend/controller/RecentProductViewController.java @@ -19,30 +19,30 @@ @RequiredArgsConstructor public class RecentProductViewController { - private final RecentProductViewService recentProductViewService; + private final RecentProductViewService recentProductViewService; - @Operation(summary = "Record a recent view", description = "Records a product as recently viewed by the current user.") - @PostMapping("/{productId}") - public ResponseEntity recordView( - @AuthenticationPrincipal UserPrincipal principal, - @PathVariable Long productId - ) { - Long employeeId = principal.getEmployee().getEmployeeId(); - recentProductViewService.recordView(employeeId, productId); - return ResponseEntity.ok().build(); - } + @Operation(summary = "Record a recent view", description = "Records a product as recently viewed by the current user.") + @PostMapping("/{productId}") + public ResponseEntity recordView( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long productId + ) { + Long employeeId = principal.getEmployee().getEmployeeId(); + recentProductViewService.recordView(employeeId, productId); + return ResponseEntity.ok().build(); + } - @Operation( - summary = "Get recent views", - description = "Returns the current user's recently viewed products within the last 3 months.", - parameters = @Parameter(name = "limit", description = "Max items to return (default 20, max 100)", example = "20") - ) - @GetMapping - public ResponseEntity> getRecentViews( - @AuthenticationPrincipal UserPrincipal principal, - @RequestParam(required = false) Integer limit - ) { - Long employeeId = principal.getEmployee().getEmployeeId(); - return ResponseEntity.ok(recentProductViewService.getRecentViews(employeeId, limit)); - } + @Operation( + summary = "Get recent views", + description = "Returns the current user's recently viewed products within the last 3 months.", + parameters = @Parameter(name = "limit", description = "Max items to return (default 20, max 100)", example = "20") + ) + @GetMapping + public ResponseEntity> getRecentViews( + @AuthenticationPrincipal UserPrincipal principal, + @RequestParam(required = false) Integer limit + ) { + Long employeeId = principal.getEmployee().getEmployeeId(); + return ResponseEntity.ok(recentProductViewService.getRecentViews(employeeId, limit)); + } } diff --git a/src/main/java/com/joycrew/backend/controller/StatisticsController.java b/src/main/java/com/joycrew/backend/controller/StatisticsController.java index d785dc8..6115143 100644 --- a/src/main/java/com/joycrew/backend/controller/StatisticsController.java +++ b/src/main/java/com/joycrew/backend/controller/StatisticsController.java @@ -19,14 +19,14 @@ @RequiredArgsConstructor public class StatisticsController { - private final StatisticsService statisticsService; + private final StatisticsService statisticsService; - @Operation(summary = "내 포인트 통계 조회", description = "로그인된 사용자의 주고받은 포인트 및 태그 통계를 조회합니다.", security = @SecurityRequirement(name = "Authorization")) - @GetMapping("/me") - public ResponseEntity getMyStatistics( - @AuthenticationPrincipal UserPrincipal principal - ) { - PointStatisticsResponse stats = statisticsService.getPointStatistics(principal.getUsername()); - return ResponseEntity.ok(stats); - } -} \ No newline at end of file + @Operation(summary = "내 포인트 통계 조회", description = "로그인된 사용자의 주고받은 포인트 및 태그 통계를 조회합니다.", security = @SecurityRequirement(name = "Authorization")) + @GetMapping("/me") + public ResponseEntity getMyStatistics( + @AuthenticationPrincipal UserPrincipal principal + ) { + PointStatisticsResponse stats = statisticsService.getPointStatistics(principal.getUsername()); + return ResponseEntity.ok(stats); + } +} diff --git a/src/main/java/com/joycrew/backend/controller/TransactionHistoryController.java b/src/main/java/com/joycrew/backend/controller/TransactionHistoryController.java index 360b117..b398ce2 100644 --- a/src/main/java/com/joycrew/backend/controller/TransactionHistoryController.java +++ b/src/main/java/com/joycrew/backend/controller/TransactionHistoryController.java @@ -21,13 +21,13 @@ @RequiredArgsConstructor public class TransactionHistoryController { - private final TransactionHistoryService transactionHistoryService; + private final TransactionHistoryService transactionHistoryService; - @Operation(summary = "Get point transaction history", security = @SecurityRequirement(name = "Authorization")) - @GetMapping - public ResponseEntity> getMyTransactions( - @AuthenticationPrincipal UserPrincipal principal) { - List history = transactionHistoryService.getTransactionHistory(principal.getUsername()); - return ResponseEntity.ok(history); - } + @Operation(summary = "Get point transaction history", security = @SecurityRequirement(name = "Authorization")) + @GetMapping + public ResponseEntity> getMyTransactions( + @AuthenticationPrincipal UserPrincipal principal) { + List history = transactionHistoryService.getTransactionHistory(principal.getUsername()); + return ResponseEntity.ok(history); + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/controller/UserController.java b/src/main/java/com/joycrew/backend/controller/UserController.java index 6acd306..b9af83b 100644 --- a/src/main/java/com/joycrew/backend/controller/UserController.java +++ b/src/main/java/com/joycrew/backend/controller/UserController.java @@ -1,9 +1,6 @@ package com.joycrew.backend.controller; -import com.joycrew.backend.dto.PasswordChangeRequest; -import com.joycrew.backend.dto.SuccessResponse; -import com.joycrew.backend.dto.UserProfileResponse; -import com.joycrew.backend.dto.UserProfileUpdateRequest; +import com.joycrew.backend.dto.*; import com.joycrew.backend.security.UserPrincipal; import com.joycrew.backend.service.EmployeeService; import io.swagger.v3.oas.annotations.Operation; @@ -23,34 +20,44 @@ @RequiredArgsConstructor public class UserController { - private final EmployeeService employeeService; - - @Operation(summary = "Get user profile", security = @SecurityRequirement(name = "Authorization")) - @GetMapping("/profile") - public ResponseEntity getProfile( - @AuthenticationPrincipal UserPrincipal principal - ) { - return ResponseEntity.ok(employeeService.getUserProfile(principal.getUsername())); - } - - @Operation(summary = "Change password", security = @SecurityRequirement(name = "Authorization")) - @PostMapping("/password") - public ResponseEntity forceChangePassword( - @AuthenticationPrincipal UserPrincipal principal, - @Valid @RequestBody PasswordChangeRequest request - ) { - employeeService.forcePasswordChange(principal.getUsername(), request); - return ResponseEntity.ok(new SuccessResponse("Password changed successfully.")); - } - - @Operation(summary = "Update my information", description = "Send profile data as 'request' part and image as 'profileImage' part in a multipart/form-data request.", security = @SecurityRequirement(name = "Authorization")) - @PatchMapping(value = "/profile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity updateMyProfile( - @AuthenticationPrincipal UserPrincipal principal, - @RequestPart("request") UserProfileUpdateRequest request, - @RequestPart(value = "profileImage", required = false) MultipartFile profileImage) { - - employeeService.updateUserProfile(principal.getUsername(), request, profileImage); - return ResponseEntity.ok(new SuccessResponse("Your information has been updated successfully.")); - } -} \ No newline at end of file + private final EmployeeService employeeService; + + @Operation(summary = "Get user profile", security = @SecurityRequirement(name = "Authorization")) + @GetMapping("/profile") + public ResponseEntity getProfile( + @AuthenticationPrincipal UserPrincipal principal + ) { + return ResponseEntity.ok(employeeService.getUserProfile(principal.getUsername())); + } + + @Operation(summary = "Change password", security = @SecurityRequirement(name = "Authorization")) + @PostMapping("/password") + public ResponseEntity forceChangePassword( + @AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody PasswordChangeRequest request + ) { + employeeService.forcePasswordChange(principal.getUsername(), request); + return ResponseEntity.ok(new SuccessResponse("Password changed successfully.")); + } + + @Operation(summary = "Update my information", description = "Send profile data as 'request' part and image as 'profileImage' part in a multipart/form-data request.", security = @SecurityRequirement(name = "Authorization")) + @PatchMapping(value = "/profile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity updateMyProfile( + @AuthenticationPrincipal UserPrincipal principal, + @RequestPart("request") UserProfileUpdateRequest request, + @RequestPart(value = "profileImage", required = false) MultipartFile profileImage) { + + employeeService.updateUserProfile(principal.getUsername(), request, profileImage); + return ResponseEntity.ok(new SuccessResponse("Your information has been updated successfully.")); + } + + @Operation(summary = "Verify current password", security = @SecurityRequirement(name = "Authorization")) + @PostMapping("/password/verify") + public ResponseEntity verifyPassword( + @AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody PasswordVerifyRequest request + ) { + employeeService.verifyCurrentPassword(principal.getUsername(), request); + return ResponseEntity.ok(new SuccessResponse("Password verified successfully.")); + } +} diff --git a/src/main/java/com/joycrew/backend/controller/WalletController.java b/src/main/java/com/joycrew/backend/controller/WalletController.java index 0b48fe8..88ba450 100644 --- a/src/main/java/com/joycrew/backend/controller/WalletController.java +++ b/src/main/java/com/joycrew/backend/controller/WalletController.java @@ -19,13 +19,13 @@ @RequiredArgsConstructor public class WalletController { - private final WalletService walletService; + private final WalletService walletService; - @Operation(summary = "Get point balance", security = @SecurityRequirement(name = "Authorization")) - @GetMapping("/point") - public ResponseEntity getWalletPoint( - @AuthenticationPrincipal UserPrincipal principal - ) { - return ResponseEntity.ok(walletService.getPointBalance(principal.getUsername())); - } -} \ No newline at end of file + @Operation(summary = "Get point balance", security = @SecurityRequirement(name = "Authorization")) + @GetMapping("/point") + public ResponseEntity getWalletPoint( + @AuthenticationPrincipal UserPrincipal principal + ) { + return ResponseEntity.ok(walletService.getPointBalance(principal.getUsername())); + } +} diff --git a/src/main/java/com/joycrew/backend/dto/AdminEmployeeQueryResponse.java b/src/main/java/com/joycrew/backend/dto/AdminEmployeeQueryResponse.java index d086023..ebd179d 100644 --- a/src/main/java/com/joycrew/backend/dto/AdminEmployeeQueryResponse.java +++ b/src/main/java/com/joycrew/backend/dto/AdminEmployeeQueryResponse.java @@ -5,33 +5,36 @@ @Schema(description = "Admin-specific Employee Search Response DTO") public record AdminEmployeeQueryResponse( - @Schema(description = "Employee ID", example = "1") - Long employeeId, + @Schema(description = "Employee ID", example = "1") + Long employeeId, - @Schema(description = "Employee name", example = "John Doe") - String employeeName, + @Schema(description = "Employee name", example = "John Doe") + String employeeName, - @Schema(description = "Employee email", example = "john.doe@example.com") - String email, + @Schema(description = "Employee email", example = "john.doe@example.com") + String email, - @Schema(description = "Department name", example = "Engineering") - String departmentName, + @Schema(description = "Department name", example = "Engineering") + String departmentName, - @Schema(description = "Position or title", example = "Backend Developer") - String position, + @Schema(description = "Position or title", example = "Backend Developer") + String position, - @Schema(description = "URL of the profile image") - String profileImageUrl, + @Schema(description = "URL of the profile image") + String profileImageUrl, - @Schema(description = "Employee role/permission level", example = "HR_ADMIN") - String adminLevel, + @Schema(description = "Employee role/permission level", example = "HR_ADMIN") + String adminLevel, - @Schema(description = "Birth date", example = "1995-05-10") - LocalDate birthday, + @Schema(description = "Phone number", example = "010-1234-5678") + String phoneNumber, - @Schema(description = "Address", example = "123 Teheran-ro, Gangnam-gu, Seoul") - String address, + @Schema(description = "Birth date", example = "1995-05-10") + LocalDate birthday, - @Schema(description = "Hire date", example = "2023-01-10") - LocalDate hireDate + @Schema(description = "Address", example = "123 Teheran-ro, Gangnam-gu, Seoul") + String address, + + @Schema(description = "Hire date", example = "2023-01-10") + LocalDate hireDate ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/AdminEmployeeUpdateRequest.java b/src/main/java/com/joycrew/backend/dto/AdminEmployeeUpdateRequest.java index bf19f75..358d21f 100644 --- a/src/main/java/com/joycrew/backend/dto/AdminEmployeeUpdateRequest.java +++ b/src/main/java/com/joycrew/backend/dto/AdminEmployeeUpdateRequest.java @@ -3,9 +3,9 @@ import com.joycrew.backend.entity.enums.AdminLevel; public record AdminEmployeeUpdateRequest( - String name, - Long departmentId, - String position, - AdminLevel level, - String status + String name, + Long departmentId, + String position, + AdminLevel level, + String status ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/AdminPagedEmployeeResponse.java b/src/main/java/com/joycrew/backend/dto/AdminPagedEmployeeResponse.java index 7448b72..854af84 100644 --- a/src/main/java/com/joycrew/backend/dto/AdminPagedEmployeeResponse.java +++ b/src/main/java/com/joycrew/backend/dto/AdminPagedEmployeeResponse.java @@ -5,15 +5,15 @@ @Schema(description = "Admin-specific Paginated Employee List Response DTO") public record AdminPagedEmployeeResponse( - @Schema(description = "List of employees") - List employees, + @Schema(description = "List of employees") + List employees, - @Schema(description = "Current page number (0-based)") - int currentPage, + @Schema(description = "Current page number (0-based)") + int currentPage, - @Schema(description = "Total number of pages") - int totalPages, + @Schema(description = "Total number of pages") + int totalPages, - @Schema(description = "Indicates if this is the last page") - boolean last + @Schema(description = "Indicates if this is the last page") + boolean last ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/AdminPointBudgetResponse.java b/src/main/java/com/joycrew/backend/dto/AdminPointBudgetResponse.java index 331608e..512abb9 100644 --- a/src/main/java/com/joycrew/backend/dto/AdminPointBudgetResponse.java +++ b/src/main/java/com/joycrew/backend/dto/AdminPointBudgetResponse.java @@ -7,12 +7,12 @@ */ @Schema(description = "Admin's point budget and personal balance response DTO") public record AdminPointBudgetResponse( - @Schema(description = "The total point budget available for the entire company") - Double companyTotalBalance, + @Schema(description = "The total point budget available for the entire company") + Double companyTotalBalance, - @Schema(description = "The admin's personal total point balance") - Integer adminPersonalTotalBalance, + @Schema(description = "The admin's personal total point balance") + Integer adminPersonalTotalBalance, - @Schema(description = "The admin's personal giftable point balance") - Integer adminPersonalGiftableBalance + @Schema(description = "The admin's personal giftable point balance") + Integer adminPersonalGiftableBalance ) {} diff --git a/src/main/java/com/joycrew/backend/dto/AdminPointDistributionRequest.java b/src/main/java/com/joycrew/backend/dto/AdminPointDistributionRequest.java index 31f6686..d7f0677 100644 --- a/src/main/java/com/joycrew/backend/dto/AdminPointDistributionRequest.java +++ b/src/main/java/com/joycrew/backend/dto/AdminPointDistributionRequest.java @@ -9,20 +9,20 @@ import java.util.List; public record AdminPointDistributionRequest( - @NotEmpty(message = "Distribution list cannot be empty.") - @Size(min = 1, message = "At least one employee must be selected.") - @Valid - List distributions, + @NotEmpty(message = "Distribution list cannot be empty.") + @Size(min = 1, message = "At least one employee must be selected.") + @Valid + List distributions, - @NotNull(message = "Message is required.") - String message, + @NotNull(message = "Message is required.") + String message, - @NotNull(message = "Transaction type is required.") - @Schema( - description = "Fixed to AWARD_MANAGER_SPOT for admin bulk distributions", - allowableValues = {"AWARD_MANAGER_SPOT"}, - example = "AWARD_MANAGER_SPOT", - defaultValue = "AWARD_MANAGER_SPOT" - ) - TransactionType type + @NotNull(message = "Transaction type is required.") + @Schema( + description = "Fixed to AWARD_MANAGER_SPOT for admin bulk distributions", + allowableValues = {"AWARD_MANAGER_SPOT"}, + example = "AWARD_MANAGER_SPOT", + defaultValue = "AWARD_MANAGER_SPOT" + ) + TransactionType type ) { } diff --git a/src/main/java/com/joycrew/backend/dto/CreateOrderRequest.java b/src/main/java/com/joycrew/backend/dto/CreateOrderRequest.java index c8d28dc..0232615 100644 --- a/src/main/java/com/joycrew/backend/dto/CreateOrderRequest.java +++ b/src/main/java/com/joycrew/backend/dto/CreateOrderRequest.java @@ -4,8 +4,8 @@ @Schema(description = "Create order request") public record CreateOrderRequest( - @Schema(description = "Product ID", example = "101") - Long productId, - @Schema(description = "Quantity", example = "2") - Integer quantity + @Schema(description = "Product ID", example = "101") + Long productId, + @Schema(description = "Quantity", example = "2") + Integer quantity ) { } diff --git a/src/main/java/com/joycrew/backend/dto/EmployeeQueryResponse.java b/src/main/java/com/joycrew/backend/dto/EmployeeQueryResponse.java index cb0e1a7..1d36fd1 100644 --- a/src/main/java/com/joycrew/backend/dto/EmployeeQueryResponse.java +++ b/src/main/java/com/joycrew/backend/dto/EmployeeQueryResponse.java @@ -4,18 +4,18 @@ @Schema(description = "Employee Search Result DTO") public record EmployeeQueryResponse( - @Schema(description = "Unique ID of the employee", example = "1") - Long employeeId, + @Schema(description = "Unique ID of the employee", example = "1") + Long employeeId, - @Schema(description = "URL of the profile image", example = "https://cdn.joycrew.com/profile/user123.jpg") - String profileImageUrl, + @Schema(description = "URL of the profile image", example = "https://cdn.joycrew.com/profile/user123.jpg") + String profileImageUrl, - @Schema(description = "Name of the employee", example = "John Doe") - String employeeName, + @Schema(description = "Name of the employee", example = "John Doe") + String employeeName, - @Schema(description = "Department name", example = "Engineering") - String departmentName, + @Schema(description = "Department name", example = "Engineering") + String departmentName, - @Schema(description = "Position or title", example = "Backend Developer") - String position + @Schema(description = "Position or title", example = "Backend Developer") + String position ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java b/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java index 268a9b2..17e7154 100644 --- a/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java +++ b/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationRequest.java @@ -1,6 +1,7 @@ package com.joycrew.backend.dto; import com.joycrew.backend.entity.enums.AdminLevel; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -9,31 +10,34 @@ import java.time.LocalDate; public record EmployeeRegistrationRequest ( - @NotBlank(message = "Name is required.") - String name, + @NotBlank(message = "Name is required.") + String name, - @NotBlank(message = "Email is required.") - @Email(message = "Must be a valid email format.") - String email, + @NotBlank(message = "Email is required.") + @Email(message = "Must be a valid email format.") + String email, - @NotBlank(message = "Initial password is required.") - @Size(min = 8, message = "Password must be at least 8 characters long.") - String initialPassword, + @NotBlank(message = "Initial password is required.") + @Size(min = 8, message = "Password must be at least 8 characters long.") + String initialPassword, - @NotBlank(message = "Company name is required.") - String companyName, + @NotBlank(message = "Company name is required.") + String companyName, - String departmentName, + String departmentName, - @NotBlank(message = "Position is required.") - String position, + @NotBlank(message = "Position is required.") + String position, - @NotNull(message = "Role is required.") - AdminLevel level, + @NotNull(message = "Role is required.") + AdminLevel level, - LocalDate birthday, + @Schema(description = "Phone number", example = "010-1234-5678") + String phoneNumber, - String address, + LocalDate birthday, - LocalDate hireDate + String address, + + LocalDate hireDate ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationSuccessResponse.java b/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationSuccessResponse.java index f904ec9..a7096f6 100644 --- a/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationSuccessResponse.java +++ b/src/main/java/com/joycrew/backend/dto/EmployeeRegistrationSuccessResponse.java @@ -4,6 +4,6 @@ @Schema(description = "Employee Creation Success Response DTO") public record EmployeeRegistrationSuccessResponse( - @Schema(example = "Employee created successfully (ID: 2)", description = "Response message") - String message + @Schema(example = "Employee created successfully (ID: 2)", description = "Response message") + String message ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/ErrorResponse.java b/src/main/java/com/joycrew/backend/dto/ErrorResponse.java index 08b288f..c101da0 100644 --- a/src/main/java/com/joycrew/backend/dto/ErrorResponse.java +++ b/src/main/java/com/joycrew/backend/dto/ErrorResponse.java @@ -6,12 +6,12 @@ @Schema(description = "Error response") public record ErrorResponse( - @Schema(description = "Error code", example = "INSUFFICIENT_POINTS") - String code, - @Schema(description = "Error message", example = "Not enough points to complete the purchase.") - String message, - @Schema(description = "Timestamp", example = "2025-08-11T10:45:00") - LocalDateTime timestamp, - @Schema(description = "Request path", example = "/api/orders") - String path + @Schema(description = "Error code", example = "INSUFFICIENT_POINTS") + String code, + @Schema(description = "Error message", example = "Not enough points to complete the purchase.") + String message, + @Schema(description = "Timestamp", example = "2025-08-11T10:45:00") + LocalDateTime timestamp, + @Schema(description = "Request path", example = "/api/orders") + String path ) { } diff --git a/src/main/java/com/joycrew/backend/dto/GiftPointRequest.java b/src/main/java/com/joycrew/backend/dto/GiftPointRequest.java index e6bffb2..81683ef 100644 --- a/src/main/java/com/joycrew/backend/dto/GiftPointRequest.java +++ b/src/main/java/com/joycrew/backend/dto/GiftPointRequest.java @@ -11,21 +11,21 @@ @Schema(description = "Gift Points Request DTO") public record GiftPointRequest( - @Schema(description = "ID of the employee who will receive the points", example = "2") - @NotNull(message = "Receiver ID is required.") - Long receiverId, + @Schema(description = "ID of the employee who will receive the points", example = "2") + @NotNull(message = "Receiver ID is required.") + Long receiverId, - @Schema(description = "Number of points to gift", example = "50", minimum = "1") - @NotNull(message = "Points are required.") - @Min(value = 1, message = "Points must be at least 1.") - int points, + @Schema(description = "Number of points to gift", example = "50", minimum = "1") + @NotNull(message = "Points are required.") + @Min(value = 1, message = "Points must be at least 1.") + int points, - @Schema(description = "Encouragement message (optional, max 255 chars)", example = "Great job on the project!") - @Size(max = 255, message = "Message cannot exceed 255 characters.") - String message, + @Schema(description = "Encouragement message (optional, max 255 chars)", example = "Great job on the project!") + @Size(max = 255, message = "Message cannot exceed 255 characters.") + String message, - @Schema(description = "List of tags to send with the points (min 1, max 3)", example = "[\"TEAMWORK\", \"LEADERSHIP\"]") - @NotNull(message = "Tags are required.") - @Size(min = 1, max = 3, message = "Between 1 and 3 tags can be selected.") - List tags + @Schema(description = "List of tags to send with the points (min 1, max 3)", example = "[\"TEAMWORK\", \"LEADERSHIP\"]") + @NotNull(message = "Tags are required.") + @Size(min = 1, max = 3, message = "Between 1 and 3 tags can be selected.") + List tags ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/LoginRequest.java b/src/main/java/com/joycrew/backend/dto/LoginRequest.java index 0184e05..bda82bb 100644 --- a/src/main/java/com/joycrew/backend/dto/LoginRequest.java +++ b/src/main/java/com/joycrew/backend/dto/LoginRequest.java @@ -5,12 +5,12 @@ import jakarta.validation.constraints.NotBlank; public record LoginRequest( - @Schema(description = "Email address", example = "user@example.com") - @Email(message = "Must be a valid email format.") - @NotBlank(message = "Email is required.") - String email, + @Schema(description = "Email address", example = "user@example.com") + @Email(message = "Must be a valid email format.") + @NotBlank(message = "Email is required.") + String email, - @Schema(description = "Password", example = "password123!") - @NotBlank(message = "Password is required.") - String password + @Schema(description = "Password", example = "password123!") + @NotBlank(message = "Password is required.") + String password ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/LoginResponse.java b/src/main/java/com/joycrew/backend/dto/LoginResponse.java index fba2888..3514ab7 100644 --- a/src/main/java/com/joycrew/backend/dto/LoginResponse.java +++ b/src/main/java/com/joycrew/backend/dto/LoginResponse.java @@ -5,24 +5,24 @@ @Schema(description = "Login Response DTO") public record LoginResponse( - @Schema(description = "JWT access token") - String accessToken, - @Schema(description = "Response message") - String message, - @Schema(description = "Unique ID of the user") - Long userId, - @Schema(description = "Name of the user") - String name, - @Schema(description = "Email of the user") - String email, - @Schema(description = "Role of the user") - AdminLevel role, - @Schema(description = "Total points balance") - Integer totalPoint, - @Schema(description = "URL of the profile image") - String profileImageUrl + @Schema(description = "JWT access token") + String accessToken, + @Schema(description = "Response message") + String message, + @Schema(description = "Unique ID of the user") + Long userId, + @Schema(description = "Name of the user") + String name, + @Schema(description = "Email of the user") + String email, + @Schema(description = "Role of the user") + AdminLevel role, + @Schema(description = "Total points balance") + Integer totalPoint, + @Schema(description = "URL of the profile image") + String profileImageUrl ) { - public static LoginResponse fail(String message) { - return new LoginResponse(null, message, null, null, null, null, null, null); - } + public static LoginResponse fail(String message) { + return new LoginResponse(null, message, null, null, null, null, null, null); + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/OrderResponse.java b/src/main/java/com/joycrew/backend/dto/OrderResponse.java index ec7d0fc..7b24644 100644 --- a/src/main/java/com/joycrew/backend/dto/OrderResponse.java +++ b/src/main/java/com/joycrew/backend/dto/OrderResponse.java @@ -9,45 +9,48 @@ @Schema(description = "Order response") public record OrderResponse( - @Schema(description = "Order ID", example = "1001") - Long orderId, - @Schema(description = "Employee ID", example = "1") - Long employeeId, - @Schema(description = "Product ID", example = "101") - Long productId, - @Schema(description = "Product name", example = "Smartphone") - String productName, - @Schema(description = "Product item ID", example = "12345") - String productItemId, - @Schema(description = "Unit price", example = "499") - Integer productUnitPrice, - @Schema(description = "Quantity", example = "2") - Integer quantity, - @Schema(description = "Total price", example = "998") - Integer totalPrice, - @Schema(description = "Status", example = "PLACED") - OrderStatus status, - @Schema(description = "Ordered at", example = "2025-08-11T10:00:00") - LocalDateTime orderedAt, - @Schema(description = "Shipped at", example = "2025-08-12T09:00:00") - LocalDateTime shippedAt, - @Schema(description = "Delivered at", example = "2025-08-13T18:30:00") - LocalDateTime deliveredAt + @Schema(description = "Order ID", example = "1001") + Long orderId, + @Schema(description = "Employee ID", example = "1") + Long employeeId, + @Schema(description = "Product ID", example = "101") + Long productId, + @Schema(description = "Product name", example = "Smartphone") + String productName, + @Schema(description = "상품 썸네일 URL") + String thumbnailUrl, + @Schema(description = "Product item ID", example = "12345") + String productItemId, + @Schema(description = "Unit price", example = "499") + Integer productUnitPrice, + @Schema(description = "Quantity", example = "2") + Integer quantity, + @Schema(description = "Total price", example = "998") + Integer totalPrice, + @Schema(description = "Status", example = "PLACED") + OrderStatus status, + @Schema(description = "Ordered at", example = "2025-08-11T10:00:00") + LocalDateTime orderedAt, + @Schema(description = "Shipped at", example = "2025-08-12T09:00:00") + LocalDateTime shippedAt, + @Schema(description = "Delivered at", example = "2025-08-13T18:30:00") + LocalDateTime deliveredAt ) { - public static OrderResponse from(Order o) { - return new OrderResponse( - o.getOrderId(), - o.getEmployee().getEmployeeId(), - o.getProductId(), - o.getProductName(), - o.getProductItemId(), - o.getProductUnitPrice(), - o.getQuantity(), - o.getTotalPrice(), - o.getStatus(), - o.getOrderedAt(), - o.getShippedAt(), - o.getDeliveredAt() - ); - } + public static OrderResponse from(Order o, String thumbnailUrl) { + return new OrderResponse( + o.getOrderId(), + o.getEmployee().getEmployeeId(), + o.getProductId(), + o.getProductName(), + thumbnailUrl, + o.getProductItemId(), + o.getProductUnitPrice(), + o.getQuantity(), + o.getTotalPrice(), + o.getStatus(), + o.getOrderedAt(), + o.getShippedAt(), + o.getDeliveredAt() + ); + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/PagedEmployeeResponse.java b/src/main/java/com/joycrew/backend/dto/PagedEmployeeResponse.java index 1b4bea5..a2bc47d 100644 --- a/src/main/java/com/joycrew/backend/dto/PagedEmployeeResponse.java +++ b/src/main/java/com/joycrew/backend/dto/PagedEmployeeResponse.java @@ -5,15 +5,15 @@ @Schema(description = "Paginated Employee List Response DTO") public record PagedEmployeeResponse( - @Schema(description = "List of employee information") - List employees, + @Schema(description = "List of employee information") + List employees, - @Schema(description = "Current page number (0-based)", example = "0") - int currentPage, + @Schema(description = "Current page number (0-based)", example = "0") + int currentPage, - @Schema(description = "Total number of pages", example = "10") - int totalPages, + @Schema(description = "Total number of pages", example = "10") + int totalPages, - @Schema(description = "Indicates if this is the last page", example = "false") - boolean isLastPage + @Schema(description = "Indicates if this is the last page", example = "false") + boolean isLastPage ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/PagedOrderResponse.java b/src/main/java/com/joycrew/backend/dto/PagedOrderResponse.java index e5e6e22..3a7ea12 100644 --- a/src/main/java/com/joycrew/backend/dto/PagedOrderResponse.java +++ b/src/main/java/com/joycrew/backend/dto/PagedOrderResponse.java @@ -6,18 +6,18 @@ @Schema(description = "Paged order response") public record PagedOrderResponse( - @Schema(description = "Content list") - List content, - @Schema(description = "Current page (0-based)", example = "0") - int page, - @Schema(description = "Page size", example = "20") - int size, - @Schema(description = "Total elements", example = "12") - long totalElements, - @Schema(description = "Total pages", example = "2") - int totalPages, - @Schema(description = "Has next page", example = "true") - boolean hasNext, - @Schema(description = "Has previous page", example = "false") - boolean hasPrevious + @Schema(description = "Content list") + List content, + @Schema(description = "Current page (0-based)", example = "0") + int page, + @Schema(description = "Page size", example = "20") + int size, + @Schema(description = "Total elements", example = "12") + long totalElements, + @Schema(description = "Total pages", example = "2") + int totalPages, + @Schema(description = "Has next page", example = "true") + boolean hasNext, + @Schema(description = "Has previous page", example = "false") + boolean hasPrevious ) { } diff --git a/src/main/java/com/joycrew/backend/dto/PagedProductResponse.java b/src/main/java/com/joycrew/backend/dto/PagedProductResponse.java index ba98963..874feb0 100644 --- a/src/main/java/com/joycrew/backend/dto/PagedProductResponse.java +++ b/src/main/java/com/joycrew/backend/dto/PagedProductResponse.java @@ -8,34 +8,34 @@ @Schema(description = "Paged product response") public record PagedProductResponse( - @ArraySchema(arraySchema = @Schema(description = "Content list"), - schema = @Schema(implementation = ProductResponse.class)) - List content, - @Schema(description = "Current page (0-based)", example = "0") - int page, - @Schema(description = "Page size", example = "20") - int size, - @Schema(description = "Total elements", example = "123") - long totalElements, - @Schema(description = "Total pages", example = "7") - int totalPages, - @Schema(description = "Has next page", example = "true") - boolean hasNext, - @Schema(description = "Has previous page", example = "false") - boolean hasPrevious + @ArraySchema(arraySchema = @Schema(description = "Content list"), + schema = @Schema(implementation = ProductResponse.class)) + List content, + @Schema(description = "Current page (0-based)", example = "0") + int page, + @Schema(description = "Page size", example = "20") + int size, + @Schema(description = "Total elements", example = "123") + long totalElements, + @Schema(description = "Total pages", example = "7") + int totalPages, + @Schema(description = "Has next page", example = "true") + boolean hasNext, + @Schema(description = "Has previous page", example = "false") + boolean hasPrevious ) { - public static PagedProductResponse from(org.springframework.data.domain.Page pageData) { - List mapped = pageData.getContent().stream() - .map(ProductResponse::from) - .toList(); - return new PagedProductResponse( - mapped, - pageData.getNumber(), - pageData.getSize(), - pageData.getTotalElements(), - pageData.getTotalPages(), - pageData.hasNext(), - pageData.hasPrevious() - ); - } + public static PagedProductResponse from(org.springframework.data.domain.Page pageData) { + List mapped = pageData.getContent().stream() + .map(ProductResponse::from) + .toList(); + return new PagedProductResponse( + mapped, + pageData.getNumber(), + pageData.getSize(), + pageData.getTotalElements(), + pageData.getTotalPages(), + pageData.hasNext(), + pageData.hasPrevious() + ); + } } diff --git a/src/main/java/com/joycrew/backend/dto/PasswordChangeRequest.java b/src/main/java/com/joycrew/backend/dto/PasswordChangeRequest.java index 829a00f..fbf4f5d 100644 --- a/src/main/java/com/joycrew/backend/dto/PasswordChangeRequest.java +++ b/src/main/java/com/joycrew/backend/dto/PasswordChangeRequest.java @@ -4,8 +4,8 @@ import jakarta.validation.constraints.Pattern; public record PasswordChangeRequest( - @NotBlank(message = "새로운 비밀번호는 필수입니다.") - @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&])[A-Za-z\\d@$!%*#?&]{8,20}$", - message = "비밀번호는 영문, 숫자, 특수문자를 포함하여 8자 이상 20자 이하이어야 합니다.") - String newPassword + @NotBlank(message = "새로운 비밀번호는 필수입니다.") + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&])[A-Za-z\\d@$!%*#?&]{8,20}$", + message = "비밀번호는 영문, 숫자, 특수문자를 포함하여 8자 이상 20자 이하이어야 합니다.") + String newPassword ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/PasswordResetConfirmRequest.java b/src/main/java/com/joycrew/backend/dto/PasswordResetConfirmRequest.java index b356ffc..5c2d260 100644 --- a/src/main/java/com/joycrew/backend/dto/PasswordResetConfirmRequest.java +++ b/src/main/java/com/joycrew/backend/dto/PasswordResetConfirmRequest.java @@ -5,13 +5,13 @@ import jakarta.validation.constraints.Pattern; public record PasswordResetConfirmRequest( - @Schema(description = "Password reset token received via email") - @NotBlank(message = "Token is required.") - String token, + @Schema(description = "Password reset token received via email") + @NotBlank(message = "Token is required.") + String token, - @Schema(description = "The new password") - @NotBlank(message = "New password is required.") - @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&])[A-Za-z\\d@$!%*#?&]{8,20}$", - message = "Password must be 8-20 characters long and include at least one letter, one number, and one special character.") - String newPassword + @Schema(description = "The new password") + @NotBlank(message = "New password is required.") + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&])[A-Za-z\\d@$!%*#?&]{8,20}$", + message = "Password must be 8-20 characters long and include at least one letter, one number, and one special character.") + String newPassword ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/PasswordResetRequest.java b/src/main/java/com/joycrew/backend/dto/PasswordResetRequest.java index cddb827..f732ae6 100644 --- a/src/main/java/com/joycrew/backend/dto/PasswordResetRequest.java +++ b/src/main/java/com/joycrew/backend/dto/PasswordResetRequest.java @@ -5,8 +5,8 @@ import jakarta.validation.constraints.NotBlank; public record PasswordResetRequest( - @Schema(description = "Email of the account to reset the password for", example = "user@example.com") - @NotBlank(message = "Email is required.") - @Email(message = "Must be a valid email format.") - String email + @Schema(description = "Email of the account to reset the password for", example = "user@example.com") + @NotBlank(message = "Email is required.") + @Email(message = "Must be a valid email format.") + String email ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/PasswordVerifyRequest.java b/src/main/java/com/joycrew/backend/dto/PasswordVerifyRequest.java new file mode 100644 index 0000000..bbe1c8f --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/PasswordVerifyRequest.java @@ -0,0 +1,11 @@ +package com.joycrew.backend.dto; + +import jakarta.validation.constraints.NotBlank; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "DTO for verifying the current password") +public record PasswordVerifyRequest( + @Schema(description = "The user's current password", example = "password123!") + @NotBlank(message = "Current password is required.") + String currentPassword +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/PointBalanceResponse.java b/src/main/java/com/joycrew/backend/dto/PointBalanceResponse.java index 5f9a792..6cd836e 100644 --- a/src/main/java/com/joycrew/backend/dto/PointBalanceResponse.java +++ b/src/main/java/com/joycrew/backend/dto/PointBalanceResponse.java @@ -4,6 +4,6 @@ @Schema(description = "Wallet Balance Response DTO") public record PointBalanceResponse( - @Schema(description = "Current total balance") Integer totalBalance, - @Schema(description = "Current giftable points") Integer giftableBalance + @Schema(description = "Current total balance") Integer totalBalance, + @Schema(description = "Current giftable points") Integer giftableBalance ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/PointDistributionDetail.java b/src/main/java/com/joycrew/backend/dto/PointDistributionDetail.java index f13791a..b4dcd7d 100644 --- a/src/main/java/com/joycrew/backend/dto/PointDistributionDetail.java +++ b/src/main/java/com/joycrew/backend/dto/PointDistributionDetail.java @@ -4,10 +4,10 @@ import jakarta.validation.constraints.NotNull; public record PointDistributionDetail( - @NotNull(message = "Employee ID is required.") - Long employeeId, + @NotNull(message = "Employee ID is required.") + Long employeeId, - @NotNull(message = "Points are required.") - @Min(value = 1, message = "Points must be at least 1.") - Integer points + @NotNull(message = "Points are required.") + @Min(value = 1, message = "Points must be at least 1.") + Integer points ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/PointStatisticsResponse.java b/src/main/java/com/joycrew/backend/dto/PointStatisticsResponse.java index 3cc26bc..15b1a2c 100644 --- a/src/main/java/com/joycrew/backend/dto/PointStatisticsResponse.java +++ b/src/main/java/com/joycrew/backend/dto/PointStatisticsResponse.java @@ -1,17 +1,22 @@ package com.joycrew.backend.dto; -import com.joycrew.backend.entity.enums.Tag; import io.swagger.v3.oas.annotations.media.Schema; -import java.util.Map; +import java.util.List; @Schema(description = "포인트 및 태그 통계 응답 DTO") public record PointStatisticsResponse( - @Schema(description = "총 받은 포인트 합계") - Integer totalPointsReceived, + @Schema(description = "총 받은 포인트 합계") + Integer totalPointsReceived, - @Schema(description = "총 보낸 포인트 합계") - Integer totalPointsSent, + @Schema(description = "총 보낸 포인트 합계") + Integer totalPointsSent, - @Schema(description = "받은 태그 종류 및 횟수") - Map tagStatistics + @Schema(description = "받은 태그 종류 및 횟수 (Enum 순서대로)") + List tagCounts, + + @Schema(description = "받은 포인트 거래 내역") + List receivedTransactions, + + @Schema(description = "보낸 포인트 거래 내역") + List sentTransactions ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/ProductResponse.java b/src/main/java/com/joycrew/backend/dto/ProductResponse.java index 7b0fa08..590934c 100644 --- a/src/main/java/com/joycrew/backend/dto/ProductResponse.java +++ b/src/main/java/com/joycrew/backend/dto/ProductResponse.java @@ -8,36 +8,36 @@ @Schema(description = "Product summary DTO") public record ProductResponse( - @Schema(description = "Product ID", example = "1") - Long id, - @Schema(description = "Category (keyword)", example = "BEAUTY") - Category keyword, - @Schema(description = "Rank order", example = "1") - Integer rankOrder, - @Schema(description = "Product name", example = "Smartphone") - String name, - @Schema(description = "Thumbnail URL", example = "https://example.com/image.jpg") - String thumbnailUrl, - @Schema(description = "Price", example = "499") - Integer price, - @Schema(description = "Detail URL", example = "https://example.com/product/1") - String detailUrl, - @Schema(description = "Item ID", example = "12345") - String itemId, - @Schema(description = "Registered time", example = "2025-08-11T10:00:00") - LocalDateTime registeredAt + @Schema(description = "Product ID", example = "1") + Long id, + @Schema(description = "Category (keyword)", example = "BEAUTY") + Category keyword, + @Schema(description = "Rank order", example = "1") + Integer rankOrder, + @Schema(description = "Product name", example = "Smartphone") + String name, + @Schema(description = "Thumbnail URL", example = "https://example.com/image.jpg") + String thumbnailUrl, + @Schema(description = "Price", example = "499") + Integer price, + @Schema(description = "Detail URL", example = "https://example.com/product/1") + String detailUrl, + @Schema(description = "Item ID", example = "12345") + String itemId, + @Schema(description = "Registered time", example = "2025-08-11T10:00:00") + LocalDateTime registeredAt ) { - public static ProductResponse from(Product p) { - return new ProductResponse( - p.getId(), - p.getKeyword(), - p.getRankOrder(), - p.getName(), - p.getThumbnailUrl(), - p.getPrice(), - p.getDetailUrl(), - p.getItemId(), - p.getRegisteredAt() - ); - } + public static ProductResponse from(Product p) { + return new ProductResponse( + p.getId(), + p.getKeyword(), + p.getRankOrder(), + p.getName(), + p.getThumbnailUrl(), + p.getPrice(), + p.getDetailUrl(), + p.getItemId(), + p.getRegisteredAt() + ); + } } diff --git a/src/main/java/com/joycrew/backend/dto/RecentViewedProductResponse.java b/src/main/java/com/joycrew/backend/dto/RecentViewedProductResponse.java index c4186bc..b25dfd1 100644 --- a/src/main/java/com/joycrew/backend/dto/RecentViewedProductResponse.java +++ b/src/main/java/com/joycrew/backend/dto/RecentViewedProductResponse.java @@ -7,12 +7,12 @@ @Schema(description = "Recently viewed product item") public record RecentViewedProductResponse( - @Schema(description = "Product") - ProductResponse product, - @Schema(description = "Viewed at", example = "2025-08-11T12:34:56") - LocalDateTime viewedAt + @Schema(description = "Product") + ProductResponse product, + @Schema(description = "Viewed at", example = "2025-08-11T12:34:56") + LocalDateTime viewedAt ) { - public static RecentViewedProductResponse of(Product product, LocalDateTime viewedAt) { - return new RecentViewedProductResponse(ProductResponse.from(product), viewedAt); - } + public static RecentViewedProductResponse of(Product product, LocalDateTime viewedAt) { + return new RecentViewedProductResponse(ProductResponse.from(product), viewedAt); + } } diff --git a/src/main/java/com/joycrew/backend/dto/SuccessResponse.java b/src/main/java/com/joycrew/backend/dto/SuccessResponse.java index 25c59ba..8125116 100644 --- a/src/main/java/com/joycrew/backend/dto/SuccessResponse.java +++ b/src/main/java/com/joycrew/backend/dto/SuccessResponse.java @@ -4,10 +4,10 @@ @Schema(description = "Successful Operation Response DTO") public record SuccessResponse( - @Schema(description = "Success message", example = "The operation was completed successfully.") - String message + @Schema(description = "Success message", example = "The operation was completed successfully.") + String message ) { - public static SuccessResponse defaultSuccess() { - return new SuccessResponse("Processed successfully."); - } + public static SuccessResponse defaultSuccess() { + return new SuccessResponse("Processed successfully."); + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/TransactionHistoryResponse.java b/src/main/java/com/joycrew/backend/dto/TransactionHistoryResponse.java index bafb9ec..6cda645 100644 --- a/src/main/java/com/joycrew/backend/dto/TransactionHistoryResponse.java +++ b/src/main/java/com/joycrew/backend/dto/TransactionHistoryResponse.java @@ -1,16 +1,35 @@ package com.joycrew.backend.dto; import com.joycrew.backend.entity.enums.TransactionType; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import java.time.LocalDateTime; @Builder +@Schema(description = "Detailed transaction history item") public record TransactionHistoryResponse( + @Schema(description = "Transaction ID") Long transactionId, + + @Schema(description = "Transaction type") TransactionType type, + + @Schema(description = "Point amount (negative for sent/redeemed points)") int amount, + + @Schema(description = "Counterparty's name or system message") String counterparty, + + @Schema(description = "Transaction message") String message, - LocalDateTime transactionDate + + @Schema(description = "Transaction date and time") + LocalDateTime transactionDate, + + @Schema(description = "Counterparty's profile image URL (if applicable)") + String counterpartyProfileImageUrl, + + @Schema(description = "Counterparty's department name (if applicable)") + String counterpartyDepartmentName ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java b/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java index f19e244..61e5bc4 100644 --- a/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java +++ b/src/main/java/com/joycrew/backend/dto/UserProfileResponse.java @@ -7,16 +7,17 @@ @Schema(description = "User Profile Response DTO") public record UserProfileResponse( - @Schema(description = "Unique ID of the user") Long employeeId, - @Schema(description = "Name of the user") String name, - @Schema(description = "Email address of the user") String email, - @Schema(description = "URL of the profile image") String profileImageUrl, - @Schema(description = "Current total point balance") Integer totalBalance, - @Schema(description = "Current giftable point balance") Integer giftableBalance, - @Schema(description = "Role or permission level of the user") AdminLevel level, - @Schema(description = "Department name") String department, - @Schema(description = "Position or title of the user") String position, - @Schema(description = "Birth date of the user") LocalDate birthday, - @Schema(description = "Address of the user") String address, - @Schema(description = "Hire date of the user") LocalDate hireDate + @Schema(description = "Unique ID of the user") Long employeeId, + @Schema(description = "Name of the user") String name, + @Schema(description = "Email address of the user") String email, + @Schema(description = "URL of the profile image") String profileImageUrl, + @Schema(description = "Current total point balance") Integer totalBalance, + @Schema(description = "Current giftable point balance") Integer giftableBalance, + @Schema(description = "Role or permission level of the user") AdminLevel level, + @Schema(description = "Department name") String department, + @Schema(description = "Position or title of the user") String position, + @Schema(description = "Phone number of the user") String phoneNumber, + @Schema(description = "Birth date of the user") LocalDate birthday, + @Schema(description = "Address of the user") String address, + @Schema(description = "Hire date of the user") LocalDate hireDate ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/UserProfileUpdateRequest.java b/src/main/java/com/joycrew/backend/dto/UserProfileUpdateRequest.java index 72f69df..ebf7988 100644 --- a/src/main/java/com/joycrew/backend/dto/UserProfileUpdateRequest.java +++ b/src/main/java/com/joycrew/backend/dto/UserProfileUpdateRequest.java @@ -6,21 +6,21 @@ @Schema(description = "User Profile Update Request DTO") public record UserProfileUpdateRequest( - @Schema(description = "The employee's new name", example = "John Doe") - String name, + @Schema(description = "The employee's new name", example = "John Doe") + String name, - @Schema(description = "The new profile image URL") - String profileImageUrl, + @Schema(description = "The new profile image URL") + String profileImageUrl, - @Schema(description = "The new personal email address") - String personalEmail, + @Schema(description = "The new personal email address") + String personalEmail, - @Schema(description = "The new phone number") - String phoneNumber, + @Schema(description = "The new phone number") + String phoneNumber, - @Schema(description = "The new birth date") - LocalDate birthday, + @Schema(description = "The new birth date") + LocalDate birthday, - @Schema(description = "The new address") - String address + @Schema(description = "The new address") + String address ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/Company.java b/src/main/java/com/joycrew/backend/entity/Company.java index 83fbf70..b70e563 100644 --- a/src/main/java/com/joycrew/backend/entity/Company.java +++ b/src/main/java/com/joycrew/backend/entity/Company.java @@ -16,70 +16,70 @@ @Builder public class Company { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long companyId; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long companyId; - private String companyName; - private String status; - private LocalDateTime startAt; + private String companyName; + private String status; + private LocalDateTime startAt; - @Column(nullable = false) - private Double totalCompanyBalance; + @Column(nullable = false) + private Double totalCompanyBalance; - @Builder.Default - @OneToMany(mappedBy = "company") - private List employees = new ArrayList<>(); + @Builder.Default + @OneToMany(mappedBy = "company") + private List employees = new ArrayList<>(); - @Builder.Default - @OneToMany(mappedBy = "company", cascade = CascadeType.ALL, orphanRemoval = true) - private List departments = new ArrayList<>(); + @Builder.Default + @OneToMany(mappedBy = "company", cascade = CascadeType.ALL, orphanRemoval = true) + private List departments = new ArrayList<>(); - @Builder.Default - @OneToMany(mappedBy = "company", cascade = CascadeType.ALL, orphanRemoval = true) - private List adminAccessList = new ArrayList<>(); + @Builder.Default + @OneToMany(mappedBy = "company", cascade = CascadeType.ALL, orphanRemoval = true) + private List adminAccessList = new ArrayList<>(); - private LocalDateTime createdAt; - private LocalDateTime updatedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; - public void changeName(String newCompanyName) { - this.companyName = newCompanyName; - } + public void changeName(String newCompanyName) { + this.companyName = newCompanyName; + } - public void changeStatus(String newStatus) { - this.status = newStatus; - } + public void changeStatus(String newStatus) { + this.status = newStatus; + } - public void addBudget(double amount) { - if (amount < 0) { - throw new IllegalArgumentException("Budget amount cannot be negative."); - } - this.totalCompanyBalance += amount; + public void addBudget(double amount) { + if (amount < 0) { + throw new IllegalArgumentException("Budget amount cannot be negative."); } + this.totalCompanyBalance += amount; + } - public void spendBudget(double amount) { - if (amount < 0) { - throw new IllegalArgumentException("Amount to spend cannot be negative."); - } - if (this.totalCompanyBalance < amount) { - throw new InsufficientPointsException("The company does not have enough budget to distribute the points."); - } - this.totalCompanyBalance -= amount; + public void spendBudget(double amount) { + if (amount < 0) { + throw new IllegalArgumentException("Amount to spend cannot be negative."); } - - @PrePersist - protected void onCreate() { - this.createdAt = this.updatedAt = LocalDateTime.now(); - if (this.totalCompanyBalance == null) { - this.totalCompanyBalance = 0.0; - } - if (this.status == null) { - this.status = "ACTIVE"; - } + if (this.totalCompanyBalance < amount) { + throw new InsufficientPointsException("The company does not have enough budget to distribute the points."); } - - @PreUpdate - protected void onUpdate() { - this.updatedAt = LocalDateTime.now(); + this.totalCompanyBalance -= amount; + } + + @PrePersist + protected void onCreate() { + this.createdAt = this.updatedAt = LocalDateTime.now(); + if (this.totalCompanyBalance == null) { + this.totalCompanyBalance = 0.0; + } + if (this.status == null) { + this.status = "ACTIVE"; } + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/CompanyAdminAccess.java b/src/main/java/com/joycrew/backend/entity/CompanyAdminAccess.java index d5983d4..e34ab58 100644 --- a/src/main/java/com/joycrew/backend/entity/CompanyAdminAccess.java +++ b/src/main/java/com/joycrew/backend/entity/CompanyAdminAccess.java @@ -15,56 +15,56 @@ @Builder public class CompanyAdminAccess { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long accessId; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long accessId; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "employee_id", nullable = false) - private Employee employee; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "employee_id", nullable = false) + private Employee employee; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "company_id", nullable = false) - private Company company; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "company_id", nullable = false) + private Company company; - @Column(nullable = false) - @Enumerated(EnumType.STRING) - private AdminLevel adminLevel; + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private AdminLevel adminLevel; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "assigned_by", nullable = true) - private Employee assignedBy; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "assigned_by", nullable = true) + private Employee assignedBy; - @Column(nullable = false) - private LocalDateTime assignedAt; + @Column(nullable = false) + private LocalDateTime assignedAt; - @Column(nullable = false) - @Enumerated(EnumType.STRING) - private AccessStatus status; + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private AccessStatus status; - @Column(nullable = false) - private LocalDateTime createdAt; - @Column(nullable = false) - private LocalDateTime updatedAt; + @Column(nullable = false) + private LocalDateTime createdAt; + @Column(nullable = false) + private LocalDateTime updatedAt; - public void revoke() { - this.status = AccessStatus.REVOKED; - } + public void revoke() { + this.status = AccessStatus.REVOKED; + } - @PrePersist - protected void onCreate() { - this.createdAt = this.updatedAt = LocalDateTime.now(); - if (this.assignedAt == null) { - this.assignedAt = LocalDateTime.now(); - } - if (this.status == null) { - this.status = AccessStatus.ACTIVE; - } + @PrePersist + protected void onCreate() { + this.createdAt = this.updatedAt = LocalDateTime.now(); + if (this.assignedAt == null) { + this.assignedAt = LocalDateTime.now(); } - - @PreUpdate - protected void onUpdate() { - this.updatedAt = LocalDateTime.now(); + if (this.status == null) { + this.status = AccessStatus.ACTIVE; } + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/joycrew/backend/entity/Department.java b/src/main/java/com/joycrew/backend/entity/Department.java index 88d3760..6149744 100644 --- a/src/main/java/com/joycrew/backend/entity/Department.java +++ b/src/main/java/com/joycrew/backend/entity/Department.java @@ -14,50 +14,50 @@ @Builder public class Department { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long departmentId; - - @Column(nullable = false) - private String name; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "company_id", nullable = false) - private Company company; - - @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "department_head_id", unique = true) - private Employee departmentHead; - - @Builder.Default - @OneToMany(mappedBy = "department") - private List employees = new ArrayList<>(); - - @Column(nullable = false) - private LocalDateTime createdAt; - @Column(nullable = false) - private LocalDateTime updatedAt; - - public void changeName(String newName) { - this.name = newName; - } - - public void assignHead(Employee head) { - this.departmentHead = head; - } - - public void addEmployee(Employee employee) { - this.employees.add(employee); - employee.assignToDepartment(this); - } - - @PrePersist - protected void onCreate() { - this.createdAt = this.updatedAt = LocalDateTime.now(); - } - - @PreUpdate - protected void onUpdate() { - this.updatedAt = LocalDateTime.now(); - } + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long departmentId; + + @Column(nullable = false) + private String name; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "company_id", nullable = false) + private Company company; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "department_head_id", unique = true) + private Employee departmentHead; + + @Builder.Default + @OneToMany(mappedBy = "department") + private List employees = new ArrayList<>(); + + @Column(nullable = false) + private LocalDateTime createdAt; + @Column(nullable = false) + private LocalDateTime updatedAt; + + public void changeName(String newName) { + this.name = newName; + } + + public void assignHead(Employee head) { + this.departmentHead = head; + } + + public void addEmployee(Employee employee) { + this.employees.add(employee); + employee.assignToDepartment(this); + } + + @PrePersist + protected void onCreate() { + this.createdAt = this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/Employee.java b/src/main/java/com/joycrew/backend/entity/Employee.java index 55a38ba..0d93fce 100644 --- a/src/main/java/com/joycrew/backend/entity/Employee.java +++ b/src/main/java/com/joycrew/backend/entity/Employee.java @@ -19,127 +19,128 @@ @Builder public class Employee { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long employeeId; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "company_id", nullable = false) - private Company company; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "department_id", nullable = true) - private Department department; - - @Column(nullable = false) - private String passwordHash; - @Column(nullable = false) - private String employeeName; - @Column(nullable = false, unique = true) - private String email; - private String position; - @Column(nullable = false) - private String status; - @Column(nullable = false) - private AdminLevel role; - - @Column(length = 2048) - private String profileImageUrl; - private String personalEmail; - private String phoneNumber; - private String shippingAddress; - private LocalDate birthday; - private String address; - private LocalDate hireDate; - private Boolean emailNotificationEnabled; - private Boolean appNotificationEnabled; - private String language; - private String timezone; - - private LocalDateTime lastLoginAt; - @Column(nullable = false) - private LocalDateTime createdAt; - @Column(nullable = false) - private LocalDateTime updatedAt; - - @OneToOne(mappedBy = "employee", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) - private Wallet wallet; - - @Builder.Default - @OneToMany(mappedBy = "sender", cascade = CascadeType.ALL, orphanRemoval = true) - private List sentTransactions = new ArrayList<>(); - - @Builder.Default - @OneToMany(mappedBy = "receiver", cascade = CascadeType.ALL, orphanRemoval = true) - private List receivedTransactions = new ArrayList<>(); - - @Builder.Default - @OneToMany(mappedBy = "employee", cascade = CascadeType.ALL, orphanRemoval = true) - private List adminAccesses = new ArrayList<>(); - - public boolean isActive() { - return "ACTIVE".equals(this.status); - } - - public void updateLastLogin() { - this.lastLoginAt = LocalDateTime.now(); - } - - public void assignToDepartment(Department newDepartment) { - this.department = newDepartment; - } - - @PrePersist - protected void onCreate() { - this.createdAt = this.updatedAt = LocalDateTime.now(); - if (this.status == null) this.status = "ACTIVE"; - if (this.role == null) this.role = AdminLevel.EMPLOYEE; - if (this.emailNotificationEnabled == null) this.emailNotificationEnabled = true; - if (this.appNotificationEnabled == null) this.appNotificationEnabled = true; - } - - @PreUpdate - protected void onUpdate() { - this.updatedAt = LocalDateTime.now(); - } - - public void changePassword(String rawPassword, PasswordEncoder encoder) { - this.passwordHash = encoder.encode(rawPassword); - } - - public void updateName(String newName) { - this.employeeName = newName; - } - - public void updatePosition(String newPosition) { - this.position = newPosition; - } - - public void updateRole(AdminLevel newRole) { - this.role = newRole; - } - - public void updateStatus(String newStatus) { - this.status = newStatus; - } - - public void updateProfileImageUrl(String newUrl) { - this.profileImageUrl = newUrl; - } - - public void updatePersonalEmail(String newEmail) { - this.personalEmail = newEmail; - } - - public void updatePhoneNumber(String newNumber) { - this.phoneNumber = newNumber; - } - - public void updateBirthday(LocalDate birthday) { - this.birthday = birthday; - } - - public void updateAddress(String address) { - this.address = address; - } + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long employeeId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "company_id", nullable = false) + private Company company; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "department_id", nullable = true) + private Department department; + + @Column(nullable = false) + private String passwordHash; + @Column(nullable = false) + private String employeeName; + @Column(nullable = false, unique = true) + private String email; + private String position; + @Column(nullable = false) + private String status; + @Column(nullable = false, columnDefinition = "VARCHAR(255)") + @Enumerated(EnumType.STRING) + private AdminLevel role; + + @Column(length = 2048) + private String profileImageUrl; + private String personalEmail; + private String phoneNumber; + private String shippingAddress; + private LocalDate birthday; + private String address; + private LocalDate hireDate; + private Boolean emailNotificationEnabled; + private Boolean appNotificationEnabled; + private String language; + private String timezone; + + private LocalDateTime lastLoginAt; + @Column(nullable = false) + private LocalDateTime createdAt; + @Column(nullable = false) + private LocalDateTime updatedAt; + + @OneToOne(mappedBy = "employee", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private Wallet wallet; + + @Builder.Default + @OneToMany(mappedBy = "sender", cascade = CascadeType.ALL, orphanRemoval = true) + private List sentTransactions = new ArrayList<>(); + + @Builder.Default + @OneToMany(mappedBy = "receiver", cascade = CascadeType.ALL, orphanRemoval = true) + private List receivedTransactions = new ArrayList<>(); + + @Builder.Default + @OneToMany(mappedBy = "employee", cascade = CascadeType.ALL, orphanRemoval = true) + private List adminAccesses = new ArrayList<>(); + + public boolean isActive() { + return "ACTIVE".equals(this.status); + } + + public void updateLastLogin() { + this.lastLoginAt = LocalDateTime.now(); + } + + public void assignToDepartment(Department newDepartment) { + this.department = newDepartment; + } + + @PrePersist + protected void onCreate() { + this.createdAt = this.updatedAt = LocalDateTime.now(); + if (this.status == null) this.status = "ACTIVE"; + if (this.role == null) this.role = AdminLevel.EMPLOYEE; + if (this.emailNotificationEnabled == null) this.emailNotificationEnabled = true; + if (this.appNotificationEnabled == null) this.appNotificationEnabled = true; + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + public void changePassword(String rawPassword, PasswordEncoder encoder) { + this.passwordHash = encoder.encode(rawPassword); + } + + public void updateName(String newName) { + this.employeeName = newName; + } + + public void updatePosition(String newPosition) { + this.position = newPosition; + } + + public void updateRole(AdminLevel newRole) { + this.role = newRole; + } + + public void updateStatus(String newStatus) { + this.status = newStatus; + } + + public void updateProfileImageUrl(String newUrl) { + this.profileImageUrl = newUrl; + } + + public void updatePersonalEmail(String newEmail) { + this.personalEmail = newEmail; + } + + public void updatePhoneNumber(String newNumber) { + this.phoneNumber = newNumber; + } + + public void updateBirthday(LocalDate birthday) { + this.birthday = birthday; + } + + public void updateAddress(String address) { + this.address = address; + } } diff --git a/src/main/java/com/joycrew/backend/entity/Order.java b/src/main/java/com/joycrew/backend/entity/Order.java index 03a682d..e56a1ae 100644 --- a/src/main/java/com/joycrew/backend/entity/Order.java +++ b/src/main/java/com/joycrew/backend/entity/Order.java @@ -8,8 +8,8 @@ @Entity @Table(name = "orders", indexes = { - @Index(name = "idx_orders_employee", columnList = "employee_id"), - @Index(name = "idx_orders_status", columnList = "status") + @Index(name = "idx_orders_employee", columnList = "employee_id"), + @Index(name = "idx_orders_status", columnList = "status") }) @Getter @Setter @@ -18,46 +18,46 @@ @Builder public class Order { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long orderId; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long orderId; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "employee_id") - private Employee employee; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "employee_id") + private Employee employee; - // Product snapshot at the time of order - @Column(nullable = false) - private Long productId; + // Product snapshot at the time of order + @Column(nullable = false) + private Long productId; - @Column(nullable = false, length = 1000) - private String productName; + @Column(nullable = false, length = 1000) + private String productName; - @Column(nullable = false, length = 64) - private String productItemId; + @Column(nullable = false, length = 64) + private String productItemId; - @Column(nullable = false) - private Integer productUnitPrice; + @Column(nullable = false) + private Integer productUnitPrice; - @Column(nullable = false) - private Integer quantity; + @Column(nullable = false) + private Integer quantity; - @Column(nullable = false) - private Integer totalPrice; + @Column(nullable = false) + private Integer totalPrice; - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 32) - private OrderStatus status; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private OrderStatus status; - @Column(nullable = false) - private LocalDateTime orderedAt; + @Column(nullable = false) + private LocalDateTime orderedAt; - private LocalDateTime shippedAt; - private LocalDateTime deliveredAt; + private LocalDateTime shippedAt; + private LocalDateTime deliveredAt; - @PrePersist - protected void onCreate() { - if (this.orderedAt == null) this.orderedAt = LocalDateTime.now(); - if (this.status == null) this.status = OrderStatus.PLACED; - } + @PrePersist + protected void onCreate() { + if (this.orderedAt == null) this.orderedAt = LocalDateTime.now(); + if (this.status == null) this.status = OrderStatus.PLACED; + } } diff --git a/src/main/java/com/joycrew/backend/entity/Product.java b/src/main/java/com/joycrew/backend/entity/Product.java index 4cc2628..e13239f 100644 --- a/src/main/java/com/joycrew/backend/entity/Product.java +++ b/src/main/java/com/joycrew/backend/entity/Product.java @@ -16,41 +16,41 @@ @Builder public class Product { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Schema(description = "상품의 고유 ID", example = "1") - private Long id; - - @Column(nullable = false, length = 50) - @Enumerated(EnumType.STRING) - @Schema(description = "상품의 카테고리", example = "BEAUTY") - private Category keyword; - - @Column(nullable = false) - @Schema(description = "상품 순위", example = "1") - private Integer rankOrder; - - @Column(nullable = false, length = 1000) - @Schema(description = "상품명", example = "Smartphone") - private String name; - - @Column(nullable = true, length = 2000) - @Schema(description = "상품 썸네일 URL", example = "https://example.com/image.jpg") - private String thumbnailUrl; - - @Column(nullable = false) - @Schema(description = "상품 가격", example = "499") - private Integer price; - - @Column(nullable = false, length = 2000) - @Schema(description = "상품 상세 URL", example = "https://example.com/product/1") - private String detailUrl; - - @Column(nullable = false, length = 64) - @Schema(description = "상품 고유 아이템 ID", example = "12345") - private String itemId; - - @Column(nullable = false) - @Schema(description = "상품 등록 시간", example = "2025-08-11T10:00:00") - private LocalDateTime registeredAt; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Schema(description = "상품의 고유 ID", example = "1") + private Long id; + + @Column(nullable = false, length = 50) + @Enumerated(EnumType.STRING) + @Schema(description = "상품의 카테고리", example = "BEAUTY") + private Category keyword; + + @Column(nullable = false) + @Schema(description = "상품 순위", example = "1") + private Integer rankOrder; + + @Column(nullable = false, length = 1000) + @Schema(description = "상품명", example = "Smartphone") + private String name; + + @Column(nullable = true, length = 2000) + @Schema(description = "상품 썸네일 URL", example = "https://example.com/image.jpg") + private String thumbnailUrl; + + @Column(nullable = false) + @Schema(description = "상품 가격", example = "499") + private Integer price; + + @Column(nullable = false, length = 2000) + @Schema(description = "상품 상세 URL", example = "https://example.com/product/1") + private String detailUrl; + + @Column(nullable = false, length = 64) + @Schema(description = "상품 고유 아이템 ID", example = "12345") + private String itemId; + + @Column(nullable = false) + @Schema(description = "상품 등록 시간", example = "2025-08-11T10:00:00") + private LocalDateTime registeredAt; } diff --git a/src/main/java/com/joycrew/backend/entity/RecentProductView.java b/src/main/java/com/joycrew/backend/entity/RecentProductView.java index 4665d06..f339f82 100644 --- a/src/main/java/com/joycrew/backend/entity/RecentProductView.java +++ b/src/main/java/com/joycrew/backend/entity/RecentProductView.java @@ -7,11 +7,11 @@ @Entity @Table(name = "recent_product_view", - uniqueConstraints = @UniqueConstraint(name = "uq_employee_product", columnNames = {"employee_id", "product_id"}), - indexes = { - @Index(name = "idx_rpv_employee_viewed", columnList = "employee_id, viewedAt"), - @Index(name = "idx_rpv_viewed", columnList = "viewedAt") - }) + uniqueConstraints = @UniqueConstraint(name = "uq_employee_product", columnNames = {"employee_id", "product_id"}), + indexes = { + @Index(name = "idx_rpv_employee_viewed", columnList = "employee_id, viewedAt"), + @Index(name = "idx_rpv_viewed", columnList = "viewedAt") + }) @Getter @Setter @NoArgsConstructor @@ -19,25 +19,25 @@ @Builder public class RecentProductView { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - // who viewed - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "employee_id") - private Employee employee; + // who viewed + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "employee_id") + private Employee employee; - // which product - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "product_id") - private Product product; + // which product + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "product_id") + private Product product; - @Column(nullable = false) - private LocalDateTime viewedAt; + @Column(nullable = false) + private LocalDateTime viewedAt; - @PrePersist - public void onCreate() { - if (this.viewedAt == null) this.viewedAt = LocalDateTime.now(); - } + @PrePersist + public void onCreate() { + if (this.viewedAt == null) this.viewedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/joycrew/backend/entity/RewardPointTransaction.java b/src/main/java/com/joycrew/backend/entity/RewardPointTransaction.java index e889267..b495c2e 100644 --- a/src/main/java/com/joycrew/backend/entity/RewardPointTransaction.java +++ b/src/main/java/com/joycrew/backend/entity/RewardPointTransaction.java @@ -16,43 +16,43 @@ @Builder public class RewardPointTransaction { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long transactionId; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long transactionId; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "sender_id", nullable = true) - private Employee sender; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sender_id", nullable = true) + private Employee sender; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "receiver_id", nullable = false) - private Employee receiver; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "receiver_id", nullable = true) + private Employee receiver; - @Column(nullable = false) - private Integer pointAmount; + @Column(nullable = false) + private Integer pointAmount; - @Lob - private String message; + @Lob + private String message; - @Column(nullable = false) - @Enumerated(EnumType.STRING) - private TransactionType type; + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private TransactionType type; - // A collection of tags associated with the transaction. - @ElementCollection(targetClass = Tag.class, fetch = FetchType.EAGER) - @CollectionTable(name = "transaction_tags", joinColumns = @JoinColumn(name = "transaction_id")) - @Enumerated(EnumType.STRING) - @Column(name = "tag", nullable = false) - private List tags; + // A collection of tags associated with the transaction. + @ElementCollection(targetClass = Tag.class, fetch = FetchType.EAGER) + @CollectionTable(name = "transaction_tags", joinColumns = @JoinColumn(name = "transaction_id")) + @Enumerated(EnumType.STRING) + @Column(name = "tag", nullable = false) + private List tags; - @Column(nullable = false) - private LocalDateTime transactionDate; + @Column(nullable = false) + private LocalDateTime transactionDate; - @Column(nullable = false) - private LocalDateTime createdAt; + @Column(nullable = false) + private LocalDateTime createdAt; - @PrePersist - protected void onCreate() { - this.createdAt = this.transactionDate = LocalDateTime.now(); - } + @PrePersist + protected void onCreate() { + this.createdAt = this.transactionDate = LocalDateTime.now(); + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/Wallet.java b/src/main/java/com/joycrew/backend/entity/Wallet.java index f8e238b..5a7aaad 100644 --- a/src/main/java/com/joycrew/backend/entity/Wallet.java +++ b/src/main/java/com/joycrew/backend/entity/Wallet.java @@ -10,69 +10,90 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Wallet { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long walletId; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long walletId; - @OneToOne - @JoinColumn(name = "employee_id", nullable = false, unique = true) - private Employee employee; + @OneToOne + @JoinColumn(name = "employee_id", nullable = false, unique = true) + private Employee employee; - @Column(nullable = false) - private Integer balance; + @Column(nullable = false) + private Integer balance; - @Column(nullable = false) - private Integer giftablePoint; + @Column(nullable = false) + private Integer giftablePoint; - @Column(nullable = false) - private LocalDateTime createdAt; + @Column(nullable = false) + private LocalDateTime createdAt; - @Column(nullable = false) - private LocalDateTime updatedAt; + @Column(nullable = false) + private LocalDateTime updatedAt; - public Wallet(Employee employee) { - this.employee = employee; - this.balance = 0; - this.giftablePoint = 0; - } + public Wallet(Employee employee) { + this.employee = employee; + this.balance = 0; + this.giftablePoint = 0; + } - public void addPoints(int amount) { - if (amount < 0) { - throw new IllegalArgumentException("Points to add cannot be negative."); - } - this.balance += amount; - this.giftablePoint += amount; + public void addPoints(int amount) { + if (amount < 0) { + throw new IllegalArgumentException("Points to add cannot be negative."); } + this.balance += amount; + this.giftablePoint += amount; + } - public void spendPoints(int amount) { - if (amount < 0) { - throw new IllegalArgumentException("Points to spend cannot be negative."); - } - if (this.balance < amount || this.giftablePoint < amount) { - throw new InsufficientPointsException("Insufficient giftable points."); - } - this.balance -= amount; - this.giftablePoint -= amount; + public void spendGiftablePoints(int amount) { + if (amount < 0) { + throw new IllegalArgumentException("Points to spend cannot be negative."); + } + if (this.giftablePoint < amount) { + throw new InsufficientPointsException("Insufficient giftable points."); } + this.balance -= amount; + this.giftablePoint -= amount; + } - // Refund purchase points back to wallet - public void refundPoints(int amount) { - if (amount < 0) { - throw new IllegalArgumentException("Refund amount cannot be negative."); - } - this.balance += amount; - this.giftablePoint += amount; + public void purchaseWithPoints(int amount) { + if (amount < 0) { + throw new IllegalArgumentException("Purchase amount cannot be negative."); + } + if (this.balance < amount) { + throw new InsufficientPointsException("Insufficient points for purchase."); } + this.balance -= amount; + this.giftablePoint = Math.max(0, this.giftablePoint - amount); + } - @PrePersist - protected void onCreate() { - this.createdAt = this.updatedAt = LocalDateTime.now(); - if (this.balance == null) this.balance = 0; - if (this.giftablePoint == null) this.giftablePoint = 0; + public void revokePoints(int amount) { + if (amount < 0) { + throw new IllegalArgumentException("Amount to revoke cannot be negative."); } + if (this.balance < amount) { + throw new InsufficientPointsException("Insufficient balance to revoke points."); + } + this.balance -= amount; + this.giftablePoint = Math.max(0, this.giftablePoint - amount); + } - @PreUpdate - protected void onUpdate() { - this.updatedAt = LocalDateTime.now(); + public void refundPoints(int amount) { + if (amount < 0) { + throw new IllegalArgumentException("Refund amount cannot be negative."); } + this.balance += amount; + this.giftablePoint += amount; + } + + @PrePersist + protected void onCreate() { + this.createdAt = this.updatedAt = LocalDateTime.now(); + if (this.balance == null) this.balance = 0; + if (this.giftablePoint == null) this.giftablePoint = 0; + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/joycrew/backend/entity/enums/AccessStatus.java b/src/main/java/com/joycrew/backend/entity/enums/AccessStatus.java index abd5085..7235391 100644 --- a/src/main/java/com/joycrew/backend/entity/enums/AccessStatus.java +++ b/src/main/java/com/joycrew/backend/entity/enums/AccessStatus.java @@ -1,7 +1,7 @@ package com.joycrew.backend.entity.enums; public enum AccessStatus { - ACTIVE, - INACTIVE, - REVOKED + ACTIVE, + INACTIVE, + REVOKED } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/enums/AdminLevel.java b/src/main/java/com/joycrew/backend/entity/enums/AdminLevel.java index 1faf9f7..c759074 100644 --- a/src/main/java/com/joycrew/backend/entity/enums/AdminLevel.java +++ b/src/main/java/com/joycrew/backend/entity/enums/AdminLevel.java @@ -1,8 +1,8 @@ package com.joycrew.backend.entity.enums; public enum AdminLevel { - SUPER_ADMIN, - HR_ADMIN, - MANAGER, - EMPLOYEE + SUPER_ADMIN, + HR_ADMIN, + MANAGER, + EMPLOYEE } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/enums/Category.java b/src/main/java/com/joycrew/backend/entity/enums/Category.java index f795caf..a196084 100644 --- a/src/main/java/com/joycrew/backend/entity/enums/Category.java +++ b/src/main/java/com/joycrew/backend/entity/enums/Category.java @@ -6,11 +6,11 @@ @Getter @RequiredArgsConstructor public enum Category { - BEAUTY("뷰티"), - APPLIANCES("가전"), - FURNITURE("가구"), - CLOTHING("옷"), - FOOD("음식"); + BEAUTY("뷰티"), + APPLIANCES("가전"), + FURNITURE("가구"), + CLOTHING("옷"), + FOOD("음식"); - private final String kr; // 실제 쿠팡 검색에 사용할 한글 키워드 + private final String kr; } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/enums/OrderStatus.java b/src/main/java/com/joycrew/backend/entity/enums/OrderStatus.java index 12ec71e..772a1bb 100644 --- a/src/main/java/com/joycrew/backend/entity/enums/OrderStatus.java +++ b/src/main/java/com/joycrew/backend/entity/enums/OrderStatus.java @@ -1,8 +1,8 @@ package com.joycrew.backend.entity.enums; public enum OrderStatus { - PLACED, // 주문 생성(결제 완료) - SHIPPED, // 배송 중 - DELIVERED, // 배송 완료 - CANCELED // 취소(선택) + PLACED, // 주문 생성(결제 완료) + SHIPPED, // 배송 중 + DELIVERED, // 배송 완료 + CANCELED // 취소(선택) } diff --git a/src/main/java/com/joycrew/backend/entity/enums/Tag.java b/src/main/java/com/joycrew/backend/entity/enums/Tag.java index fdef3a7..8aba20d 100644 --- a/src/main/java/com/joycrew/backend/entity/enums/Tag.java +++ b/src/main/java/com/joycrew/backend/entity/enums/Tag.java @@ -1,12 +1,12 @@ package com.joycrew.backend.entity.enums; public enum Tag { - CUSTOMERS, - FLEXIBILITY, - GOALS, - EXTRAORDINARY, - TEAMWORK, - INNOVATION, - SIMPLICITY, - DELIVER_RESULTS + CUSTOMERS, + FLEXIBILITY, + GOALS, + EXTRAORDINARY, + TEAMWORK, + INNOVATION, + SIMPLICITY, + DELIVER_RESULTS } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/enums/TransactionType.java b/src/main/java/com/joycrew/backend/entity/enums/TransactionType.java index 58900fb..9ec8591 100644 --- a/src/main/java/com/joycrew/backend/entity/enums/TransactionType.java +++ b/src/main/java/com/joycrew/backend/entity/enums/TransactionType.java @@ -1,10 +1,10 @@ package com.joycrew.backend.entity.enums; public enum TransactionType { - AWARD_P2P, - AWARD_MANAGER_SPOT, - AWARD_AUTOMATED, - REDEEM_ITEM, - ADMIN_ADJUSTMENT, - EXPIRE_POINTS + AWARD_P2P, + AWARD_MANAGER_SPOT, + AWARD_AUTOMATED, + REDEEM_ITEM, + ADMIN_ADJUSTMENT, + EXPIRE_POINTS } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/event/NotificationListener.java b/src/main/java/com/joycrew/backend/event/NotificationListener.java index 510941a..d1ec29a 100644 --- a/src/main/java/com/joycrew/backend/event/NotificationListener.java +++ b/src/main/java/com/joycrew/backend/event/NotificationListener.java @@ -9,18 +9,18 @@ @Component public class NotificationListener { - @Async - @EventListener - public void handleRecognitionEvent(RecognitionEvent event) { - log.info("Recognition event received. Starting asynchronous processing."); - try { - // Simulate a delay for notification processing (e.g., sending a push notification). - Thread.sleep(2000); - log.info("User {} gifted {} points to user {}. Message: {}", - event.getSenderId(), event.getPoints(), event.getReceiverId(), event.getMessage()); - } catch (InterruptedException e) { - log.error("Error occurred while processing notification", e); - Thread.currentThread().interrupt(); - } + @Async + @EventListener + public void handleRecognitionEvent(RecognitionEvent event) { + log.info("Recognition event received. Starting asynchronous processing."); + try { + // Simulate a delay for notification processing (e.g., sending a push notification). + Thread.sleep(2000); + log.info("User {} gifted {} points to user {}. Message: {}", + event.getSenderId(), event.getPoints(), event.getReceiverId(), event.getMessage()); + } catch (InterruptedException e) { + log.error("Error occurred while processing notification", e); + Thread.currentThread().interrupt(); } + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/event/RecognitionEvent.java b/src/main/java/com/joycrew/backend/event/RecognitionEvent.java index 75a6d91..448f666 100644 --- a/src/main/java/com/joycrew/backend/event/RecognitionEvent.java +++ b/src/main/java/com/joycrew/backend/event/RecognitionEvent.java @@ -5,16 +5,16 @@ @Getter public class RecognitionEvent extends ApplicationEvent { - private final Long senderId; - private final Long receiverId; - private final int points; - private final String message; + private final Long senderId; + private final Long receiverId; + private final int points; + private final String message; - public RecognitionEvent(Object source, Long senderId, Long receiverId, int points, String message) { - super(source); - this.senderId = senderId; - this.receiverId = receiverId; - this.points = points; - this.message = message; - } + public RecognitionEvent(Object source, Long senderId, Long receiverId, int points, String message) { + super(source); + this.senderId = senderId; + this.receiverId = receiverId; + this.points = points; + this.message = message; + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java b/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java index 16d483e..962665c 100644 --- a/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java @@ -3,6 +3,8 @@ import com.joycrew.backend.dto.ErrorResponse; import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime; @@ -11,21 +13,32 @@ @RestControllerAdvice public class GlobalExceptionHandler { - @ExceptionHandler(InsufficientPointsException.class) - @ResponseStatus(HttpStatus.BAD_REQUEST) - public ErrorResponse handleInsufficientPoints(InsufficientPointsException ex, HttpServletRequest req) { - return new ErrorResponse("INSUFFICIENT_POINTS", ex.getMessage(), LocalDateTime.now(), req.getRequestURI()); - } + @ExceptionHandler(InsufficientPointsException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleInsufficientPoints(InsufficientPointsException ex, HttpServletRequest req) { + return new ErrorResponse("INSUFFICIENT_POINTS", ex.getMessage(), LocalDateTime.now(), req.getRequestURI()); + } - @ExceptionHandler(NoSuchElementException.class) - @ResponseStatus(HttpStatus.NOT_FOUND) - public ErrorResponse handleNoSuchElement(NoSuchElementException ex, HttpServletRequest req) { - return new ErrorResponse("NOT_FOUND", ex.getMessage(), LocalDateTime.now(), req.getRequestURI()); - } + @ExceptionHandler(NoSuchElementException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public ErrorResponse handleNoSuchElement(NoSuchElementException ex, HttpServletRequest req) { + return new ErrorResponse("NOT_FOUND", ex.getMessage(), LocalDateTime.now(), req.getRequestURI()); + } - @ExceptionHandler(IllegalStateException.class) - @ResponseStatus(HttpStatus.BAD_REQUEST) - public ErrorResponse handleIllegalState(IllegalStateException ex, HttpServletRequest req) { - return new ErrorResponse("ORDER_CANNOT_CANCEL", ex.getMessage(), LocalDateTime.now(), req.getRequestURI()); - } + @ExceptionHandler(IllegalStateException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleIllegalState(IllegalStateException ex, HttpServletRequest req) { + return new ErrorResponse("ORDER_CANNOT_CANCEL", ex.getMessage(), LocalDateTime.now(), req.getRequestURI()); + } + + @ExceptionHandler(BadCredentialsException.class) + public ResponseEntity handleAuthenticationFailed(BadCredentialsException ex, HttpServletRequest req) { // 1. HttpServletRequest 추가 + ErrorResponse errorResponse = new ErrorResponse( + "AUTHENTICATION_FAILED", + ex.getMessage(), + LocalDateTime.now(), + req.getRequestURI() + ); + return new ResponseEntity<>(errorResponse, HttpStatus.UNAUTHORIZED); + } } diff --git a/src/main/java/com/joycrew/backend/exception/InsufficientPointsException.java b/src/main/java/com/joycrew/backend/exception/InsufficientPointsException.java index 61430a0..c1b1829 100644 --- a/src/main/java/com/joycrew/backend/exception/InsufficientPointsException.java +++ b/src/main/java/com/joycrew/backend/exception/InsufficientPointsException.java @@ -1,7 +1,7 @@ package com.joycrew.backend.exception; public class InsufficientPointsException extends RuntimeException { - public InsufficientPointsException(String message) { - super(message); - } + public InsufficientPointsException(String message) { + super(message); + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/exception/UserNotFoundException.java b/src/main/java/com/joycrew/backend/exception/UserNotFoundException.java index c51e17d..fdabc4e 100644 --- a/src/main/java/com/joycrew/backend/exception/UserNotFoundException.java +++ b/src/main/java/com/joycrew/backend/exception/UserNotFoundException.java @@ -1,7 +1,7 @@ package com.joycrew.backend.exception; public class UserNotFoundException extends RuntimeException { - public UserNotFoundException(String message) { - super(message); - } + public UserNotFoundException(String message) { + super(message); + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/repository/CompanyRepository.java b/src/main/java/com/joycrew/backend/repository/CompanyRepository.java index d9c6bec..e00083e 100644 --- a/src/main/java/com/joycrew/backend/repository/CompanyRepository.java +++ b/src/main/java/com/joycrew/backend/repository/CompanyRepository.java @@ -6,5 +6,5 @@ import java.util.Optional; public interface CompanyRepository extends JpaRepository { - Optional findByCompanyName(String name); + Optional findByCompanyName(String name); } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java b/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java index 6ed591f..3ed066e 100644 --- a/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java +++ b/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java @@ -8,6 +8,6 @@ import java.util.Optional; public interface DepartmentRepository extends JpaRepository { - List findAllByCompanyCompanyId(Long companyId); - Optional findByCompanyAndName(Company company, String name); + List findAllByCompanyCompanyId(Long companyId); + Optional findByCompanyAndName(Company company, String name); } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/repository/EmployeeQueryRepository.java b/src/main/java/com/joycrew/backend/repository/EmployeeQueryRepository.java index 3d6b5e2..fab9d0c 100644 --- a/src/main/java/com/joycrew/backend/repository/EmployeeQueryRepository.java +++ b/src/main/java/com/joycrew/backend/repository/EmployeeQueryRepository.java @@ -1,9 +1,73 @@ package com.joycrew.backend.repository; +import com.joycrew.backend.dto.EmployeeQueryResponse; +import com.joycrew.backend.dto.PagedEmployeeResponse; import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.service.mapper.EmployeeMapper; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.TypedQuery; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; import java.util.List; +import java.util.stream.Collectors; -public interface EmployeeQueryRepository { - List searchByKeyword(String keyword, int offset, int limit); -} +@Repository +@RequiredArgsConstructor +public class EmployeeQueryRepository { + + @PersistenceContext + private final EntityManager em; + private final EmployeeMapper employeeMapper; + + public PagedEmployeeResponse getEmployees(String keyword, int page, int size, Long currentEmployeeId) { + StringBuilder whereClause = new StringBuilder(); + boolean hasKeyword = StringUtils.hasText(keyword); + + // Base condition to exclude the current user + whereClause.append("WHERE e.id != :currentEmployeeId "); + + // Dynamic condition for keyword search + if (hasKeyword) { + whereClause.append("AND (LOWER(e.employeeName) LIKE :keyword ") + .append("OR LOWER(e.email) LIKE :keyword ") + .append("OR LOWER(e.department.name) LIKE :keyword) "); + } + + // Count Query + String countJpql = "SELECT COUNT(e) FROM Employee e LEFT JOIN e.department d " + whereClause; + TypedQuery countQuery = em.createQuery(countJpql, Long.class); + countQuery.setParameter("currentEmployeeId", currentEmployeeId); + if (hasKeyword) { + countQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); + } + long totalCount = countQuery.getSingleResult(); + int totalPages = (int) Math.ceil((double) totalCount / size); + + // Data Query + String dataJpql = "SELECT e FROM Employee e " + + "LEFT JOIN FETCH e.department d " + + whereClause + + "ORDER BY e.employeeName ASC"; + TypedQuery dataQuery = em.createQuery(dataJpql, Employee.class); + dataQuery.setParameter("currentEmployeeId", currentEmployeeId); + if (hasKeyword) { + dataQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); + } + dataQuery.setFirstResult(page * size); + dataQuery.setMaxResults(size); + + List employees = dataQuery.getResultList().stream() + .map(employeeMapper::toEmployeeQueryResponse) + .collect(Collectors.toList()); + + return new PagedEmployeeResponse( + employees, + page, + totalPages, + page >= totalPages - 1 + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java b/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java index f62f1e3..dcc2e17 100644 --- a/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java +++ b/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java @@ -1,12 +1,17 @@ package com.joycrew.backend.repository; import com.joycrew.backend.entity.Employee; -import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Optional; public interface EmployeeRepository extends JpaRepository { - @EntityGraph(attributePaths = {"company", "department"}) - Optional findByEmail(String email); -} \ No newline at end of file + Optional findByEmail(String email); + + @Query("SELECT e FROM Employee e WHERE e.employeeName LIKE %:keyword% OR e.email LIKE %:keyword% OR e.department.name LIKE %:keyword%") + Page findByKeyword(@Param("keyword") String keyword, Pageable pageable); +} diff --git a/src/main/java/com/joycrew/backend/repository/OrderRepository.java b/src/main/java/com/joycrew/backend/repository/OrderRepository.java index f95af16..18f09c9 100644 --- a/src/main/java/com/joycrew/backend/repository/OrderRepository.java +++ b/src/main/java/com/joycrew/backend/repository/OrderRepository.java @@ -9,7 +9,7 @@ public interface OrderRepository extends JpaRepository { - Page findByEmployee_EmployeeId(Long employeeId, Pageable pageable); + Page findByEmployee_EmployeeId(Long employeeId, Pageable pageable); - Optional findByOrderIdAndEmployee_EmployeeId(Long orderId, Long employeeId); + Optional findByOrderIdAndEmployee_EmployeeId(Long orderId, Long employeeId); } diff --git a/src/main/java/com/joycrew/backend/repository/ProductRepository.java b/src/main/java/com/joycrew/backend/repository/ProductRepository.java index 889602a..cb060a9 100644 --- a/src/main/java/com/joycrew/backend/repository/ProductRepository.java +++ b/src/main/java/com/joycrew/backend/repository/ProductRepository.java @@ -5,7 +5,33 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface ProductRepository extends JpaRepository { - Page findByKeyword(Category keyword, Pageable pageable); + + Page findByKeyword(Category keyword, Pageable pageable); + + @Query(""" + SELECT p + FROM Product p + WHERE (:q IS NULL OR :q = '') + OR LOWER(p.name) LIKE LOWER(CONCAT('%', :q, '%')) + OR p.itemId LIKE CONCAT('%', :q, '%') + """) + Page searchByQuery(@Param("q") String q, Pageable pageable); + + @Query(""" + SELECT p + FROM Product p + WHERE p.keyword = :category + AND ( + :q IS NULL OR :q = '' + OR LOWER(p.name) LIKE LOWER(CONCAT('%', :q, '%')) + OR p.itemId LIKE CONCAT('%', :q, '%') + ) + """) + Page searchByCategoryAndQuery(@Param("category") Category category, + @Param("q") String q, + Pageable pageable); } diff --git a/src/main/java/com/joycrew/backend/repository/RecentProductViewRepository.java b/src/main/java/com/joycrew/backend/repository/RecentProductViewRepository.java index 5f3563d..c6098e8 100644 --- a/src/main/java/com/joycrew/backend/repository/RecentProductViewRepository.java +++ b/src/main/java/com/joycrew/backend/repository/RecentProductViewRepository.java @@ -10,11 +10,11 @@ public interface RecentProductViewRepository extends JpaRepository { - Optional findByEmployee_EmployeeIdAndProduct_Id(Long employeeId, Long productId); + Optional findByEmployee_EmployeeIdAndProduct_Id(Long employeeId, Long productId); - List findByEmployee_EmployeeIdAndViewedAtAfterOrderByViewedAtDesc( - Long employeeId, LocalDateTime threshold, Pageable pageable - ); + List findByEmployee_EmployeeIdAndViewedAtAfterOrderByViewedAtDesc( + Long employeeId, LocalDateTime threshold, Pageable pageable + ); - long deleteByViewedAtBefore(LocalDateTime threshold); + long deleteByViewedAtBefore(LocalDateTime threshold); } diff --git a/src/main/java/com/joycrew/backend/repository/RewardPointTransactionRepository.java b/src/main/java/com/joycrew/backend/repository/RewardPointTransactionRepository.java index e91b3e0..9f78788 100644 --- a/src/main/java/com/joycrew/backend/repository/RewardPointTransactionRepository.java +++ b/src/main/java/com/joycrew/backend/repository/RewardPointTransactionRepository.java @@ -8,11 +8,11 @@ import java.util.List; public interface RewardPointTransactionRepository extends JpaRepository { - @EntityGraph(attributePaths = {"sender", "receiver"}) - List findBySenderOrReceiverOrderByTransactionDateDesc(Employee sender, Employee receiver); + @EntityGraph(attributePaths = {"sender", "receiver"}) + List findBySenderOrReceiverOrderByTransactionDateDesc(Employee sender, Employee receiver); - List findAllByOrderByTransactionDateDesc(); + List findAllByOrderByTransactionDateDesc(); - @EntityGraph(attributePaths = {"sender", "receiver"}) - List findBySenderOrReceiver(Employee sender, Employee receiver); + @EntityGraph(attributePaths = {"sender", "receiver"}) + List findBySenderOrReceiver(Employee sender, Employee receiver); } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/repository/WalletRepository.java b/src/main/java/com/joycrew/backend/repository/WalletRepository.java index e9bf7e3..851d09b 100644 --- a/src/main/java/com/joycrew/backend/repository/WalletRepository.java +++ b/src/main/java/com/joycrew/backend/repository/WalletRepository.java @@ -6,5 +6,5 @@ import java.util.Optional; public interface WalletRepository extends JpaRepository { - Optional findByEmployee_EmployeeId(Long employeeId); + Optional findByEmployee_EmployeeId(Long employeeId); } diff --git a/src/main/java/com/joycrew/backend/scheduler/RecentViewCleanupJob.java b/src/main/java/com/joycrew/backend/scheduler/RecentViewCleanupJob.java index db20444..53c2a91 100644 --- a/src/main/java/com/joycrew/backend/scheduler/RecentViewCleanupJob.java +++ b/src/main/java/com/joycrew/backend/scheduler/RecentViewCleanupJob.java @@ -11,16 +11,16 @@ @RequiredArgsConstructor public class RecentViewCleanupJob { - private final RecentProductViewService recentProductViewService; + private final RecentProductViewService recentProductViewService; - // 매일 새벽 03:00 (서울 시간대) - @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul") - public void cleanupOldRecentViews() { - long deleted = recentProductViewService.cleanupOldViews(); - if (deleted > 0) { - log.info("Cleaned up {} recent product views older than 3 months.", deleted); - } else { - log.debug("No recent product views to clean."); - } + // 매일 새벽 03:00 (서울 시간대) + @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul") + public void cleanupOldRecentViews() { + long deleted = recentProductViewService.cleanupOldViews(); + if (deleted > 0) { + log.info("Cleaned up {} recent product views older than 3 months.", deleted); + } else { + log.debug("No recent product views to clean."); } + } } diff --git a/src/main/java/com/joycrew/backend/security/EmployeeDetailsService.java b/src/main/java/com/joycrew/backend/security/EmployeeDetailsService.java index aec4968..febab59 100644 --- a/src/main/java/com/joycrew/backend/security/EmployeeDetailsService.java +++ b/src/main/java/com/joycrew/backend/security/EmployeeDetailsService.java @@ -13,14 +13,14 @@ @RequiredArgsConstructor public class EmployeeDetailsService implements UserDetailsService { - private final EmployeeRepository employeeRepository; + private final EmployeeRepository employeeRepository; - @Override - @Transactional(readOnly = true) - public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { - Employee employee = employeeRepository.findByEmail(email) - .orElseThrow(() -> new UsernameNotFoundException("User not found with email: " + email)); + @Override + @Transactional(readOnly = true) + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + Employee employee = employeeRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("User not found with email: " + email)); - return new UserPrincipal(employee); - } + return new UserPrincipal(employee); + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java b/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java index 6ecd20e..5f4a2e2 100644 --- a/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java @@ -21,44 +21,44 @@ @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { - private final JwtUtil jwtUtil; - private final UserDetailsService userDetailsService; + private final JwtUtil jwtUtil; + private final UserDetailsService userDetailsService; - @Override - protected void doFilterInternal(HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain) - throws ServletException, IOException { + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { - String authHeader = request.getHeader("Authorization"); + String authHeader = request.getHeader("Authorization"); - if (authHeader == null || !authHeader.startsWith("Bearer ") || SecurityContextHolder.getContext().getAuthentication() != null) { - filterChain.doFilter(request, response); - return; - } - - String token = authHeader.substring(7); - String email = null; - try { - email = jwtUtil.getEmailFromToken(token); - } catch (ExpiredJwtException e) { - log.warn("JWT token has expired: {}", e.getMessage()); - } catch (JwtException e) { - log.warn("Invalid JWT token: {}", e.getMessage()); - } + if (authHeader == null || !authHeader.startsWith("Bearer ") || SecurityContextHolder.getContext().getAuthentication() != null) { + filterChain.doFilter(request, response); + return; + } - if (email != null) { - UserDetails userDetails = this.userDetailsService.loadUserByUsername(email); + String token = authHeader.substring(7); + String email = null; + try { + email = jwtUtil.getEmailFromToken(token); + } catch (ExpiredJwtException e) { + log.warn("JWT token has expired: {}", e.getMessage()); + } catch (JwtException e) { + log.warn("Invalid JWT token: {}", e.getMessage()); + } - UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( - userDetails, - null, - userDetails.getAuthorities() - ); - authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(authentication); - } + if (email != null) { + UserDetails userDetails = this.userDetailsService.loadUserByUsername(email); - filterChain.doFilter(request, response); + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); } + + filterChain.doFilter(request, response); + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/security/JwtUtil.java b/src/main/java/com/joycrew/backend/security/JwtUtil.java index 388e87d..6bb5bf6 100644 --- a/src/main/java/com/joycrew/backend/security/JwtUtil.java +++ b/src/main/java/com/joycrew/backend/security/JwtUtil.java @@ -13,39 +13,39 @@ @Component public class JwtUtil { - @Value("${jwt.secret}") - private String secretKey; - - @Value("${jwt.expiration-ms}") - private long expirationTime; - - private SecretKey getSigningKey() { - byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8); - return Keys.hmacShaKeyFor(keyBytes); - } - - public String generateToken(String email) { - return generateToken(email, expirationTime); - } - - public String generateToken(String email, long customExpirationMs) { - Date now = new Date(); - Date expiryDate = new Date(now.getTime() + customExpirationMs); - - return Jwts.builder() - .setSubject(email) - .setIssuedAt(now) - .setExpiration(expiryDate) - .signWith(getSigningKey(), SignatureAlgorithm.HS256) - .compact(); - } - - public String getEmailFromToken(String token) { - return Jwts.parserBuilder() - .setSigningKey(getSigningKey()) - .build() - .parseClaimsJws(token) - .getBody() - .getSubject(); - } + @Value("${jwt.secret}") + private String secretKey; + + @Value("${jwt.expiration-ms}") + private long expirationTime; + + private SecretKey getSigningKey() { + byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8); + return Keys.hmacShaKeyFor(keyBytes); + } + + public String generateToken(String email) { + return generateToken(email, expirationTime); + } + + public String generateToken(String email, long customExpirationMs) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + customExpirationMs); + + return Jwts.builder() + .setSubject(email) + .setIssuedAt(now) + .setExpiration(expiryDate) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .compact(); + } + + public String getEmailFromToken(String token) { + return Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody() + .getSubject(); + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/security/UserPrincipal.java b/src/main/java/com/joycrew/backend/security/UserPrincipal.java index 67b40ab..e0886e3 100644 --- a/src/main/java/com/joycrew/backend/security/UserPrincipal.java +++ b/src/main/java/com/joycrew/backend/security/UserPrincipal.java @@ -12,44 +12,44 @@ @Getter public class UserPrincipal implements UserDetails { - private final Employee employee; - - public UserPrincipal(Employee employee) { - this.employee = employee; - } - - @Override - public Collection getAuthorities() { - return Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + employee.getRole().name())); - } - - @Override - public String getPassword() { - return employee.getPasswordHash(); - } - - @Override - public String getUsername() { - return employee.getEmail(); - } - - @Override - public boolean isEnabled() { - return employee.isActive(); - } - - @Override - public boolean isAccountNonExpired() { - return true; - } - - @Override - public boolean isAccountNonLocked() { - return true; - } - - @Override - public boolean isCredentialsNonExpired() { - return true; - } + private final Employee employee; + + public UserPrincipal(Employee employee) { + this.employee = employee; + } + + @Override + public Collection getAuthorities() { + return Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + employee.getRole().name())); + } + + @Override + public String getPassword() { + return employee.getPasswordHash(); + } + + @Override + public String getUsername() { + return employee.getEmail(); + } + + @Override + public boolean isEnabled() { + return employee.isActive(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/AdminDashboardService.java b/src/main/java/com/joycrew/backend/service/AdminDashboardService.java index 5738e7d..0ce0a50 100644 --- a/src/main/java/com/joycrew/backend/service/AdminDashboardService.java +++ b/src/main/java/com/joycrew/backend/service/AdminDashboardService.java @@ -16,33 +16,33 @@ @Transactional(readOnly = true) public class AdminDashboardService { - private final EmployeeRepository employeeRepository; - private final WalletRepository walletRepository; + private final EmployeeRepository employeeRepository; + private final WalletRepository walletRepository; - /** - * Fetches both the company's total point budget and the admin's personal wallet balance. - * @param adminEmail The email of the currently logged-in administrator. - * @return A DTO containing both company and personal point balances. - */ - public AdminPointBudgetResponse getAdminAndCompanyBalance(String adminEmail) { - // 1. Fetch the admin employee and their associated company - Employee admin = employeeRepository.findByEmail(adminEmail) - .orElseThrow(() -> new UserNotFoundException("Admin user not found.")); + /** + * Fetches both the company's total point budget and the admin's personal wallet balance. + * @param adminEmail The email of the currently logged-in administrator. + * @return A DTO containing both company and personal point balances. + */ + public AdminPointBudgetResponse getAdminAndCompanyBalance(String adminEmail) { + // 1. Fetch the admin employee and their associated company + Employee admin = employeeRepository.findByEmail(adminEmail) + .orElseThrow(() -> new UserNotFoundException("Admin user not found.")); - Company company = admin.getCompany(); - if (company == null) { - throw new IllegalStateException("Admin is not associated with any company."); - } + Company company = admin.getCompany(); + if (company == null) { + throw new IllegalStateException("Admin is not associated with any company."); + } - // 2. Fetch the admin's personal wallet - Wallet adminWallet = walletRepository.findByEmployee_EmployeeId(admin.getEmployeeId()) - .orElse(new Wallet(admin)); // If no wallet, create a new one with 0 points + // 2. Fetch the admin's personal wallet + Wallet adminWallet = walletRepository.findByEmployee_EmployeeId(admin.getEmployeeId()) + .orElse(new Wallet(admin)); // If no wallet, create a new one with 0 points - // 3. Create and return the combined response DTO - return new AdminPointBudgetResponse( - company.getTotalCompanyBalance(), - adminWallet.getBalance(), - adminWallet.getGiftablePoint() - ); - } + // 3. Create and return the combined response DTO + return new AdminPointBudgetResponse( + company.getTotalCompanyBalance(), + adminWallet.getBalance(), + adminWallet.getGiftablePoint() + ); + } } diff --git a/src/main/java/com/joycrew/backend/service/AdminPointService.java b/src/main/java/com/joycrew/backend/service/AdminPointService.java index 458ae37..1a8da4f 100644 --- a/src/main/java/com/joycrew/backend/service/AdminPointService.java +++ b/src/main/java/com/joycrew/backend/service/AdminPointService.java @@ -26,58 +26,58 @@ @Transactional public class AdminPointService { - private final EmployeeRepository employeeRepository; - private final WalletRepository walletRepository; - private final RewardPointTransactionRepository transactionRepository; - private final CompanyRepository companyRepository; + private final EmployeeRepository employeeRepository; + private final WalletRepository walletRepository; + private final RewardPointTransactionRepository transactionRepository; + private final CompanyRepository companyRepository; - public void distributePoints(AdminPointDistributionRequest request, Employee admin) { - int netPointsChange = request.distributions().stream() - .mapToInt(PointDistributionDetail::points) - .sum(); + public void distributePoints(AdminPointDistributionRequest request, Employee admin) { + int netPointsChange = request.distributions().stream() + .mapToInt(PointDistributionDetail::points) + .sum(); - Company company = admin.getCompany(); - if (netPointsChange > 0) { - company.spendBudget(netPointsChange); - } else if (netPointsChange < 0) { - company.addBudget(Math.abs(netPointsChange)); - } - companyRepository.save(company); + Company company = admin.getCompany(); + if (netPointsChange > 0) { + company.spendBudget(netPointsChange); + } else if (netPointsChange < 0) { + company.addBudget(Math.abs(netPointsChange)); + } + companyRepository.save(company); - List employeeIds = request.distributions().stream() - .map(PointDistributionDetail::employeeId) - .toList(); + List employeeIds = request.distributions().stream() + .map(PointDistributionDetail::employeeId) + .toList(); - Map employeeMap = employeeRepository.findAllById(employeeIds).stream() - .collect(Collectors.toMap(Employee::getEmployeeId, Function.identity())); + Map employeeMap = employeeRepository.findAllById(employeeIds).stream() + .collect(Collectors.toMap(Employee::getEmployeeId, Function.identity())); - if (employeeMap.size() != employeeIds.size()) { - throw new UserNotFoundException("Could not find some of the requested employees. Please verify the IDs."); - } + if (employeeMap.size() != employeeIds.size()) { + throw new UserNotFoundException("Could not find some of the requested employees. Please verify the IDs."); + } - for (PointDistributionDetail detail : request.distributions()) { - Employee employee = employeeMap.get(detail.employeeId()); - Wallet wallet = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) - .orElseThrow(() -> new IllegalStateException("Wallet not found for employee: " + employee.getEmployeeName())); + for (PointDistributionDetail detail : request.distributions()) { + Employee employee = employeeMap.get(detail.employeeId()); + Wallet wallet = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) + .orElseThrow(() -> new IllegalStateException("Wallet not found for employee: " + employee.getEmployeeName())); - int pointsToProcess = detail.points(); + int pointsToProcess = detail.points(); - if (pointsToProcess > 0) { - wallet.addPoints(pointsToProcess); - } else if (pointsToProcess < 0) { - wallet.spendPoints(Math.abs(pointsToProcess)); - } + if (pointsToProcess > 0) { + wallet.addPoints(pointsToProcess); + } else if (pointsToProcess < 0) { + wallet.revokePoints(Math.abs(pointsToProcess)); + } - if (pointsToProcess != 0) { - RewardPointTransaction transaction = RewardPointTransaction.builder() - .sender(admin) - .receiver(employee) - .pointAmount(pointsToProcess) - .message(request.message()) - .type(TransactionType.AWARD_MANAGER_SPOT) - .build(); - transactionRepository.save(transaction); - } - } + if (pointsToProcess != 0) { + RewardPointTransaction transaction = RewardPointTransaction.builder() + .sender(admin) + .receiver(employee) + .pointAmount(pointsToProcess) + .message(request.message()) + .type(TransactionType.AWARD_MANAGER_SPOT) + .build(); + transactionRepository.save(transaction); + } } + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/AuthService.java b/src/main/java/com/joycrew/backend/service/AuthService.java index 12646f6..dfb4c8d 100644 --- a/src/main/java/com/joycrew/backend/service/AuthService.java +++ b/src/main/java/com/joycrew/backend/service/AuthService.java @@ -28,85 +28,92 @@ @RequiredArgsConstructor public class AuthService { - private static final Logger log = LoggerFactory.getLogger(AuthService.class); - - @Value("${jwt.password-reset-expiration-ms}") - private long passwordResetExpirationMs; - - private final JwtUtil jwtUtil; - private final AuthenticationManager authenticationManager; - private final WalletRepository walletRepository; - private final EmployeeRepository employeeRepository; - private final PasswordEncoder passwordEncoder; - private final EmailService emailService; - - @Transactional - public LoginResponse login(LoginRequest request) { - log.info("Attempting login for email: {}", request.email()); - - try { - Authentication authentication = authenticationManager.authenticate( - new UsernamePasswordAuthenticationToken(request.email(), request.password()) - ); - - UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal(); - Employee employee = userPrincipal.getEmployee(); - - Integer totalPoint = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) - .map(Wallet::getBalance) - .orElse(0); - - employee.updateLastLogin(); - - String accessToken = jwtUtil.generateToken(employee.getEmail()); - - return new LoginResponse( - accessToken, - "Login successful", - employee.getEmployeeId(), - employee.getEmployeeName(), - employee.getEmail(), - employee.getRole(), - totalPoint, - employee.getProfileImageUrl() - ); - - } catch (UsernameNotFoundException | BadCredentialsException e) { - log.warn("Login failed for email {}: {}", request.email(), e.getMessage()); - throw e; - } + private static final Logger log = LoggerFactory.getLogger(AuthService.class); + + @Value("${jwt.password-reset-expiration-ms}") + private long passwordResetExpirationMs; + + private final JwtUtil jwtUtil; + private final AuthenticationManager authenticationManager; + private final WalletRepository walletRepository; + private final EmployeeRepository employeeRepository; + private final PasswordEncoder passwordEncoder; + private final EmailService emailService; + + @Transactional + public LoginResponse login(LoginRequest request) { + log.info("Attempting login for email: {}", request.email()); + + if ("dev@joycrew.co.kr".equals(request.email())) { + String correctHash = passwordEncoder.encode("password123!"); + log.warn("================== DEBUG HASH =================="); + log.warn("Correct hash for 'password123!': {}", correctHash); + log.warn("================================================"); } - public void logout(HttpServletRequest request) { - final String authHeader = request.getHeader("Authorization"); - if (authHeader != null && authHeader.startsWith("Bearer ")) { - String jwt = authHeader.substring(7); - log.info("Logout request received. Token blacklisting can be implemented here if needed."); - } - } + try { + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.email(), request.password()) + ); - @Transactional(readOnly = true) - public void requestPasswordReset(String email) { - employeeRepository.findByEmail(email).ifPresent(employee -> { - String token = jwtUtil.generateToken(email, passwordResetExpirationMs); - emailService.sendPasswordResetEmail(email, token); - log.info("Password reset requested for email: {}", email); - }); - } + UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal(); + Employee employee = userPrincipal.getEmployee(); + + Integer totalPoint = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) + .map(Wallet::getBalance) + .orElse(0); - @Transactional - public void confirmPasswordReset(String token, String newPassword) { - String email; - try { - email = jwtUtil.getEmailFromToken(token); - } catch (JwtException e) { - throw new BadCredentialsException("Invalid or expired token.", e); - } + employee.updateLastLogin(); - Employee employee = employeeRepository.findByEmail(email) - .orElseThrow(() -> new UserNotFoundException("User not found.")); + String accessToken = jwtUtil.generateToken(employee.getEmail()); - employee.changePassword(newPassword, passwordEncoder); - log.info("Password has been reset for: {}", email); + return new LoginResponse( + accessToken, + "Login successful", + employee.getEmployeeId(), + employee.getEmployeeName(), + employee.getEmail(), + employee.getRole(), + totalPoint, + employee.getProfileImageUrl() + ); + + } catch (UsernameNotFoundException | BadCredentialsException e) { + log.warn("Login failed for email {}: {}", request.email(), e.getMessage()); + throw e; } + } + + public void logout(HttpServletRequest request) { + final String authHeader = request.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String jwt = authHeader.substring(7); + log.info("Logout request received. Token blacklisting can be implemented here if needed."); + } + } + + @Transactional(readOnly = true) + public void requestPasswordReset(String email) { + employeeRepository.findByEmail(email).ifPresent(employee -> { + String token = jwtUtil.generateToken(email, passwordResetExpirationMs); + emailService.sendPasswordResetEmail(email, token); + log.info("Password reset requested for email: {}", email); + }); + } + + @Transactional + public void confirmPasswordReset(String token, String newPassword) { + String email; + try { + email = jwtUtil.getEmailFromToken(token); + } catch (JwtException e) { + throw new BadCredentialsException("Invalid or expired token.", e); + } + + Employee employee = employeeRepository.findByEmail(email) + .orElseThrow(() -> new UserNotFoundException("User not found.")); + + employee.changePassword(newPassword, passwordEncoder); + log.info("Password has been reset for: {}", email); + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/EmailService.java b/src/main/java/com/joycrew/backend/service/EmailService.java index 32b8953..651ec56 100644 --- a/src/main/java/com/joycrew/backend/service/EmailService.java +++ b/src/main/java/com/joycrew/backend/service/EmailService.java @@ -13,26 +13,26 @@ @RequiredArgsConstructor public class EmailService { - private static final Logger log = LoggerFactory.getLogger(EmailService.class); - private final JavaMailSender mailSender; + private static final Logger log = LoggerFactory.getLogger(EmailService.class); + private final JavaMailSender mailSender; - @Value("${app.frontend-url}") - private String frontendUrlBase; + @Value("${app.frontend-url}") + private String frontendUrlBase; - @Async("taskExecutor") - public void sendPasswordResetEmail(String toEmail, String token) { - String resetUrl = frontendUrlBase + "/reset-password?token=" + token; + @Async("taskExecutor") + public void sendPasswordResetEmail(String toEmail, String token) { + String resetUrl = frontendUrlBase + "/reset-password?token=" + token; - SimpleMailMessage message = new SimpleMailMessage(); - message.setTo(toEmail); - message.setSubject("[JoyCrew] Password Reset Instructions"); - message.setText("To reset your password, please click the link below. This link is valid for 15 minutes.\n\n" + resetUrl); + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(toEmail); + message.setSubject("[JoyCrew] Password Reset Instructions"); + message.setText("To reset your password, please click the link below. This link is valid for 15 minutes.\n\n" + resetUrl); - try { - mailSender.send(message); - log.info("Password reset email sent successfully to: {}", toEmail); - } catch (Exception e) { - log.error("Failed to send password reset email to: {}", toEmail, e); - } + try { + mailSender.send(message); + log.info("Password reset email sent successfully to: {}", toEmail); + } catch (Exception e) { + log.error("Failed to send password reset email to: {}", toEmail, e); } + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/EmployeeManagementService.java b/src/main/java/com/joycrew/backend/service/EmployeeManagementService.java index 67f5034..e1ccc99 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeManagementService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeManagementService.java @@ -23,87 +23,87 @@ @Transactional public class EmployeeManagementService { - private final EmployeeRepository employeeRepository; - private final DepartmentRepository departmentRepository; - private final EmployeeMapper employeeMapper; - @PersistenceContext - private final EntityManager em; + private final EmployeeRepository employeeRepository; + private final DepartmentRepository departmentRepository; + private final EmployeeMapper employeeMapper; + @PersistenceContext + private final EntityManager em; - @Transactional(readOnly = true) - public AdminPagedEmployeeResponse searchEmployees(String keyword, int page, int size) { - StringBuilder whereClause = new StringBuilder("WHERE 1=1 "); - if (keyword != null && !keyword.isBlank()) { - whereClause.append("AND (LOWER(e.employeeName) LIKE :keyword ") - .append("OR LOWER(e.email) LIKE :keyword ") - .append("OR LOWER(d.name) LIKE :keyword) "); - } + @Transactional(readOnly = true) + public AdminPagedEmployeeResponse searchEmployees(String keyword, int page, int size) { + StringBuilder whereClause = new StringBuilder("WHERE 1=1 "); + if (keyword != null && !keyword.isBlank()) { + whereClause.append("AND (LOWER(e.employeeName) LIKE :keyword ") + .append("OR LOWER(e.email) LIKE :keyword ") + .append("OR LOWER(d.name) LIKE :keyword) "); + } - String countJpql = "SELECT COUNT(e) FROM Employee e LEFT JOIN e.department d " + whereClause; - TypedQuery countQuery = em.createQuery(countJpql, Long.class); - if (keyword != null && !keyword.isBlank()) { - countQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); - } - long total = countQuery.getSingleResult(); - int totalPages = (int) Math.ceil((double) total / size); + String countJpql = "SELECT COUNT(e) FROM Employee e LEFT JOIN e.department d " + whereClause; + TypedQuery countQuery = em.createQuery(countJpql, Long.class); + if (keyword != null && !keyword.isBlank()) { + countQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); + } + long total = countQuery.getSingleResult(); + int totalPages = (int) Math.ceil((double) total / size); - String dataJpql = "SELECT e FROM Employee e " + - "LEFT JOIN FETCH e.department d " + - "LEFT JOIN FETCH e.company c " + - whereClause + - "ORDER BY e.employeeName ASC"; - TypedQuery dataQuery = em.createQuery(dataJpql, Employee.class) - .setFirstResult(page * size) - .setMaxResults(size); - if (keyword != null && !keyword.isBlank()) { - dataQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); - } + String dataJpql = "SELECT e FROM Employee e " + + "LEFT JOIN FETCH e.department d " + + "LEFT JOIN FETCH e.company c " + + whereClause + + "ORDER BY e.employeeName ASC"; + TypedQuery dataQuery = em.createQuery(dataJpql, Employee.class) + .setFirstResult(page * size) + .setMaxResults(size); + if (keyword != null && !keyword.isBlank()) { + dataQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); + } - List employees = dataQuery.getResultList().stream() - .map(employeeMapper::toAdminEmployeeQueryResponse) // Use Mapper - .toList(); + List employees = dataQuery.getResultList().stream() + .map(employeeMapper::toAdminEmployeeQueryResponse) // Use Mapper + .toList(); - return new AdminPagedEmployeeResponse( - employees, - page, // Return 0-based page index for consistency - totalPages, - page >= totalPages - 1 - ); - } + return new AdminPagedEmployeeResponse( + employees, + page, // Return 0-based page index for consistency + totalPages, + page >= totalPages - 1 + ); + } - public Employee updateEmployee(Long employeeId, AdminEmployeeUpdateRequest request) { - Employee employee = employeeRepository.findById(employeeId) - .orElseThrow(() -> new UserNotFoundException("Employee not found with ID: " + employeeId)); + public Employee updateEmployee(Long employeeId, AdminEmployeeUpdateRequest request) { + Employee employee = employeeRepository.findById(employeeId) + .orElseThrow(() -> new UserNotFoundException("Employee not found with ID: " + employeeId)); - if (request.name() != null) { - employee.updateName(request.name()); - } - if (request.departmentId() != null) { - Department department = departmentRepository.findById(request.departmentId()) - .orElseThrow(() -> new IllegalArgumentException("Department not found with ID: " + request.departmentId())); - employee.assignToDepartment(department); - } - if (request.position() != null) { - employee.updatePosition(request.position()); - } - if (request.level() != null) { - employee.updateRole(request.level()); - } - if (request.status() != null) { - employee.updateStatus(request.status()); - } - return employee; // @Transactional will handle the save + if (request.name() != null) { + employee.updateName(request.name()); } - - public void deactivateEmployee(Long employeeId) { - Employee employee = employeeRepository.findById(employeeId) - .orElseThrow(() -> new UserNotFoundException("Employee not found with ID: " + employeeId)); - employee.updateStatus("DELETED"); + if (request.departmentId() != null) { + Department department = departmentRepository.findById(request.departmentId()) + .orElseThrow(() -> new IllegalArgumentException("Department not found with ID: " + request.departmentId())); + employee.assignToDepartment(department); } - - @Transactional(readOnly = true) - public List getAllEmployees() { - return employeeRepository.findAll().stream() - .map(employeeMapper::toAdminEmployeeQueryResponse) // Use Mapper - .toList(); + if (request.position() != null) { + employee.updatePosition(request.position()); + } + if (request.level() != null) { + employee.updateRole(request.level()); + } + if (request.status() != null) { + employee.updateStatus(request.status()); } + return employee; // @Transactional will handle the save + } + + public void deactivateEmployee(Long employeeId) { + Employee employee = employeeRepository.findById(employeeId) + .orElseThrow(() -> new UserNotFoundException("Employee not found with ID: " + employeeId)); + employee.updateStatus("DELETED"); + } + + @Transactional(readOnly = true) + public List getAllEmployees() { + return employeeRepository.findAll().stream() + .map(employeeMapper::toAdminEmployeeQueryResponse) // Use Mapper + .toList(); + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java b/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java index 83306eb..4f9c385 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java @@ -20,55 +20,55 @@ @Transactional(readOnly = true) public class EmployeeQueryService { - @PersistenceContext - private final EntityManager em; - private final EmployeeMapper employeeMapper; + @PersistenceContext + private final EntityManager em; + private final EmployeeMapper employeeMapper; - public PagedEmployeeResponse getEmployees(String keyword, int page, int size, Long currentUserId) { - StringBuilder whereClause = new StringBuilder(); - boolean hasKeyword = StringUtils.hasText(keyword); + public PagedEmployeeResponse getEmployees(String keyword, int page, int size, Long currentUserId) { + StringBuilder whereClause = new StringBuilder(); + boolean hasKeyword = StringUtils.hasText(keyword); - if (hasKeyword) { - whereClause.append("WHERE (LOWER(e.employeeName) LIKE :keyword ") - .append("OR LOWER(e.email) LIKE :keyword ") - .append("OR LOWER(d.name) LIKE :keyword) "); - } + if (hasKeyword) { + whereClause.append("WHERE (LOWER(e.employeeName) LIKE :keyword ") + .append("OR LOWER(e.email) LIKE :keyword ") + .append("OR LOWER(d.name) LIKE :keyword) "); + } - whereClause.append(hasKeyword ? "AND " : "WHERE "); - whereClause.append("e.id != :currentUserId "); + whereClause.append(hasKeyword ? "AND " : "WHERE "); + whereClause.append("e.id != :currentUserId "); - String countJpql = "SELECT COUNT(e) FROM Employee e LEFT JOIN e.department d " + whereClause; - TypedQuery countQuery = em.createQuery(countJpql, Long.class); - countQuery.setParameter("currentUserId", currentUserId); - if (hasKeyword) { - countQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); - } - long totalCount = countQuery.getSingleResult(); - int totalPages = (int) Math.ceil((double) totalCount / size); + String countJpql = "SELECT COUNT(e) FROM Employee e LEFT JOIN e.department d " + whereClause; + TypedQuery countQuery = em.createQuery(countJpql, Long.class); + countQuery.setParameter("currentUserId", currentUserId); + if (hasKeyword) { + countQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); + } + long totalCount = countQuery.getSingleResult(); + int totalPages = (int) Math.ceil((double) totalCount / size); - String dataJpql = "SELECT e FROM Employee e " + - "JOIN FETCH e.company c " + - "LEFT JOIN FETCH e.department d " + - whereClause + - "ORDER BY e.employeeName ASC"; - TypedQuery dataQuery = em.createQuery(dataJpql, Employee.class); - dataQuery.setParameter("currentUserId", currentUserId); - if (hasKeyword) { - dataQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); - } + String dataJpql = "SELECT e FROM Employee e " + + "JOIN FETCH e.company c " + + "LEFT JOIN FETCH e.department d " + + whereClause + + "ORDER BY e.employeeName ASC"; + TypedQuery dataQuery = em.createQuery(dataJpql, Employee.class); + dataQuery.setParameter("currentUserId", currentUserId); + if (hasKeyword) { + dataQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); + } - dataQuery.setFirstResult(page * size); - dataQuery.setMaxResults(size); + dataQuery.setFirstResult(page * size); + dataQuery.setMaxResults(size); - List employees = dataQuery.getResultList().stream() - .map(employeeMapper::toEmployeeQueryResponse) - .collect(Collectors.toList()); + List employees = dataQuery.getResultList().stream() + .map(employeeMapper::toEmployeeQueryResponse) + .collect(Collectors.toList()); - return new PagedEmployeeResponse( - employees, - page, - totalPages, - page >= totalPages - 1 - ); - } + return new PagedEmployeeResponse( + employees, + page, + totalPages, + page >= totalPages - 1 + ); + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/EmployeeRegistrationService.java b/src/main/java/com/joycrew/backend/service/EmployeeRegistrationService.java index 72dde81..5d57ae2 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeRegistrationService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeRegistrationService.java @@ -30,112 +30,115 @@ @Transactional public class EmployeeRegistrationService { - private final EmployeeRepository employeeRepository; - private final CompanyRepository companyRepository; - private final DepartmentRepository departmentRepository; - private final WalletRepository walletRepository; - private final PasswordEncoder passwordEncoder; - private final S3FileStorageService s3FileStorageService; - - public Employee registerEmployee(EmployeeRegistrationRequest request) { - if (employeeRepository.findByEmail(request.email()).isPresent()) { - throw new IllegalStateException("This email is already in use."); - } - - Company company = companyRepository.findByCompanyName(request.companyName()) - .orElseThrow(() -> new IllegalArgumentException("Company with the given name does not exist.")); + private final EmployeeRepository employeeRepository; + private final CompanyRepository companyRepository; + private final DepartmentRepository departmentRepository; + private final WalletRepository walletRepository; + private final PasswordEncoder passwordEncoder; + private final S3FileStorageService s3FileStorageService; + + public Employee registerEmployee(EmployeeRegistrationRequest request) { + if (employeeRepository.findByEmail(request.email()).isPresent()) { + throw new IllegalStateException("This email is already in use."); + } - Department department = null; - if (request.departmentName() != null && !request.departmentName().isBlank()) { - department = departmentRepository.findByCompanyAndName(company, request.departmentName()) - .orElseThrow(() -> new IllegalArgumentException("Department with the given name does not exist in this company.")); - } + Company company = companyRepository.findByCompanyName(request.companyName()) + .orElseThrow(() -> new IllegalArgumentException("Company with the given name does not exist.")); - Employee newEmployee = Employee.builder() - .employeeName(request.name()) - .email(request.email()) - .passwordHash(passwordEncoder.encode(request.initialPassword())) - .company(company) - .department(department) - .position(request.position()) - .role(request.level()) - .status("ACTIVE") - .birthday(request.birthday()) - .address(request.address()) - .hireDate(request.hireDate()) - .build(); - - Employee savedEmployee = employeeRepository.save(newEmployee); - walletRepository.save(new Wallet(savedEmployee)); - return savedEmployee; + Department department = null; + if (request.departmentName() != null && !request.departmentName().isBlank()) { + department = departmentRepository.findByCompanyAndName(company, request.departmentName()) + .orElseThrow(() -> new IllegalArgumentException("Department with the given name does not exist in this company.")); } - public void registerEmployeesFromCsv(MultipartFile file) { - try (BufferedReader reader = new BufferedReader(new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) { - String line; - boolean isFirstLine = true; - - while ((line = reader.readLine()) != null) { - if (isFirstLine) { - isFirstLine = false; - continue; - } - - String[] tokens = line.split(","); - if (tokens.length < 10) { // Adjusted for all fields including optional ones - log.warn("Skipping row with missing fields: {}", line); - continue; - } - - try { - AdminLevel adminLevel = parseAdminLevel(tokens[6].trim()); - LocalDate birthday = parseDate(tokens[7].trim()); - String address = tokens[8].trim(); - LocalDate hireDate = parseDate(tokens[9].trim()); - - EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( - tokens[0].trim(), // name - tokens[1].trim(), // email - tokens[2].trim(), // initialPassword - tokens[3].trim(), // companyName - tokens[4].trim().isBlank() ? null : tokens[4].trim(), // departmentName - tokens[5].trim(), // position - adminLevel, - birthday, - address, - hireDate - ); - registerEmployee(request); - } catch (Exception e) { - log.warn("Failed to register employee. Input: [{}], Reason: {}", line, e.getMessage()); - } - } - } catch (IOException e) { - throw new RuntimeException("Failed to read CSV file.", e); + Employee newEmployee = Employee.builder() + .employeeName(request.name()) + .email(request.email()) + .passwordHash(passwordEncoder.encode(request.initialPassword())) + .company(company) + .department(department) + .position(request.position()) + .role(request.level()) + .phoneNumber(request.phoneNumber()) + .status("ACTIVE") + .birthday(request.birthday()) + .address(request.address()) + .hireDate(request.hireDate()) + .build(); + + Employee savedEmployee = employeeRepository.save(newEmployee); + walletRepository.save(new Wallet(savedEmployee)); + return savedEmployee; + } + + public void registerEmployeesFromCsv(MultipartFile file) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) { + String line; + boolean isFirstLine = true; + + while ((line = reader.readLine()) != null) { + if (isFirstLine) { + isFirstLine = false; + continue; } - } - private LocalDate parseDate(String dateStr) { - if (dateStr == null || dateStr.isBlank()) { - return null; + String[] tokens = line.split(",", -1); + if (tokens.length < 11) { + log.warn("Skipping row with missing fields: {}", line); + continue; } + try { - return LocalDate.parse(dateStr); // Expects YYYY-MM-DD format - } catch (DateTimeParseException e) { - log.warn("Invalid date format: {}. Processing as null.", dateStr); - return null; + AdminLevel adminLevel = parseAdminLevel(tokens[6].trim()); + LocalDate birthday = parseDate(tokens[7].trim()); + String address = tokens[8].trim(); + LocalDate hireDate = parseDate(tokens[9].trim()); + String phoneNumber = tokens[10].trim(); + + EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( + tokens[0].trim(), // name + tokens[1].trim(), // email + tokens[2].trim(), // initialPassword + tokens[3].trim(), // companyName + tokens[4].trim().isBlank() ? null : tokens[4].trim(), // departmentName + tokens[5].trim(), // position + adminLevel, + phoneNumber, + birthday, + address, + hireDate + ); + registerEmployee(request); + } catch (Exception e) { + log.warn("Failed to register employee. Input: [{}], Reason: {}", line, e.getMessage()); } + } + } catch (IOException e) { + throw new RuntimeException("Failed to read CSV file.", e); } + } - private AdminLevel parseAdminLevel(String level) { - if (level == null || level.isBlank()) { - return AdminLevel.EMPLOYEE; - } - try { - return AdminLevel.valueOf(level.toUpperCase()); - } catch (IllegalArgumentException e) { - log.warn("Invalid role level: {}. Defaulting to EMPLOYEE.", level); - return AdminLevel.EMPLOYEE; - } + private LocalDate parseDate(String dateStr) { + if (dateStr == null || dateStr.isBlank()) { + return null; + } + try { + return LocalDate.parse(dateStr); // Expects YYYY-MM-DD format + } catch (DateTimeParseException e) { + log.warn("Invalid date format: {}. Processing as null.", dateStr); + return null; + } + } + + private AdminLevel parseAdminLevel(String level) { + if (level == null || level.isBlank()) { + return AdminLevel.EMPLOYEE; + } + try { + return AdminLevel.valueOf(level.toUpperCase()); + } catch (IllegalArgumentException e) { + log.warn("Invalid role level: {}. Defaulting to EMPLOYEE.", level); + return AdminLevel.EMPLOYEE; } + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/EmployeeService.java b/src/main/java/com/joycrew/backend/service/EmployeeService.java index b7ed75c..bd2cd60 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeService.java @@ -1,6 +1,7 @@ package com.joycrew.backend.service; import com.joycrew.backend.dto.PasswordChangeRequest; +import com.joycrew.backend.dto.PasswordVerifyRequest; import com.joycrew.backend.dto.UserProfileResponse; import com.joycrew.backend.dto.UserProfileUpdateRequest; import com.joycrew.backend.entity.Employee; @@ -10,6 +11,7 @@ import com.joycrew.backend.repository.WalletRepository; import com.joycrew.backend.service.mapper.EmployeeMapper; import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,52 +21,60 @@ @RequiredArgsConstructor @Transactional public class EmployeeService { - private final EmployeeRepository employeeRepository; - private final WalletRepository walletRepository; - private final PasswordEncoder passwordEncoder; - private final EmployeeMapper employeeMapper; - private final S3FileStorageService s3FileStorageService; + private final EmployeeRepository employeeRepository; + private final WalletRepository walletRepository; + private final PasswordEncoder passwordEncoder; + private final EmployeeMapper employeeMapper; + private final S3FileStorageService s3FileStorageService; - @Transactional(readOnly = true) - public UserProfileResponse getUserProfile(String userEmail) { - Employee employee = employeeRepository.findByEmail(userEmail) - .orElseThrow(() -> new UserNotFoundException("Authenticated user not found.")); + @Transactional(readOnly = true) + public UserProfileResponse getUserProfile(String userEmail) { + Employee employee = employeeRepository.findByEmail(userEmail) + .orElseThrow(() -> new UserNotFoundException("Authenticated user not found.")); - Wallet wallet = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) - .orElse(new Wallet(employee)); + Wallet wallet = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) + .orElse(new Wallet(employee)); - return employeeMapper.toUserProfileResponse(employee, wallet); - } + return employeeMapper.toUserProfileResponse(employee, wallet); + } + + public void forcePasswordChange(String userEmail, PasswordChangeRequest request) { + Employee employee = employeeRepository.findByEmail(userEmail) + .orElseThrow(() -> new UserNotFoundException("Authenticated user not found.")); + employee.changePassword(request.newPassword(), passwordEncoder); + } - public void forcePasswordChange(String userEmail, PasswordChangeRequest request) { - Employee employee = employeeRepository.findByEmail(userEmail) - .orElseThrow(() -> new UserNotFoundException("Authenticated user not found.")); - employee.changePassword(request.newPassword(), passwordEncoder); + public void verifyCurrentPassword(String userEmail, PasswordVerifyRequest request) { + Employee employee = employeeRepository.findByEmail(userEmail) + .orElseThrow(() -> new UserNotFoundException("Authenticated user not found.")); + + if (!passwordEncoder.matches(request.currentPassword(), employee.getPasswordHash())) { + throw new BadCredentialsException("The current password is not correct."); } + } - public void updateUserProfile(String userEmail, UserProfileUpdateRequest request, MultipartFile profileImage) { - Employee employee = employeeRepository.findByEmail(userEmail) - .orElseThrow(() -> new UserNotFoundException("Authenticated user not found.")); + public void updateUserProfile(String userEmail, UserProfileUpdateRequest request, MultipartFile profileImage) { + Employee employee = employeeRepository.findByEmail(userEmail) + .orElseThrow(() -> new UserNotFoundException("Authenticated user not found.")); - if (request.name() != null) { - employee.updateName(request.name()); - } - if (profileImage != null && !profileImage.isEmpty()) { - String profileImageUrl = s3FileStorageService.uploadFile(profileImage); - employee.updateProfileImageUrl(profileImageUrl); - } - if (request.personalEmail() != null) { - employee.updatePersonalEmail(request.personalEmail()); - } - if (request.phoneNumber() != null) { - employee.updatePhoneNumber(request.phoneNumber()); - } - if (request.birthday() != null) { - employee.updateBirthday(request.birthday()); - } - if (request.address() != null) { - employee.updateAddress(request.address()); - } - // No explicit save call is needed due to @Transactional + if (request.name() != null) { + employee.updateName(request.name()); + } + if (profileImage != null && !profileImage.isEmpty()) { + String profileImageUrl = s3FileStorageService.uploadFile(profileImage); + employee.updateProfileImageUrl(profileImageUrl); + } + if (request.personalEmail() != null) { + employee.updatePersonalEmail(request.personalEmail()); + } + if (request.phoneNumber() != null) { + employee.updatePhoneNumber(request.phoneNumber()); + } + if (request.birthday() != null) { + employee.updateBirthday(request.birthday()); + } + if (request.address() != null) { + employee.updateAddress(request.address()); } + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/GiftPointService.java b/src/main/java/com/joycrew/backend/service/GiftPointService.java index 757d4be..b8e831f 100644 --- a/src/main/java/com/joycrew/backend/service/GiftPointService.java +++ b/src/main/java/com/joycrew/backend/service/GiftPointService.java @@ -19,41 +19,41 @@ @RequiredArgsConstructor public class GiftPointService { - private final EmployeeRepository employeeRepository; - private final WalletRepository walletRepository; - private final RewardPointTransactionRepository transactionRepository; - private final ApplicationEventPublisher eventPublisher; - - @Transactional - public void giftPointsToColleague(String senderEmail, GiftPointRequest request) { - Employee sender = employeeRepository.findByEmail(senderEmail) - .orElseThrow(() -> new UserNotFoundException("Sender not found.")); - Employee receiver = employeeRepository.findById(request.receiverId()) - .orElseThrow(() -> new UserNotFoundException("Receiver not found.")); - - Wallet senderWallet = walletRepository.findByEmployee_EmployeeId(sender.getEmployeeId()) - .orElseThrow(() -> new IllegalStateException("Sender's wallet does not exist.")); - Wallet receiverWallet = walletRepository.findByEmployee_EmployeeId(receiver.getEmployeeId()) - .orElseThrow(() -> new IllegalStateException("Receiver's wallet does not exist.")); - - // Transfer points - senderWallet.spendPoints(request.points()); - receiverWallet.addPoints(request.points()); - - // Record the transaction - RewardPointTransaction transaction = RewardPointTransaction.builder() - .sender(sender) - .receiver(receiver) - .pointAmount(request.points()) - .message(request.message()) - .type(TransactionType.AWARD_P2P) - .tags(request.tags()) - .build(); - transactionRepository.save(transaction); - - // Publish an event for notifications or other async tasks - eventPublisher.publishEvent( - new RecognitionEvent(this, sender.getEmployeeId(), receiver.getEmployeeId(), request.points(), request.message()) - ); - } + private final EmployeeRepository employeeRepository; + private final WalletRepository walletRepository; + private final RewardPointTransactionRepository transactionRepository; + private final ApplicationEventPublisher eventPublisher; + + @Transactional + public void giftPointsToColleague(String senderEmail, GiftPointRequest request) { + Employee sender = employeeRepository.findByEmail(senderEmail) + .orElseThrow(() -> new UserNotFoundException("Sender not found.")); + Employee receiver = employeeRepository.findById(request.receiverId()) + .orElseThrow(() -> new UserNotFoundException("Receiver not found.")); + + Wallet senderWallet = walletRepository.findByEmployee_EmployeeId(sender.getEmployeeId()) + .orElseThrow(() -> new IllegalStateException("Sender's wallet does not exist.")); + Wallet receiverWallet = walletRepository.findByEmployee_EmployeeId(receiver.getEmployeeId()) + .orElseThrow(() -> new IllegalStateException("Receiver's wallet does not exist.")); + + // Transfer points + senderWallet.spendGiftablePoints(request.points()); + receiverWallet.addPoints(request.points()); + + // Record the transaction + RewardPointTransaction transaction = RewardPointTransaction.builder() + .sender(sender) + .receiver(receiver) + .pointAmount(request.points()) + .message(request.message()) + .type(TransactionType.AWARD_P2P) + .tags(request.tags()) + .build(); + transactionRepository.save(transaction); + + // Publish an event for notifications or other async tasks + eventPublisher.publishEvent( + new RecognitionEvent(this, sender.getEmployeeId(), receiver.getEmployeeId(), request.points(), request.message()) + ); + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/OrderService.java b/src/main/java/com/joycrew/backend/service/OrderService.java index fb326fa..7e6e65c 100644 --- a/src/main/java/com/joycrew/backend/service/OrderService.java +++ b/src/main/java/com/joycrew/backend/service/OrderService.java @@ -5,99 +5,135 @@ import com.joycrew.backend.dto.PagedOrderResponse; import com.joycrew.backend.entity.*; import com.joycrew.backend.entity.enums.OrderStatus; -import com.joycrew.backend.repository.EmployeeRepository; -import com.joycrew.backend.repository.OrderRepository; -import com.joycrew.backend.repository.ProductRepository; -import com.joycrew.backend.repository.WalletRepository; +import com.joycrew.backend.entity.enums.TransactionType; +import com.joycrew.backend.repository.*; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; import java.util.NoSuchElementException; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor public class OrderService { - private final OrderRepository orderRepository; - private final ProductRepository productRepository; - private final WalletRepository walletRepository; - private final EmployeeRepository employeeRepository; - - @Transactional - public OrderResponse createOrder(Long employeeId, CreateOrderRequest req) { - Product product = productRepository.findById(req.productId()) - .orElseThrow(() -> new NoSuchElementException("Product not found")); - - Employee employee = employeeRepository.findById(employeeId) - .orElseThrow(() -> new NoSuchElementException("Employee not found")); - - Wallet wallet = walletRepository.findByEmployee_EmployeeId(employeeId) - .orElseThrow(() -> new NoSuchElementException("Wallet not found")); - - int qty = (req.quantity() == null || req.quantity() <= 0) ? 1 : req.quantity(); - int total = product.getPrice() * qty; - - wallet.spendPoints(total); - - Order order = Order.builder() - .employee(employee) - .productId(product.getId()) - .productName(product.getName()) - .productItemId(product.getItemId()) - .productUnitPrice(product.getPrice()) - .quantity(qty) - .totalPrice(total) - .status(OrderStatus.PLACED) - .orderedAt(LocalDateTime.now()) - .build(); - - Order saved = orderRepository.save(order); - return OrderResponse.from(saved); + private final OrderRepository orderRepository; + private final ProductRepository productRepository; + private final WalletRepository walletRepository; + private final EmployeeRepository employeeRepository; + private final RewardPointTransactionRepository transactionRepository; + + @Transactional + public OrderResponse createOrder(Long employeeId, CreateOrderRequest req) { + Product product = productRepository.findById(req.productId()) + .orElseThrow(() -> new NoSuchElementException("Product not found")); + + Employee employee = employeeRepository.findById(employeeId) + .orElseThrow(() -> new NoSuchElementException("Employee not found")); + + Wallet wallet = walletRepository.findByEmployee_EmployeeId(employeeId) + .orElseThrow(() -> new NoSuchElementException("Wallet not found")); + + int qty = (req.quantity() == null || req.quantity() <= 0) ? 1 : req.quantity(); + int total = product.getPrice() * qty; + + wallet.purchaseWithPoints(total); + + // Create and save the transaction history + RewardPointTransaction transaction = RewardPointTransaction.builder() + .sender(employee) + .receiver(null) // No specific receiver for item redemption + .pointAmount(total) + .message(String.format("Purchased: %s", product.getName())) + .type(TransactionType.REDEEM_ITEM) + .build(); + transactionRepository.save(transaction); + + Order order = Order.builder() + .employee(employee) + .productId(product.getId()) + .productName(product.getName()) + .productItemId(product.getItemId()) + .productUnitPrice(product.getPrice()) + .quantity(qty) + .totalPrice(total) + .status(OrderStatus.PLACED) + .orderedAt(LocalDateTime.now()) + .build(); + + Order saved = orderRepository.save(order); + + return OrderResponse.from(saved, product.getThumbnailUrl()); + } + + @Transactional(readOnly = true) + public PagedOrderResponse getMyOrders(Long employeeId, int page, int size) { + PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "orderedAt")); + Page orderPage = orderRepository.findByEmployee_EmployeeId(employeeId, pageable); + + List productIds = orderPage.getContent().stream() + .map(Order::getProductId) + .distinct() + .toList(); + + Map productThumbnailMap = productRepository.findAllById(productIds).stream() + .collect(Collectors.toMap(Product::getId, Product::getThumbnailUrl)); + + List orderResponses = orderPage.getContent().stream() + .map(order -> { + String thumbnailUrl = productThumbnailMap.get(order.getProductId()); + return OrderResponse.from(order, thumbnailUrl); + }) + .toList(); + + return new PagedOrderResponse( + orderResponses, + orderPage.getNumber(), + orderPage.getSize(), + orderPage.getTotalElements(), + orderPage.getTotalPages(), + orderPage.hasNext(), + orderPage.hasPrevious() + ); + } + + @Transactional(readOnly = true) + public OrderResponse getMyOrderDetail(Long employeeId, Long orderId) { + Order order = orderRepository.findByOrderIdAndEmployee_EmployeeId(orderId, employeeId) + .orElseThrow(() -> new NoSuchElementException("Order not found")); + + String thumbnailUrl = productRepository.findById(order.getProductId()) + .map(Product::getThumbnailUrl) + .orElse(null); + + return OrderResponse.from(order, thumbnailUrl); + } + + @Transactional + public OrderResponse cancelMyOrder(Long employeeId, Long orderId) { + Order order = orderRepository.findByOrderIdAndEmployee_EmployeeId(orderId, employeeId) + .orElseThrow(() -> new NoSuchElementException("Order not found")); + + if (order.getStatus() == OrderStatus.SHIPPED || order.getStatus() == OrderStatus.DELIVERED) { + throw new IllegalStateException("Order cannot be canceled after it has been shipped."); } - @Transactional(readOnly = true) - public PagedOrderResponse getMyOrders(Long employeeId, int page, int size) { - PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "orderedAt")); - Page p = orderRepository.findByEmployee_EmployeeId(employeeId, pageable); - return new PagedOrderResponse( - p.getContent().stream().map(OrderResponse::from).toList(), - p.getNumber(), p.getSize(), p.getTotalElements(), p.getTotalPages(), - p.hasNext(), p.hasPrevious() - ); + String thumbnailUrl = null; + if (order.getStatus() != OrderStatus.CANCELED) { + Wallet wallet = order.getEmployee().getWallet(); + wallet.refundPoints(order.getTotalPrice()); + order.setStatus(OrderStatus.CANCELED); } - @Transactional(readOnly = true) - public OrderResponse getMyOrderDetail(Long employeeId, Long orderId) { - Order order = orderRepository.findByOrderIdAndEmployee_EmployeeId(orderId, employeeId) - .orElseThrow(() -> new NoSuchElementException("Order not found")); - return OrderResponse.from(order); - } + thumbnailUrl = productRepository.findById(order.getProductId()) + .map(Product::getThumbnailUrl) + .orElse(null); - // Cancel my order if not shipped yet - @Transactional - public OrderResponse cancelMyOrder(Long employeeId, Long orderId) { - Order order = orderRepository.findByOrderIdAndEmployee_EmployeeId(orderId, employeeId) - .orElseThrow(() -> new NoSuchElementException("Order not found")); - - // already shipped or delivered cannot cancel - if (order.getStatus() == OrderStatus.SHIPPED || order.getStatus() == OrderStatus.DELIVERED) { - throw new IllegalStateException("Order cannot be canceled after it has been shipped."); - } - if (order.getStatus() == OrderStatus.CANCELED) { - // idempotent-ish: 이미 취소된 주문 - return OrderResponse.from(order); - } - - // refund - Wallet wallet = order.getEmployee().getWallet(); - wallet.refundPoints(order.getTotalPrice()); - - // update status - order.setStatus(OrderStatus.CANCELED); - - return OrderResponse.from(order); - } -} + return OrderResponse.from(order, thumbnailUrl); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/ProductQueryService.java b/src/main/java/com/joycrew/backend/service/ProductQueryService.java new file mode 100644 index 0000000..bc19baa --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/ProductQueryService.java @@ -0,0 +1,69 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.PagedProductResponse; +import com.joycrew.backend.dto.ProductResponse; +import com.joycrew.backend.entity.Product; +import com.joycrew.backend.entity.enums.Category; +import com.joycrew.backend.repository.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ProductQueryService { + + private final ProductRepository productRepository; + + // Get all products (paginated) + public PagedProductResponse getAllProducts(int page, int size) { + PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id")); + Page result = productRepository.findAll(pageable); + return PagedProductResponse.from(result); + } + + // Get a single product + public ProductResponse getProductById(Long id) { + return productRepository.findById(id) + .map(ProductResponse::from) + .orElse(null); + } + + // Get by category (paginated) + public PagedProductResponse getProductsByCategory(Category category, int page, int size) { + PageRequest pageable = PageRequest.of( + page, size, + Sort.by(Sort.Direction.ASC, "rankOrder").and(Sort.by(Sort.Direction.DESC, "id")) + ); + Page result = productRepository.findByKeyword(category, pageable); + return PagedProductResponse.from(result); + } + + // Search (fallback to all products if query is empty, with optional category) + public PagedProductResponse searchProducts(String q, Category category, int page, int size) { + PageRequest pageable = PageRequest.of( + page, size, + Sort.by(Sort.Direction.ASC, "rankOrder").and(Sort.by(Sort.Direction.DESC, "id")) + ); + + // If query is null or empty, behavior depends on category + if (q == null || q.trim().isEmpty()) { + return (category == null) + ? getAllProducts(page, size) + : getProductsByCategory(category, page, size); + } + + String keyword = q.trim(); + Page result = (category == null) + ? productRepository.searchByQuery(keyword, pageable) + : productRepository.searchByCategoryAndQuery(category, keyword, pageable); + + return PagedProductResponse.from(result); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/ProductService.java b/src/main/java/com/joycrew/backend/service/ProductService.java deleted file mode 100644 index b2bb505..0000000 --- a/src/main/java/com/joycrew/backend/service/ProductService.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.joycrew.backend.service; - -import com.joycrew.backend.dto.PagedProductResponse; -import com.joycrew.backend.dto.ProductResponse; -import com.joycrew.backend.entity.Product; -import com.joycrew.backend.entity.enums.Category; -import com.joycrew.backend.repository.ProductRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Service; - -import java.util.Optional; - -@Service -@RequiredArgsConstructor -public class ProductService { - - private final ProductRepository productRepository; - - // Get all products (paged) - public PagedProductResponse getAllProducts(int page, int size) { - PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id")); - Page result = productRepository.findAll(pageable); - return PagedProductResponse.from(result); - } - - // Get product by ID (single) - public ProductResponse getProductById(Long id) { - Optional product = productRepository.findById(id); - return product.map(ProductResponse::from).orElse(null); - } - - // Get products by category (paged) - public PagedProductResponse getProductsByCategory(Category category, int page, int size) { - PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "rankOrder").and(Sort.by(Sort.Direction.DESC, "id"))); - Page result = productRepository.findByKeyword(category, pageable); - return PagedProductResponse.from(result); - } -} diff --git a/src/main/java/com/joycrew/backend/service/RecentProductViewService.java b/src/main/java/com/joycrew/backend/service/RecentProductViewService.java index 624b7af..144249d 100644 --- a/src/main/java/com/joycrew/backend/service/RecentProductViewService.java +++ b/src/main/java/com/joycrew/backend/service/RecentProductViewService.java @@ -21,54 +21,54 @@ @RequiredArgsConstructor public class RecentProductViewService { - private static final int DEFAULT_LIMIT = 20; + private static final int DEFAULT_LIMIT = 20; - private final RecentProductViewRepository recentProductViewRepository; - private final EmployeeRepository employeeRepository; - private final ProductRepository productRepository; + private final RecentProductViewRepository recentProductViewRepository; + private final EmployeeRepository employeeRepository; + private final ProductRepository productRepository; - // Upsert viewed record - @Transactional - public void recordView(Long employeeId, Long productId) { - Employee employee = employeeRepository.findById(employeeId) - .orElseThrow(() -> new NoSuchElementException("Employee not found")); - Product product = productRepository.findById(productId) - .orElseThrow(() -> new NoSuchElementException("Product not found")); + // Upsert viewed record + @Transactional + public void recordView(Long employeeId, Long productId) { + Employee employee = employeeRepository.findById(employeeId) + .orElseThrow(() -> new NoSuchElementException("Employee not found")); + Product product = productRepository.findById(productId) + .orElseThrow(() -> new NoSuchElementException("Product not found")); - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(); - recentProductViewRepository.findByEmployee_EmployeeIdAndProduct_Id(employeeId, productId) - .ifPresentOrElse(existing -> { - existing.setViewedAt(now); - }, () -> { - RecentProductView view = RecentProductView.builder() - .employee(employee) - .product(product) - .viewedAt(now) - .build(); - recentProductViewRepository.save(view); - }); - } + recentProductViewRepository.findByEmployee_EmployeeIdAndProduct_Id(employeeId, productId) + .ifPresentOrElse(existing -> { + existing.setViewedAt(now); + }, () -> { + RecentProductView view = RecentProductView.builder() + .employee(employee) + .product(product) + .viewedAt(now) + .build(); + recentProductViewRepository.save(view); + }); + } - @Transactional(readOnly = true) - public List getRecentViews(Long employeeId, Integer limit) { - int size = (limit == null || limit <= 0) ? DEFAULT_LIMIT : Math.min(limit, 100); - LocalDateTime threshold = LocalDateTime.now().minus(3, ChronoUnit.MONTHS); + @Transactional(readOnly = true) + public List getRecentViews(Long employeeId, Integer limit) { + int size = (limit == null || limit <= 0) ? DEFAULT_LIMIT : Math.min(limit, 100); + LocalDateTime threshold = LocalDateTime.now().minus(3, ChronoUnit.MONTHS); - var views = recentProductViewRepository - .findByEmployee_EmployeeIdAndViewedAtAfterOrderByViewedAtDesc( - employeeId, threshold, PageRequest.of(0, size) - ); + var views = recentProductViewRepository + .findByEmployee_EmployeeIdAndViewedAtAfterOrderByViewedAtDesc( + employeeId, threshold, PageRequest.of(0, size) + ); - return views.stream() - .map(v -> RecentViewedProductResponse.of(v.getProduct(), v.getViewedAt())) - .toList(); - } + return views.stream() + .map(v -> RecentViewedProductResponse.of(v.getProduct(), v.getViewedAt())) + .toList(); + } - // Called by a scheduler to keep the table small - @Transactional - public long cleanupOldViews() { - LocalDateTime threshold = LocalDateTime.now().minusMonths(3); - return recentProductViewRepository.deleteByViewedAtBefore(threshold); - } + // Called by a scheduler to keep the table small + @Transactional + public long cleanupOldViews() { + LocalDateTime threshold = LocalDateTime.now().minusMonths(3); + return recentProductViewRepository.deleteByViewedAtBefore(threshold); + } } diff --git a/src/main/java/com/joycrew/backend/service/S3FileStorageService.java b/src/main/java/com/joycrew/backend/service/S3FileStorageService.java index 84be8b1..187f63c 100644 --- a/src/main/java/com/joycrew/backend/service/S3FileStorageService.java +++ b/src/main/java/com/joycrew/backend/service/S3FileStorageService.java @@ -18,38 +18,38 @@ @RequiredArgsConstructor public class S3FileStorageService { - private final S3Client s3Client; - - @Value("${aws.s3.bucket-name}") - private String bucketName; - - public String uploadFile(MultipartFile file) { - if (file == null || file.isEmpty()) { - throw new IllegalArgumentException("File to upload cannot be null or empty."); - } - - String originalFilename = file.getOriginalFilename(); - String uniqueFileName = UUID.randomUUID().toString() + "-" + originalFilename; - - try { - PutObjectRequest putObjectRequest = PutObjectRequest.builder() - .bucket(bucketName) - .key(uniqueFileName) - .contentType(file.getContentType()) - .build(); - - s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); - - String fileUrl = s3Client.utilities().getUrl(builder -> builder.bucket(bucketName).key(uniqueFileName)).toExternalForm(); - log.info("File uploaded successfully to S3. URL: {}", fileUrl); - return fileUrl; - - } catch (IOException e) { - log.error("Error getting input stream from file.", e); - throw new RuntimeException("Failed to process file for upload.", e); - } catch (S3Exception e) { - log.error("Failed to upload file to S3.", e); - throw new RuntimeException("Failed to upload file to S3.", e); - } + private final S3Client s3Client; + + @Value("${aws.s3.bucket-name}") + private String bucketName; + + public String uploadFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("File to upload cannot be null or empty."); + } + + String originalFilename = file.getOriginalFilename(); + String uniqueFileName = UUID.randomUUID().toString() + "-" + originalFilename; + + try { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucketName) + .key(uniqueFileName) + .contentType(file.getContentType()) + .build(); + + s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + + String fileUrl = s3Client.utilities().getUrl(builder -> builder.bucket(bucketName).key(uniqueFileName)).toExternalForm(); + log.info("File uploaded successfully to S3. URL: {}", fileUrl); + return fileUrl; + + } catch (IOException e) { + log.error("Error getting input stream from file.", e); + throw new RuntimeException("Failed to process file for upload.", e); + } catch (S3Exception e) { + log.error("Failed to upload file to S3.", e); + throw new RuntimeException("Failed to upload file to S3.", e); } + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/StatisticsService.java b/src/main/java/com/joycrew/backend/service/StatisticsService.java index 3296a59..3019cd0 100644 --- a/src/main/java/com/joycrew/backend/service/StatisticsService.java +++ b/src/main/java/com/joycrew/backend/service/StatisticsService.java @@ -1,6 +1,7 @@ package com.joycrew.backend.service; import com.joycrew.backend.dto.PointStatisticsResponse; +import com.joycrew.backend.dto.TransactionHistoryResponse; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.RewardPointTransaction; import com.joycrew.backend.entity.enums.Tag; @@ -11,6 +12,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -20,30 +23,59 @@ @Transactional(readOnly = true) public class StatisticsService { - private final EmployeeRepository employeeRepository; - private final RewardPointTransactionRepository transactionRepository; + private final EmployeeRepository employeeRepository; + private final RewardPointTransactionRepository transactionRepository; - public PointStatisticsResponse getPointStatistics(String userEmail) { - Employee user = employeeRepository.findByEmail(userEmail) - .orElseThrow(() -> new UserNotFoundException("User not found with email: " + userEmail)); + public PointStatisticsResponse getPointStatistics(String userEmail) { + Employee user = employeeRepository.findByEmail(userEmail) + .orElseThrow(() -> new UserNotFoundException("User not found with email: " + userEmail)); - List transactions = transactionRepository.findBySenderOrReceiver(user, user); + // Use a query that fetches related entities to avoid N+1 problems + List allTransactions = transactionRepository.findBySenderOrReceiverOrderByTransactionDateDesc(user, user); - int totalReceived = transactions.stream() - .filter(tx -> user.equals(tx.getReceiver())) - .mapToInt(RewardPointTransaction::getPointAmount) - .sum(); + List receivedHistory = new ArrayList<>(); + List sentHistory = new ArrayList<>(); - int totalSent = transactions.stream() - .filter(tx -> user.equals(tx.getSender())) - .mapToInt(RewardPointTransaction::getPointAmount) - .sum(); + for (RewardPointTransaction tx : allTransactions) { + if (user.equals(tx.getReceiver())) { + Employee sender = tx.getSender(); + receivedHistory.add(TransactionHistoryResponse.builder() + .transactionId(tx.getTransactionId()) + .type(tx.getType()) + .amount(tx.getPointAmount()) + .counterparty(sender != null ? sender.getEmployeeName() : "System") + .message(tx.getMessage()) + .transactionDate(tx.getTransactionDate()) + .counterpartyProfileImageUrl(sender != null ? sender.getProfileImageUrl() : null) + .counterpartyDepartmentName(sender != null && sender.getDepartment() != null ? sender.getDepartment().getName() : null) + .build()); + } else if (user.equals(tx.getSender())) { + Employee receiver = tx.getReceiver(); + sentHistory.add(TransactionHistoryResponse.builder() + .transactionId(tx.getTransactionId()) + .type(tx.getType()) + .amount(-tx.getPointAmount()) // Sent points are negative + .counterparty(receiver != null ? receiver.getEmployeeName() : "System") + .message(tx.getMessage()) + .transactionDate(tx.getTransactionDate()) + .counterpartyProfileImageUrl(receiver != null ? receiver.getProfileImageUrl() : null) + .counterpartyDepartmentName(receiver != null && receiver.getDepartment() != null ? receiver.getDepartment().getName() : null) + .build()); + } + } - Map tagStats = transactions.stream() - .filter(tx -> user.equals(tx.getReceiver()) && tx.getTags() != null) - .flatMap(tx -> tx.getTags().stream()) - .collect(Collectors.groupingBy(tag -> tag, Collectors.counting())); + int totalReceived = receivedHistory.stream().mapToInt(TransactionHistoryResponse::amount).sum(); + int totalSent = sentHistory.stream().mapToInt(TransactionHistoryResponse::amount).sum(); - return new PointStatisticsResponse(totalReceived, totalSent, tagStats); - } + Map tagStatsMap = allTransactions.stream() + .filter(tx -> user.equals(tx.getReceiver()) && tx.getTags() != null) + .flatMap(tx -> tx.getTags().stream()) + .collect(Collectors.groupingBy(tag -> tag, Collectors.counting())); + + List sortedTagCounts = Arrays.stream(Tag.values()) + .map(tag -> tagStatsMap.getOrDefault(tag, 0L)) + .collect(Collectors.toList()); + + return new PointStatisticsResponse(totalReceived, Math.abs(totalSent), sortedTagCounts, receivedHistory, sentHistory); + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java b/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java index b42222e..b60f54c 100644 --- a/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java +++ b/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java @@ -2,6 +2,7 @@ import com.joycrew.backend.dto.TransactionHistoryResponse; import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.enums.TransactionType; import com.joycrew.backend.exception.UserNotFoundException; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.RewardPointTransactionRepository; @@ -17,48 +18,46 @@ @Transactional(readOnly = true) public class TransactionHistoryService { - private final RewardPointTransactionRepository transactionRepository; - private final EmployeeRepository employeeRepository; - - public List getTransactionHistory(String userEmail) { - Employee user = employeeRepository.findByEmail(userEmail) - .orElseThrow(() -> new UserNotFoundException("User not found with email: " + userEmail)); - - // This call now efficiently fetches transactions with related sender/receiver data. - return transactionRepository.findBySenderOrReceiverOrderByTransactionDateDesc(user, user) - .stream() - .map(tx -> { - boolean isSender = user.equals(tx.getSender()); - int amount = isSender ? -tx.getPointAmount() : tx.getPointAmount(); - - String counterparty; - switch (tx.getType()) { - case REDEEM_ITEM: - counterparty = tx.getMessage() != null ? tx.getMessage() : "상품 구매"; - break; - case AWARD_P2P: - case AWARD_MANAGER_SPOT: - case ADMIN_ADJUSTMENT: - counterparty = isSender - ? tx.getReceiver().getEmployeeName() - : (tx.getSender() != null ? tx.getSender().getEmployeeName() : "System"); - break; - case AWARD_AUTOMATED: - case EXPIRE_POINTS: - default: - counterparty = "System"; - break; - } - - return TransactionHistoryResponse.builder() - .transactionId(tx.getTransactionId()) - .type(tx.getType()) - .amount(amount) - .counterparty(counterparty) - .message(tx.getMessage()) - .transactionDate(tx.getTransactionDate()) - .build(); - }) - .collect(Collectors.toList()); - } + private final RewardPointTransactionRepository transactionRepository; + private final EmployeeRepository employeeRepository; + + public List getTransactionHistory(String userEmail) { + Employee user = employeeRepository.findByEmail(userEmail) + .orElseThrow(() -> new UserNotFoundException("User not found with email: " + userEmail)); + + // Define which transaction types are considered personal history + List personalTransactionTypes = List.of( + TransactionType.AWARD_P2P, + TransactionType.REDEEM_ITEM + ); + + return transactionRepository.findBySenderOrReceiverOrderByTransactionDateDesc(user, user) + .stream() + // Filter only for personal transaction types + .filter(tx -> personalTransactionTypes.contains(tx.getType())) + .map(tx -> { + boolean isSender = user.equals(tx.getSender()); + // For item redemption, the user is the sender of points, so amount should be negative. + int amount = isSender ? -tx.getPointAmount() : tx.getPointAmount(); + + String counterparty; + if (tx.getType() == TransactionType.REDEEM_ITEM) { + counterparty = tx.getMessage() != null ? tx.getMessage() : "상품 구매"; + } else { // AWARD_P2P + counterparty = isSender + ? tx.getReceiver().getEmployeeName() + : tx.getSender().getEmployeeName(); + } + + return TransactionHistoryResponse.builder() + .transactionId(tx.getTransactionId()) + .type(tx.getType()) + .amount(amount) + .counterparty(counterparty) + .message(tx.getMessage()) + .transactionDate(tx.getTransactionDate()) + .build(); + }) + .collect(Collectors.toList()); + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/WalletService.java b/src/main/java/com/joycrew/backend/service/WalletService.java index ba34bb0..0cc8381 100644 --- a/src/main/java/com/joycrew/backend/service/WalletService.java +++ b/src/main/java/com/joycrew/backend/service/WalletService.java @@ -15,17 +15,17 @@ @RequiredArgsConstructor @Transactional(readOnly = true) public class WalletService { - private final WalletRepository walletRepository; - private final EmployeeRepository employeeRepository; - private final EmployeeMapper employeeMapper; + private final WalletRepository walletRepository; + private final EmployeeRepository employeeRepository; + private final EmployeeMapper employeeMapper; - public PointBalanceResponse getPointBalance(String userEmail) { - Employee employee = employeeRepository.findByEmail(userEmail) - .orElseThrow(() -> new UserNotFoundException("Authenticated user not found.")); + public PointBalanceResponse getPointBalance(String userEmail) { + Employee employee = employeeRepository.findByEmail(userEmail) + .orElseThrow(() -> new UserNotFoundException("Authenticated user not found.")); - Wallet wallet = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) - .orElse(new Wallet(employee)); + Wallet wallet = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) + .orElse(new Wallet(employee)); - return employeeMapper.toPointBalanceResponse(wallet); - } + return employeeMapper.toPointBalanceResponse(wallet); + } } diff --git a/src/main/java/com/joycrew/backend/service/mapper/EmployeeMapper.java b/src/main/java/com/joycrew/backend/service/mapper/EmployeeMapper.java index 4e5718c..771d97d 100644 --- a/src/main/java/com/joycrew/backend/service/mapper/EmployeeMapper.java +++ b/src/main/java/com/joycrew/backend/service/mapper/EmployeeMapper.java @@ -11,52 +11,54 @@ @Component public class EmployeeMapper { - public EmployeeQueryResponse toEmployeeQueryResponse(Employee employee) { - String departmentName = (employee.getDepartment() != null) ? employee.getDepartment().getName() : null; - return new EmployeeQueryResponse( - employee.getEmployeeId(), - employee.getProfileImageUrl(), - employee.getEmployeeName(), - departmentName, - employee.getPosition() - ); - } + public EmployeeQueryResponse toEmployeeQueryResponse(Employee employee) { + String departmentName = (employee.getDepartment() != null) ? employee.getDepartment().getName() : null; + return new EmployeeQueryResponse( + employee.getEmployeeId(), + employee.getProfileImageUrl(), + employee.getEmployeeName(), + departmentName, + employee.getPosition() + ); + } - public AdminEmployeeQueryResponse toAdminEmployeeQueryResponse(Employee employee) { - String departmentName = (employee.getDepartment() != null) ? employee.getDepartment().getName() : null; - return new AdminEmployeeQueryResponse( - employee.getEmployeeId(), - employee.getEmployeeName(), - employee.getEmail(), - departmentName, - employee.getPosition(), - employee.getProfileImageUrl(), - employee.getRole().name(), - employee.getBirthday(), - employee.getAddress(), - employee.getHireDate() - ); - } + public AdminEmployeeQueryResponse toAdminEmployeeQueryResponse(Employee employee) { + String departmentName = (employee.getDepartment() != null) ? employee.getDepartment().getName() : null; + return new AdminEmployeeQueryResponse( + employee.getEmployeeId(), + employee.getEmployeeName(), + employee.getEmail(), + departmentName, + employee.getPosition(), + employee.getProfileImageUrl(), + employee.getRole().name(), + employee.getPhoneNumber(), + employee.getBirthday(), + employee.getAddress(), + employee.getHireDate() + ); + } - public UserProfileResponse toUserProfileResponse(Employee employee, Wallet wallet) { - String departmentName = employee.getDepartment() != null ? employee.getDepartment().getName() : null; - return new UserProfileResponse( - employee.getEmployeeId(), - employee.getEmployeeName(), - employee.getEmail(), - employee.getProfileImageUrl(), - wallet.getBalance(), - wallet.getGiftablePoint(), - employee.getRole(), - departmentName, - employee.getPosition(), - employee.getBirthday(), - employee.getAddress(), - employee.getHireDate() - ); - } + public UserProfileResponse toUserProfileResponse(Employee employee, Wallet wallet) { + String departmentName = employee.getDepartment() != null ? employee.getDepartment().getName() : null; + return new UserProfileResponse( + employee.getEmployeeId(), + employee.getEmployeeName(), + employee.getEmail(), + employee.getProfileImageUrl(), + wallet.getBalance(), + wallet.getGiftablePoint(), + employee.getRole(), + departmentName, + employee.getPosition(), + employee.getPhoneNumber(), + employee.getBirthday(), + employee.getAddress(), + employee.getHireDate() + ); + } - public PointBalanceResponse toPointBalanceResponse(Wallet wallet) { - return new PointBalanceResponse(wallet.getBalance(), wallet.getGiftablePoint()); - } + public PointBalanceResponse toPointBalanceResponse(Wallet wallet) { + return new PointBalanceResponse(wallet.getBalance(), wallet.getGiftablePoint()); + } } \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 34672e6..c3ce73b 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -17,7 +17,9 @@ spring: # The database schema is created from scratch on every application start. jpa: hibernate: - ddl-auto: create + ddl-auto: create-drop + defer-datasource-initialization: true + # Uses development-specific Gmail credentials. # NOTE: Using an App Password instead of the actual password is recommended. diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 748b7b8..5eeb437 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -34,3 +34,12 @@ aws: s3: # Production S3 bucket name bucket-name: 'joycrew-prod-bucket' + +server: + tomcat: + accesslog: + enabled: true + directory: /var/log/joycrew + prefix: access_log + suffix: .log + pattern: common \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index dc0a155..a50febc 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -6,13 +6,6 @@ # =================================================================== server: port: 8082 - tomcat: - accesslog: - enabled: true - directory: /var/log/joycrew - prefix: access_log - suffix: .log - pattern: common spring: application: @@ -63,7 +56,7 @@ aws: s3: bucket-name: 'joycrew-dev-bucket' -#view-cleanup-scheduling +# view-cleanup-scheduling jobs: recent-view-cleanup: enabled: true diff --git a/src/test/java/com/joycrew/backend/JoyCrewBackendApplicationTests.java b/src/test/java/com/joycrew/backend/JoyCrewBackendApplicationTests.java index 56fc78e..a057187 100644 --- a/src/test/java/com/joycrew/backend/JoyCrewBackendApplicationTests.java +++ b/src/test/java/com/joycrew/backend/JoyCrewBackendApplicationTests.java @@ -6,8 +6,8 @@ @SpringBootTest class JoyCrewBackendApplicationTests { - @Test - void contextLoads() { - } + @Test + void contextLoads() { + } } diff --git a/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java b/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java index 39e5586..ecb7cd2 100644 --- a/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java +++ b/src/test/java/com/joycrew/backend/config/TestUserDetailsService.java @@ -14,33 +14,33 @@ @Service public class TestUserDetailsService implements UserDetailsService { - private final Map users = new HashMap<>(); + private final Map users = new HashMap<>(); - public TestUserDetailsService() { - users.put("testuser@joycrew.com", Employee.builder() - .employeeId(1L) - .email("testuser@joycrew.com") - .employeeName("Test User") - .role(AdminLevel.EMPLOYEE) - .status("ACTIVE") - .passwordHash("{noop}password") - .build()); + public TestUserDetailsService() { + users.put("testuser@joycrew.com", Employee.builder() + .employeeId(1L) + .email("testuser@joycrew.com") + .employeeName("Test User") + .role(AdminLevel.EMPLOYEE) + .status("ACTIVE") + .passwordHash("{noop}password") + .build()); - users.put("nowallet@joycrew.com", Employee.builder() - .employeeId(99L) - .email("nowallet@joycrew.com") - .employeeName("No Wallet User") - .role(AdminLevel.EMPLOYEE) - .status("ACTIVE") - .passwordHash("{noop}password") - .build()); - } + users.put("nowallet@joycrew.com", Employee.builder() + .employeeId(99L) + .email("nowallet@joycrew.com") + .employeeName("No Wallet User") + .role(AdminLevel.EMPLOYEE) + .status("ACTIVE") + .passwordHash("{noop}password") + .build()); + } - @Override - public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - if (!users.containsKey(username)) { - throw new UsernameNotFoundException("User not found: " + username); - } - return new UserPrincipal(users.get(username)); + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + if (!users.containsKey(username)) { + throw new UsernameNotFoundException("User not found: " + username); } + return new UserPrincipal(users.get(username)); + } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java b/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java index 0de627b..ad048be 100644 --- a/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java @@ -9,6 +9,7 @@ import com.joycrew.backend.entity.enums.AdminLevel; import com.joycrew.backend.entity.enums.TransactionType; import com.joycrew.backend.security.WithMockUserPrincipal; +import com.joycrew.backend.service.AdminDashboardService; import com.joycrew.backend.service.AdminPointService; import com.joycrew.backend.service.EmployeeManagementService; import com.joycrew.backend.service.EmployeeRegistrationService; @@ -40,6 +41,7 @@ class AdminEmployeeControllerTest { @MockBean private EmployeeRegistrationService registrationService; @MockBean private EmployeeManagementService managementService; @MockBean private AdminPointService pointService; + @MockBean private AdminDashboardService adminDashboardService; // MockBean 추가 @Test @WithMockUser(roles = "SUPER_ADMIN") @@ -59,7 +61,7 @@ void registerEmployee_success() throws Exception { mockMvc.perform(post("/api/admin/employees") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) - .with(csrf())) // CSRF 토큰 추가 + .with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("Employee created successfully (ID: 1)")); } @@ -77,7 +79,7 @@ void registerEmployeesFromCsv_success() throws Exception { // When & Then mockMvc.perform(multipart("/api/admin/employees/bulk") .file(file) - .with(csrf())) // CSRF 토큰 추가 + .with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("CSV processed and employee registration completed.")); } @@ -93,7 +95,7 @@ void updateEmployee_Success() throws Exception { mockMvc.perform(patch("/api/admin/employees/1") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) - .with(csrf())) // CSRF 토큰 추가 + .with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("Employee information updated successfully.")); } @@ -104,7 +106,7 @@ void updateEmployee_Success() throws Exception { void deleteEmployee_Success() throws Exception { // When & Then mockMvc.perform(delete("/api/admin/employees/1") - .with(csrf())) // CSRF 토큰 추가 + .with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("Employee successfully deactivated.")); } @@ -122,14 +124,14 @@ void distributePoints_Success() throws Exception { AdminPointDistributionRequest request = new AdminPointDistributionRequest( distributions, "Bonus", - TransactionType.AWARD_MANAGER_SPOT + TransactionType.ADMIN_ADJUSTMENT ); // When & Then mockMvc.perform(post("/api/admin/employees/points/distribute") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) - .with(csrf())) // CSRF 토큰 추가 + .with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("Point distribution process completed successfully.")); } -} \ No newline at end of file +} diff --git a/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java b/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java index fc86909..27b7c92 100644 --- a/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/AuthControllerTest.java @@ -29,90 +29,90 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(controllers = AuthController.class, - excludeAutoConfiguration = SecurityAutoConfiguration.class) + excludeAutoConfiguration = SecurityAutoConfiguration.class) @Import(GlobalExceptionHandler.class) class AuthControllerTest { - @Autowired private MockMvc mockMvc; - @Autowired private ObjectMapper objectMapper; - @MockBean private AuthService authService; + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @MockBean private AuthService authService; - @Test - @DisplayName("POST /api/auth/login - Should succeed with correct credentials") - void login_Success() throws Exception { - // Given - LoginRequest request = new LoginRequest("test@joycrew.com", "password123!"); - LoginResponse successResponse = new LoginResponse( - "mocked.jwt.token", "Login successful", 1L, - "Test User", "test@joycrew.com", AdminLevel.EMPLOYEE, - 1000, "http://example.com/profile.jpg" - ); - when(authService.login(any(LoginRequest.class))).thenReturn(successResponse); + @Test + @DisplayName("POST /api/auth/login - Should succeed with correct credentials") + void login_Success() throws Exception { + // Given + LoginRequest request = new LoginRequest("test@joycrew.com", "password123!"); + LoginResponse successResponse = new LoginResponse( + "mocked.jwt.token", "Login successful", 1L, + "Test User", "test@joycrew.com", AdminLevel.EMPLOYEE, + 1000, "http://example.com/profile.jpg" + ); + when(authService.login(any(LoginRequest.class))).thenReturn(successResponse); - // When & Then - mockMvc.perform(post("/api/auth/login") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.accessToken").value("mocked.jwt.token")) - .andExpect(jsonPath("$.message").value("Login successful")); - } + // When & Then + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.accessToken").value("mocked.jwt.token")) + .andExpect(jsonPath("$.message").value("Login successful")); + } - @Test - @DisplayName("POST /api/auth/login - Should fail with bad credentials") - void login_Failure_AuthenticationError() throws Exception { - // Given - LoginRequest request = new LoginRequest("test@joycrew.com", "wrongpassword"); - when(authService.login(any(LoginRequest.class))) - .thenThrow(new BadCredentialsException("Bad credentials")); + @Test + @DisplayName("POST /api/auth/login - Should fail with bad credentials") + void login_Failure_AuthenticationError() throws Exception { + // Given + LoginRequest request = new LoginRequest("test@joycrew.com", "wrongpassword"); + when(authService.login(any(LoginRequest.class))) + .thenThrow(new BadCredentialsException("Bad credentials")); - // When & Then - mockMvc.perform(post("/api/auth/login") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.code").value("AUTHENTICATION_FAILED")); - } + // When & Then + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTHENTICATION_FAILED")); + } - @Test - @DisplayName("POST /api/auth/logout - Should succeed") - void logout_Success() throws Exception { - // Given - doNothing().when(authService).logout(any(HttpServletRequest.class)); + @Test + @DisplayName("POST /api/auth/logout - Should succeed") + void logout_Success() throws Exception { + // Given + doNothing().when(authService).logout(any(HttpServletRequest.class)); - // When & Then - mockMvc.perform(post("/api/auth/logout")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("You have been logged out.")); - } + // When & Then + mockMvc.perform(post("/api/auth/logout")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("You have been logged out.")); + } - @Test - @DisplayName("POST /api/auth/password-reset/request - Should succeed") - void requestPasswordReset_Success() throws Exception { - // Given - PasswordResetRequest request = new PasswordResetRequest("user@example.com"); - doNothing().when(authService).requestPasswordReset(anyString()); + @Test + @DisplayName("POST /api/auth/password-reset/request - Should succeed") + void requestPasswordReset_Success() throws Exception { + // Given + PasswordResetRequest request = new PasswordResetRequest("user@example.com"); + doNothing().when(authService).requestPasswordReset(anyString()); - // When & Then - mockMvc.perform(post("/api/auth/password-reset/request") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("A password reset email has been requested. Please check your email.")); - } + // When & Then + mockMvc.perform(post("/api/auth/password-reset/request") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("A password reset email has been requested. Please check your email.")); + } - @Test - @DisplayName("POST /api/auth/password-reset/confirm - Should succeed") - void confirmPasswordReset_Success() throws Exception { - // Given - PasswordResetConfirmRequest request = new PasswordResetConfirmRequest("valid-token", "newPassword123!"); - doNothing().when(authService).confirmPasswordReset(anyString(), anyString()); + @Test + @DisplayName("POST /api/auth/password-reset/confirm - Should succeed") + void confirmPasswordReset_Success() throws Exception { + // Given + PasswordResetConfirmRequest request = new PasswordResetConfirmRequest("valid-token", "newPassword123!"); + doNothing().when(authService).confirmPasswordReset(anyString(), anyString()); - // When & Then - mockMvc.perform(post("/api/auth/password-reset/confirm") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("Password changed successfully.")); - } -} \ No newline at end of file + // When & Then + mockMvc.perform(post("/api/auth/password-reset/confirm") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Password changed successfully.")); + } +} diff --git a/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java b/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java index ace759b..b7103a8 100644 --- a/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java @@ -24,30 +24,34 @@ @WebMvcTest(controllers = EmployeeQueryController.class) class EmployeeQueryControllerTest { - @Autowired private MockMvc mockMvc; - @MockBean private EmployeeQueryService employeeQueryService; - @MockBean private JwtUtil jwtUtil; - @MockBean private EmployeeDetailsService employeeDetailsService; + @Autowired + private MockMvc mockMvc; + @MockBean + private EmployeeQueryService employeeQueryService; + @MockBean + private JwtUtil jwtUtil; + @MockBean + private EmployeeDetailsService employeeDetailsService; - @Test - @DisplayName("GET /api/employee/query - Should search employees successfully") - @WithMockUserPrincipal - void searchEmployees_success() throws Exception { - // Given - EmployeeQueryResponse mockEmployee = new EmployeeQueryResponse( - 2L, "https://cdn.joycrew.com/profile/user1.jpg", - "Jane Doe", "HR", "Staff" - ); - PagedEmployeeResponse mockResponse = new PagedEmployeeResponse(List.of(mockEmployee), 0, 1, true); - when(employeeQueryService.getEmployees(anyString(), anyInt(), anyInt(), anyLong())) - .thenReturn(mockResponse); + @Test + @DisplayName("GET /api/employee/query - Should search employees successfully") + @WithMockUserPrincipal + void searchEmployees_success() throws Exception { + // Given + EmployeeQueryResponse mockEmployee = new EmployeeQueryResponse( + 2L, "https://cdn.joycrew.com/profile/user1.jpg", + "Jane Doe", "HR", "Staff" + ); + PagedEmployeeResponse mockResponse = new PagedEmployeeResponse(List.of(mockEmployee), 0, 1, true); + when(employeeQueryService.getEmployees(anyString(), anyInt(), anyInt(), anyLong())) + .thenReturn(mockResponse); - // When & Then - mockMvc.perform(get("/api/employee/query") - .param("keyword", "Jane") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.employees[0].employeeName").value("Jane Doe")) - .andExpect(jsonPath("$.currentPage").value(0)); - } + // When & Then + mockMvc.perform(get("/api/employee/query") + .param("keyword", "Jane") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.employees[0].employeeName").value("Jane Doe")) + .andExpect(jsonPath("$.currentPage").value(0)); + } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/GiftPointControllerTest.java b/src/test/java/com/joycrew/backend/controller/GiftPointControllerTest.java index 12f6bd3..e322e1a 100644 --- a/src/test/java/com/joycrew/backend/controller/GiftPointControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/GiftPointControllerTest.java @@ -27,28 +27,33 @@ @WebMvcTest(GiftPointController.class) class GiftPointControllerTest { - @Autowired private MockMvc mockMvc; - @Autowired private ObjectMapper objectMapper; - @MockBean private GiftPointService giftPointService; - @MockBean private JwtUtil jwtUtil; - @MockBean private EmployeeDetailsService employeeDetailsService; + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @MockBean + private GiftPointService giftPointService; + @MockBean + private JwtUtil jwtUtil; + @MockBean + private EmployeeDetailsService employeeDetailsService; - @Test - @WithMockUserPrincipal(email = "sender@example.com") - @DisplayName("POST /api/gift-points - Should gift points to a colleague successfully") - void testGiftPointsSuccess() throws Exception { - // given - GiftPointRequest request = new GiftPointRequest( - 2L, 50, "Great work!", List.of(Tag.TEAMWORK) - ); - doNothing().when(giftPointService).giftPointsToColleague(anyString(), any(GiftPointRequest.class)); + @Test + @WithMockUserPrincipal(email = "sender@example.com") + @DisplayName("POST /api/gift-points - Should gift points to a colleague successfully") + void testGiftPointsSuccess() throws Exception { + // given + GiftPointRequest request = new GiftPointRequest( + 2L, 50, "Great work!", List.of(Tag.TEAMWORK) + ); + doNothing().when(giftPointService).giftPointsToColleague(anyString(), any(GiftPointRequest.class)); - // when & then - mockMvc.perform(post("/api/gift-points") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - .with(csrf())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("Points sent successfully.")); - } + // when & then + mockMvc.perform(post("/api/gift-points") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Points sent successfully.")); + } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/ProductControllerTest.java b/src/test/java/com/joycrew/backend/controller/ProductControllerTest.java new file mode 100644 index 0000000..d4cf252 --- /dev/null +++ b/src/test/java/com/joycrew/backend/controller/ProductControllerTest.java @@ -0,0 +1,74 @@ +package com.joycrew.backend.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.joycrew.backend.dto.PagedProductResponse; +import com.joycrew.backend.security.EmployeeDetailsService; +import com.joycrew.backend.security.JwtUtil; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = ProductController.class, excludeAutoConfiguration = SecurityAutoConfiguration.class) +class ProductControllerTest { + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private ProductService productService; + @MockBean + private JwtUtil jwtUtil; + @MockBean + private EmployeeDetailsService employeeDetailsService; + + @Test + @DisplayName("GET /api/products - Should return all products") + void getAllProducts_Success() throws Exception { + // Given + PagedProductResponse mockResponse = new PagedProductResponse( + Collections.emptyList(), 0, 20, 0, 0, false, false + ); + when(productService.getAllProducts(anyInt(), anyInt())).thenReturn(mockResponse); + + // When & Then + mockMvc.perform(get("/api/products") + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()); + } + + @Test + @DisplayName("GET /api/products/search - Should return products matching the keyword") + void searchProductsByName_Success() throws Exception { + // Given + PagedProductResponse mockResponse = new PagedProductResponse( + Collections.emptyList(), 0, 20, 0, 0, false, false + ); + when(productService.searchProductsByName(anyString(), anyInt(), anyInt())) + .thenReturn(mockResponse); + + // When & Then + mockMvc.perform(get("/api/products/search") + .param("keyword", "Test") + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()); + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/StatisticsControllerTest.java b/src/test/java/com/joycrew/backend/controller/StatisticsControllerTest.java new file mode 100644 index 0000000..151eef5 --- /dev/null +++ b/src/test/java/com/joycrew/backend/controller/StatisticsControllerTest.java @@ -0,0 +1,62 @@ +package com.joycrew.backend.controller; + +import com.joycrew.backend.dto.PointStatisticsResponse; +import com.joycrew.backend.dto.TransactionHistoryResponse; +import com.joycrew.backend.security.EmployeeDetailsService; +import com.joycrew.backend.security.JwtUtil; +import com.joycrew.backend.security.WithMockUserPrincipal; +import com.joycrew.backend.service.StatisticsService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; +import java.util.List; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(StatisticsController.class) +class StatisticsControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private StatisticsService statisticsService; + + @MockBean + private JwtUtil jwtUtil; + @MockBean + private EmployeeDetailsService employeeDetailsService; + + @Test + @DisplayName("GET /api/statistics/me - Should return detailed statistics successfully") + @WithMockUserPrincipal + void getMyStatistics_Success() throws Exception { + // Given + List mockTagCounts = List.of(3L, 5L, 6L, 0L, 0L, 0L, 0L, 0L); + List mockRecentTransactions = Collections.emptyList(); + PointStatisticsResponse mockResponse = new PointStatisticsResponse( + 100, 50, mockTagCounts, mockRecentTransactions + ); + + when(statisticsService.getPointStatistics(anyString())).thenReturn(mockResponse); + + // When & Then + mockMvc.perform(get("/api/statistics/me")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.totalPointsReceived").value(100)) + .andExpect(jsonPath("$.totalPointsSent").value(50)) + .andExpect(jsonPath("$.tagCounts[0]").value(3)) + .andExpect(jsonPath("$.tagCounts[1]").value(5)) + .andExpect(jsonPath("$.recentTransactions").isArray()); + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/TransactionHistoryControllerTest.java b/src/test/java/com/joycrew/backend/controller/TransactionHistoryControllerTest.java index 2ada93e..378738e 100644 --- a/src/test/java/com/joycrew/backend/controller/TransactionHistoryControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/TransactionHistoryControllerTest.java @@ -24,29 +24,33 @@ @WebMvcTest(controllers = TransactionHistoryController.class) class TransactionHistoryControllerTest { - @Autowired private MockMvc mockMvc; - @MockBean private TransactionHistoryService transactionHistoryService; - @MockBean private JwtUtil jwtUtil; - @MockBean private EmployeeDetailsService employeeDetailsService; + @Autowired + private MockMvc mockMvc; + @MockBean + private TransactionHistoryService transactionHistoryService; + @MockBean + private JwtUtil jwtUtil; + @MockBean + private EmployeeDetailsService employeeDetailsService; - @Test - @DisplayName("GET /api/transactions - Should get my transaction history successfully") - @WithMockUserPrincipal(email = "user@joycrew.com") - void getMyTransactions_Success() throws Exception { - // Given - List mockHistory = List.of( - TransactionHistoryResponse.builder() - .transactionId(1L).type(TransactionType.AWARD_P2P).amount(-50) - .counterparty("Colleague Name").message("Thanks!") - .transactionDate(LocalDateTime.now()).build() - ); - when(transactionHistoryService.getTransactionHistory("user@joycrew.com")).thenReturn(mockHistory); + @Test + @DisplayName("GET /api/transactions - Should get my transaction history successfully") + @WithMockUserPrincipal(email = "user@joycrew.com") + void getMyTransactions_Success() throws Exception { + // Given + List mockHistory = List.of( + TransactionHistoryResponse.builder() + .transactionId(1L).type(TransactionType.AWARD_P2P).amount(-50) + .counterparty("Colleague Name").message("Thanks!") + .transactionDate(LocalDateTime.now()).build() + ); + when(transactionHistoryService.getTransactionHistory("user@joycrew.com")).thenReturn(mockHistory); - // When & Then - mockMvc.perform(get("/api/transactions") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].transactionId").value(1L)) - .andExpect(jsonPath("$[0].counterparty").value("Colleague Name")); - } + // When & Then + mockMvc.perform(get("/api/transactions") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].transactionId").value(1L)) + .andExpect(jsonPath("$[0].counterparty").value("Colleague Name")); + } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/UserControllerTest.java b/src/test/java/com/joycrew/backend/controller/UserControllerTest.java index 56a2b4b..fd2f15b 100644 --- a/src/test/java/com/joycrew/backend/controller/UserControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/UserControllerTest.java @@ -30,66 +30,71 @@ @WebMvcTest(controllers = UserController.class) class UserControllerTest { - @Autowired private MockMvc mockMvc; - @Autowired private ObjectMapper objectMapper; - @MockBean private EmployeeService employeeService; - @MockBean private JwtUtil jwtUtil; - @MockBean private EmployeeDetailsService employeeDetailsService; + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @MockBean + private EmployeeService employeeService; + @MockBean + private JwtUtil jwtUtil; + @MockBean + private EmployeeDetailsService employeeDetailsService; - @Test - @DisplayName("GET /api/user/profile - Should get profile successfully") - @WithMockUserPrincipal - void getProfile_Success() throws Exception { - // Given - UserProfileResponse mockResponse = new UserProfileResponse( - 1L, "Test User", "testuser@joycrew.com", null, - 1500, 100, AdminLevel.EMPLOYEE, "Engineering", "Staff", - null, null, null - ); - when(employeeService.getUserProfile("testuser@joycrew.com")).thenReturn(mockResponse); + @Test + @DisplayName("GET /api/user/profile - Should get profile successfully") + @WithMockUserPrincipal + void getProfile_Success() throws Exception { + // Given + UserProfileResponse mockResponse = new UserProfileResponse( + 1L, "Test User", "testuser@joycrew.com", null, + 1500, 100, AdminLevel.EMPLOYEE, "Engineering", "Staff", + null, null, null + ); + when(employeeService.getUserProfile("testuser@joycrew.com")).thenReturn(mockResponse); - // When & Then - mockMvc.perform(get("/api/user/profile")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.name").value("Test User")); - } + // When & Then + mockMvc.perform(get("/api/user/profile")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Test User")); + } - @Test - @DisplayName("POST /api/user/password - Should change password successfully") - @WithMockUserPrincipal - void forcePasswordChange_Success() throws Exception { - // Given - PasswordChangeRequest request = new PasswordChangeRequest("newPassword123!"); - doNothing().when(employeeService).forcePasswordChange(eq("testuser@joycrew.com"), any(PasswordChangeRequest.class)); + @Test + @DisplayName("POST /api/user/password - Should change password successfully") + @WithMockUserPrincipal + void forcePasswordChange_Success() throws Exception { + // Given + PasswordChangeRequest request = new PasswordChangeRequest("newPassword123!"); + doNothing().when(employeeService).forcePasswordChange(eq("testuser@joycrew.com"), any(PasswordChangeRequest.class)); - // When & Then - mockMvc.perform(post("/api/user/password") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - .with(csrf())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("Password changed successfully.")); - } + // When & Then + mockMvc.perform(post("/api/user/password") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Password changed successfully.")); + } - @Test - @DisplayName("PATCH /api/user/profile - Should update profile successfully") - @WithMockUserPrincipal - void updateMyProfile_Success() throws Exception { - // Given - UserProfileUpdateRequest requestDto = new UserProfileUpdateRequest("New Name", null, null, null, null, "New Address"); - MockMultipartFile requestPart = new MockMultipartFile("request", "", "application/json", objectMapper.writeValueAsBytes(requestDto)); - MockMultipartFile imagePart = new MockMultipartFile("profileImage", "image.jpg", MediaType.IMAGE_JPEG_VALUE, "image_bytes".getBytes()); + @Test + @DisplayName("PATCH /api/user/profile - Should update profile successfully") + @WithMockUserPrincipal + void updateMyProfile_Success() throws Exception { + // Given + UserProfileUpdateRequest requestDto = new UserProfileUpdateRequest("New Name", null, null, null, null, "New Address"); + MockMultipartFile requestPart = new MockMultipartFile("request", "", "application/json", objectMapper.writeValueAsBytes(requestDto)); + MockMultipartFile imagePart = new MockMultipartFile("profileImage", "image.jpg", MediaType.IMAGE_JPEG_VALUE, "image_bytes".getBytes()); - // When & Then - mockMvc.perform(multipart("/api/user/profile") - .file(requestPart) - .file(imagePart) - .with(req -> { - req.setMethod("PATCH"); - return req; - }) - .with(csrf())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("Your information has been updated successfully.")); - } + // When & Then + mockMvc.perform(multipart("/api/user/profile") + .file(requestPart) + .file(imagePart) + .with(req -> { + req.setMethod("PATCH"); + return req; + }) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Your information has been updated successfully.")); + } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/WalletControllerTest.java b/src/test/java/com/joycrew/backend/controller/WalletControllerTest.java index 13aa413..8088f11 100644 --- a/src/test/java/com/joycrew/backend/controller/WalletControllerTest.java +++ b/src/test/java/com/joycrew/backend/controller/WalletControllerTest.java @@ -20,24 +20,28 @@ @WebMvcTest(controllers = WalletController.class) class WalletControllerTest { - @Autowired private MockMvc mockMvc; - @MockBean private WalletService walletService; - @MockBean private JwtUtil jwtUtil; - @MockBean private EmployeeDetailsService employeeDetailsService; + @Autowired + private MockMvc mockMvc; + @MockBean + private WalletService walletService; + @MockBean + private JwtUtil jwtUtil; + @MockBean + private EmployeeDetailsService employeeDetailsService; - @Test - @DisplayName("GET /api/wallet/point - Should get point balance successfully") - @WithMockUserPrincipal - void getWalletPoint_Success() throws Exception { - // Given - PointBalanceResponse mockResponse = new PointBalanceResponse(1500, 100); - when(walletService.getPointBalance("testuser@joycrew.com")).thenReturn(mockResponse); + @Test + @DisplayName("GET /api/wallet/point - Should get point balance successfully") + @WithMockUserPrincipal + void getWalletPoint_Success() throws Exception { + // Given + PointBalanceResponse mockResponse = new PointBalanceResponse(1500, 100); + when(walletService.getPointBalance("testuser@joycrew.com")).thenReturn(mockResponse); - // When & Then - mockMvc.perform(get("/api/wallet/point")) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.totalBalance").value(1500)) - .andExpect(jsonPath("$.giftableBalance").value(100)); - } + // When & Then + mockMvc.perform(get("/api/wallet/point")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.totalBalance").value(1500)) + .andExpect(jsonPath("$.giftableBalance").value(100)); + } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/security/WithMockUserPrincipal.java b/src/test/java/com/joycrew/backend/security/WithMockUserPrincipal.java index 0ee1e33..8ec72d8 100644 --- a/src/test/java/com/joycrew/backend/security/WithMockUserPrincipal.java +++ b/src/test/java/com/joycrew/backend/security/WithMockUserPrincipal.java @@ -1,13 +1,16 @@ package com.joycrew.backend.security; import org.springframework.security.test.context.support.WithSecurityContext; + import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @Retention(RetentionPolicy.RUNTIME) @WithSecurityContext(factory = WithMockUserPrincipalSecurityContextFactory.class) public @interface WithMockUserPrincipal { - long id() default 1L; - String email() default "testuser@joycrew.com"; - String role() default "EMPLOYEE"; + long id() default 1L; + + String email() default "testuser@joycrew.com"; + + String role() default "EMPLOYEE"; } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/security/WithMockUserPrincipalSecurityContextFactory.java b/src/test/java/com/joycrew/backend/security/WithMockUserPrincipalSecurityContextFactory.java index db7c89e..2afe701 100644 --- a/src/test/java/com/joycrew/backend/security/WithMockUserPrincipalSecurityContextFactory.java +++ b/src/test/java/com/joycrew/backend/security/WithMockUserPrincipalSecurityContextFactory.java @@ -8,21 +8,21 @@ import org.springframework.security.test.context.support.WithSecurityContextFactory; public class WithMockUserPrincipalSecurityContextFactory implements WithSecurityContextFactory { - @Override - public SecurityContext createSecurityContext(WithMockUserPrincipal annotation) { - SecurityContext context = SecurityContextHolder.createEmptyContext(); - Employee mockEmployee = Employee.builder() - .employeeId(annotation.id()) - .email(annotation.email()) - .employeeName("Test User") - .role(AdminLevel.valueOf(annotation.role())) - .passwordHash("mockPassword") - .status("ACTIVE") - .build(); - UserPrincipal principal = new UserPrincipal(mockEmployee); - UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( - principal, principal.getPassword(), principal.getAuthorities()); - context.setAuthentication(authentication); - return context; - } + @Override + public SecurityContext createSecurityContext(WithMockUserPrincipal annotation) { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + Employee mockEmployee = Employee.builder() + .employeeId(annotation.id()) + .email(annotation.email()) + .employeeName("Test User") + .role(AdminLevel.valueOf(annotation.role())) + .passwordHash("mockPassword") + .status("ACTIVE") + .build(); + UserPrincipal principal = new UserPrincipal(mockEmployee); + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + principal, principal.getPassword(), principal.getAuthorities()); + context.setAuthentication(authentication); + return context; + } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/AdminFeaturesIntegrationTest.java b/src/test/java/com/joycrew/backend/service/AdminFeaturesIntegrationTest.java index 936edc6..587ba97 100644 --- a/src/test/java/com/joycrew/backend/service/AdminFeaturesIntegrationTest.java +++ b/src/test/java/com/joycrew/backend/service/AdminFeaturesIntegrationTest.java @@ -27,83 +27,86 @@ @Transactional class AdminFeaturesIntegrationTest { - @Autowired private EmployeeManagementService managementService; - @Autowired private AdminPointService pointService; - - @Autowired private EmployeeRepository employeeRepository; - @Autowired private WalletRepository walletRepository; - @Autowired private CompanyRepository companyRepository; - @Autowired private RewardPointTransactionRepository transactionRepository; - - private Employee admin, employee1, employee2; - private Company company; - - @BeforeEach - void setUp() { - company = companyRepository.save(Company.builder().companyName("Integration Test Company").build()); - admin = createAndSaveEmployee("admin@test.com", "Admin", AdminLevel.SUPER_ADMIN, 0); - employee1 = createAndSaveEmployee("emp1@test.com", "Employee1", AdminLevel.EMPLOYEE, 100); - employee2 = createAndSaveEmployee("emp2@test.com", "Employee2", AdminLevel.EMPLOYEE, 200); - } - - private Employee createAndSaveEmployee(String email, String name, AdminLevel level, int initialPoints) { - Employee emp = Employee.builder().email(email).employeeName(name).role(level).company(company).passwordHash("...").status("ACTIVE").build(); - employeeRepository.save(emp); - Wallet wallet = new Wallet(emp); - if (initialPoints > 0) { - wallet.addPoints(initialPoints); - } - walletRepository.save(wallet); - return emp; - } - - @Test - @DisplayName("[Integration] Admin successfully distributes points to employees") - void distributePoints_Success() { - // Given - List distributions = List.of( - new PointDistributionDetail(employee1.getEmployeeId(), 500), - new PointDistributionDetail(employee2.getEmployeeId(), 500) - ); - - AdminPointDistributionRequest request = new AdminPointDistributionRequest( - distributions, - "Bonus Payout", - TransactionType.AWARD_MANAGER_SPOT - ); - - // When - pointService.distributePoints(request, admin); - - // Then - Wallet wallet1 = walletRepository.findByEmployee_EmployeeId(employee1.getEmployeeId()).get(); - Wallet wallet2 = walletRepository.findByEmployee_EmployeeId(employee2.getEmployeeId()).get(); - assertThat(wallet1.getBalance()).isEqualTo(100 + 500); - assertThat(wallet2.getBalance()).isEqualTo(200 + 500); - } - - @Test - @DisplayName("[Integration] Admin successfully deactivates an employee (soft delete)") - void deactivateEmployee_Success() { - // When - managementService.deactivateEmployee(employee1.getEmployeeId()); - - // Then - Employee deletedEmployee = employeeRepository.findById(employee1.getEmployeeId()).get(); - assertThat(deletedEmployee.getStatus()).isEqualTo("DELETED"); - } - - @Test - @DisplayName("[Integration] Admin successfully updates an employee's position") - void updateEmployee_Success() { - // Given - AdminEmployeeUpdateRequest request = new AdminEmployeeUpdateRequest(null, null, "Senior Researcher", null, null); - - // When - managementService.updateEmployee(employee1.getEmployeeId(), request); - - // Then - Employee updatedEmployee = employeeRepository.findById(employee1.getEmployeeId()).get(); - assertThat(updatedEmployee.getPosition()).isEqualTo("Senior Researcher"); + @Autowired private EmployeeManagementService managementService; + @Autowired private AdminPointService pointService; + + @Autowired private EmployeeRepository employeeRepository; + @Autowired private WalletRepository walletRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private RewardPointTransactionRepository transactionRepository; + + private Employee admin, employee1, employee2; + private Company company; + + @BeforeEach + void setUp() { + company = companyRepository.save(Company.builder() + .companyName("Integration Test Company") + .totalCompanyBalance(10000.0) + .build()); + admin = createAndSaveEmployee("admin@test.com", "Admin", AdminLevel.SUPER_ADMIN, 0); + employee1 = createAndSaveEmployee("emp1@test.com", "Employee1", AdminLevel.EMPLOYEE, 100); + employee2 = createAndSaveEmployee("emp2@test.com", "Employee2", AdminLevel.EMPLOYEE, 200); + } + + private Employee createAndSaveEmployee(String email, String name, AdminLevel level, int initialPoints) { + Employee emp = Employee.builder().email(email).employeeName(name).role(level).company(company).passwordHash("...").status("ACTIVE").build(); + employeeRepository.save(emp); + Wallet wallet = new Wallet(emp); + if (initialPoints > 0) { + wallet.addPoints(initialPoints); } + walletRepository.save(wallet); + return emp; + } + + @Test + @DisplayName("[Integration] Admin successfully distributes points to employees") + void distributePoints_Success() { + // Given + List distributions = List.of( + new PointDistributionDetail(employee1.getEmployeeId(), 500), + new PointDistributionDetail(employee2.getEmployeeId(), 500) + ); + + AdminPointDistributionRequest request = new AdminPointDistributionRequest( + distributions, + "Bonus Payout", + TransactionType.AWARD_MANAGER_SPOT + ); + + // When + pointService.distributePoints(request, admin); + + // Then + Wallet wallet1 = walletRepository.findByEmployee_EmployeeId(employee1.getEmployeeId()).get(); + Wallet wallet2 = walletRepository.findByEmployee_EmployeeId(employee2.getEmployeeId()).get(); + assertThat(wallet1.getBalance()).isEqualTo(100 + 500); + assertThat(wallet2.getBalance()).isEqualTo(200 + 500); + } + + @Test + @DisplayName("[Integration] Admin successfully deactivates an employee (soft delete)") + void deactivateEmployee_Success() { + // When + managementService.deactivateEmployee(employee1.getEmployeeId()); + + // Then + Employee deletedEmployee = employeeRepository.findById(employee1.getEmployeeId()).get(); + assertThat(deletedEmployee.getStatus()).isEqualTo("DELETED"); + } + + @Test + @DisplayName("[Integration] Admin successfully updates an employee's position") + void updateEmployee_Success() { + // Given + AdminEmployeeUpdateRequest request = new AdminEmployeeUpdateRequest(null, null, "Senior Researcher", null, null); + + // When + managementService.updateEmployee(employee1.getEmployeeId(), request); + + // Then + Employee updatedEmployee = employeeRepository.findById(employee1.getEmployeeId()).get(); + assertThat(updatedEmployee.getPosition()).isEqualTo("Senior Researcher"); + } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java index 9468181..27f8eaa 100644 --- a/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java +++ b/src/test/java/com/joycrew/backend/service/AuthServiceIntegrationTest.java @@ -25,65 +25,65 @@ @Transactional class AuthServiceIntegrationTest { - @Autowired private AuthService authService; - @Autowired private EmployeeRepository employeeRepository; - @Autowired private JwtUtil jwtUtil; - @Autowired private CompanyRepository companyRepository; - @Autowired private EmployeeRegistrationService registrationService; + @Autowired private AuthService authService; + @Autowired private EmployeeRepository employeeRepository; + @Autowired private JwtUtil jwtUtil; + @Autowired private CompanyRepository companyRepository; + @Autowired private EmployeeRegistrationService registrationService; - private String testEmail = "integration@joycrew.com"; - private String testPassword = "integrationPass123!"; - private String testName = "IntegrationTestUser"; - private Company defaultCompany; + private String testEmail = "integration@joycrew.com"; + private String testPassword = "integrationPass123!"; + private String testName = "IntegrationTestUser"; + private Company defaultCompany; - @BeforeEach - void setUp() { - defaultCompany = companyRepository.save(Company.builder().companyName("Test Company").build()); - employeeRepository.findByEmail(testEmail).ifPresent(employeeRepository::delete); + @BeforeEach + void setUp() { + defaultCompany = companyRepository.save(Company.builder().companyName("Test Company").build()); + employeeRepository.findByEmail(testEmail).ifPresent(employeeRepository::delete); - EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( - testName, testEmail, testPassword, - defaultCompany.getCompanyName(), null, "Staff", - AdminLevel.EMPLOYEE, null, null, null - ); - registrationService.registerEmployee(request); - } + EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( + testName, testEmail, testPassword, + defaultCompany.getCompanyName(), null, "Staff", + AdminLevel.EMPLOYEE, null, null, null + ); + registrationService.registerEmployee(request); + } - @Test - @DisplayName("[Integration] Login success returns JWT and user info") - void login_Integration_Success() { - // Given - LoginRequest request = new LoginRequest(testEmail, testPassword); + @Test + @DisplayName("[Integration] Login success returns JWT and user info") + void login_Integration_Success() { + // Given + LoginRequest request = new LoginRequest(testEmail, testPassword); - // When - LoginResponse response = authService.login(request); + // When + LoginResponse response = authService.login(request); - // Then - assertThat(response).isNotNull(); - assertThat(response.accessToken()).isNotBlank(); - assertThat(response.message()).isEqualTo("Login successful"); - assertThat(response.email()).isEqualTo(testEmail); - } + // Then + assertThat(response).isNotNull(); + assertThat(response.accessToken()).isNotBlank(); + assertThat(response.message()).isEqualTo("Login successful"); + assertThat(response.email()).isEqualTo(testEmail); + } - @Test - @DisplayName("[Integration] Login failure - Non-existent email") - void login_Integration_Failure_EmailNotFound() { - // Given - LoginRequest request = new LoginRequest("nonexistent@joycrew.com", "anypassword"); + @Test + @DisplayName("[Integration] Login failure - Non-existent email") + void login_Integration_Failure_EmailNotFound() { + // Given + LoginRequest request = new LoginRequest("nonexistent@joycrew.com", "anypassword"); - // When & Then - assertThatThrownBy(() -> authService.login(request)) - .isInstanceOf(BadCredentialsException.class); - } + // When & Then + assertThatThrownBy(() -> authService.login(request)) + .isInstanceOf(BadCredentialsException.class); + } - @Test - @DisplayName("[Integration] Login failure - Wrong password") - void login_Integration_Failure_WrongPassword() { - // Given - LoginRequest request = new LoginRequest(testEmail, "wrongpassword"); + @Test + @DisplayName("[Integration] Login failure - Wrong password") + void login_Integration_Failure_WrongPassword() { + // Given + LoginRequest request = new LoginRequest(testEmail, "wrongpassword"); - // When & Then - assertThatThrownBy(() -> authService.login(request)) - .isInstanceOf(BadCredentialsException.class); - } + // When & Then + assertThatThrownBy(() -> authService.login(request)) + .isInstanceOf(BadCredentialsException.class); + } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/AuthServiceTest.java b/src/test/java/com/joycrew/backend/service/AuthServiceTest.java index 3551a1c..dad0213 100644 --- a/src/test/java/com/joycrew/backend/service/AuthServiceTest.java +++ b/src/test/java/com/joycrew/backend/service/AuthServiceTest.java @@ -36,89 +36,89 @@ @ExtendWith(MockitoExtension.class) class AuthServiceTest { - @Mock private JwtUtil jwtUtil; - @Mock private AuthenticationManager authenticationManager; - @Mock private WalletRepository walletRepository; - @Mock private EmployeeRepository employeeRepository; - @Mock private PasswordEncoder passwordEncoder; - @Mock private EmailService emailService; - - @InjectMocks - private AuthService authService; - - private Employee testEmployee; - private LoginRequest testLoginRequest; - private final String testToken = "mocked.jwt.token"; - - @BeforeEach - void setUp() { - ReflectionTestUtils.setField(authService, "passwordResetExpirationMs", 900000L); - - testEmployee = Employee.builder() - .employeeId(1L) - .email("test@joycrew.com") - .passwordHash("encodedPassword") - .employeeName("Test User") - .role(AdminLevel.EMPLOYEE) - .status("ACTIVE") - .profileImageUrl("http://example.com/profile.jpg") - .build(); - - testLoginRequest = new LoginRequest("test@joycrew.com", "password123"); - } - - @Test - @DisplayName("[Unit] Login success should return JWT and user info") - void login_Success() { - // Given - UserPrincipal principal = new UserPrincipal(testEmployee); - Authentication successfulAuth = new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities()); - when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) - .thenReturn(successfulAuth); - - Wallet mockWallet = mock(Wallet.class); - when(mockWallet.getBalance()).thenReturn(1000); - when(walletRepository.findByEmployee_EmployeeId(anyLong())).thenReturn(Optional.of(mockWallet)); - - when(jwtUtil.generateToken(anyString())).thenReturn(testToken); - - // When - LoginResponse response = authService.login(testLoginRequest); - - // Then - assertThat(response).isNotNull(); - assertThat(response.accessToken()).isEqualTo(testToken); - assertThat(response.message()).isEqualTo("Login successful"); // 수정된 부분 - assertThat(response.userId()).isEqualTo(testEmployee.getEmployeeId()); - assertThat(response.email()).isEqualTo(testEmployee.getEmail()); - assertThat(response.totalPoint()).isEqualTo(1000); - assertThat(response.profileImageUrl()).isEqualTo(testEmployee.getProfileImageUrl()); - - verify(authenticationManager).authenticate(any(UsernamePasswordAuthenticationToken.class)); - verify(jwtUtil).generateToken(testEmployee.getEmail()); - } - - @Test - @DisplayName("[Unit] Login failure should throw BadCredentialsException") - void login_Failure_WrongPassword() { - // Given - when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) - .thenThrow(new BadCredentialsException("Bad credentials")); - - // When & Then - assertThatThrownBy(() -> authService.login(testLoginRequest)) - .isInstanceOf(BadCredentialsException.class); - } - - @Test - @DisplayName("[Unit] Login failure should throw UsernameNotFoundException") - void login_Failure_UserNotFound() { - // Given - when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) - .thenThrow(new UsernameNotFoundException("User not found")); - - // When & Then - assertThatThrownBy(() -> authService.login(testLoginRequest)) - .isInstanceOf(UsernameNotFoundException.class); - } + @Mock private JwtUtil jwtUtil; + @Mock private AuthenticationManager authenticationManager; + @Mock private WalletRepository walletRepository; + @Mock private EmployeeRepository employeeRepository; + @Mock private PasswordEncoder passwordEncoder; + @Mock private EmailService emailService; + + @InjectMocks + private AuthService authService; + + private Employee testEmployee; + private LoginRequest testLoginRequest; + private final String testToken = "mocked.jwt.token"; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(authService, "passwordResetExpirationMs", 900000L); + + testEmployee = Employee.builder() + .employeeId(1L) + .email("test@joycrew.com") + .passwordHash("encodedPassword") + .employeeName("Test User") + .role(AdminLevel.EMPLOYEE) + .status("ACTIVE") + .profileImageUrl("http://example.com/profile.jpg") + .build(); + + testLoginRequest = new LoginRequest("test@joycrew.com", "password123"); + } + + @Test + @DisplayName("[Unit] Login success should return JWT and user info") + void login_Success() { + // Given + UserPrincipal principal = new UserPrincipal(testEmployee); + Authentication successfulAuth = new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities()); + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) + .thenReturn(successfulAuth); + + Wallet mockWallet = mock(Wallet.class); + when(mockWallet.getBalance()).thenReturn(1000); + when(walletRepository.findByEmployee_EmployeeId(anyLong())).thenReturn(Optional.of(mockWallet)); + + when(jwtUtil.generateToken(anyString())).thenReturn(testToken); + + // When + LoginResponse response = authService.login(testLoginRequest); + + // Then + assertThat(response).isNotNull(); + assertThat(response.accessToken()).isEqualTo(testToken); + assertThat(response.message()).isEqualTo("Login successful"); // 수정된 부분 + assertThat(response.userId()).isEqualTo(testEmployee.getEmployeeId()); + assertThat(response.email()).isEqualTo(testEmployee.getEmail()); + assertThat(response.totalPoint()).isEqualTo(1000); + assertThat(response.profileImageUrl()).isEqualTo(testEmployee.getProfileImageUrl()); + + verify(authenticationManager).authenticate(any(UsernamePasswordAuthenticationToken.class)); + verify(jwtUtil).generateToken(testEmployee.getEmail()); + } + + @Test + @DisplayName("[Unit] Login failure should throw BadCredentialsException") + void login_Failure_WrongPassword() { + // Given + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) + .thenThrow(new BadCredentialsException("Bad credentials")); + + // When & Then + assertThatThrownBy(() -> authService.login(testLoginRequest)) + .isInstanceOf(BadCredentialsException.class); + } + + @Test + @DisplayName("[Unit] Login failure should throw UsernameNotFoundException") + void login_Failure_UserNotFound() { + // Given + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) + .thenThrow(new UsernameNotFoundException("User not found")); + + // When & Then + assertThatThrownBy(() -> authService.login(testLoginRequest)) + .isInstanceOf(UsernameNotFoundException.class); + } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/EmailServiceTest.java b/src/test/java/com/joycrew/backend/service/EmailServiceTest.java index 1a8112b..fbe159d 100644 --- a/src/test/java/com/joycrew/backend/service/EmailServiceTest.java +++ b/src/test/java/com/joycrew/backend/service/EmailServiceTest.java @@ -19,35 +19,35 @@ @ExtendWith(MockitoExtension.class) class EmailServiceTest { - @Mock - private JavaMailSender mailSender; - - @InjectMocks - private EmailService emailService; - - @BeforeEach - void setUp() { - ReflectionTestUtils.setField(emailService, "frontendUrlBase", "https://test.joycrew.co.kr"); - } - - @Test - @DisplayName("[Unit] Send password reset email - Verify MailSender call") - void sendPasswordResetEmail_Success() { - // Given - String toEmail = "test@joycrew.com"; - String token = "test-token"; - String expectedResetUrl = "https://test.joycrew.co.kr/reset-password?token=" + token; - - // When - emailService.sendPasswordResetEmail(toEmail, token); - - // Then - ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(SimpleMailMessage.class); - verify(mailSender, times(1)).send(messageCaptor.capture()); - - SimpleMailMessage sentMessage = messageCaptor.getValue(); - assertThat(sentMessage.getTo()).contains(toEmail); - assertThat(sentMessage.getSubject()).isEqualTo("[JoyCrew] Password Reset Instructions"); - assertThat(sentMessage.getText()).contains(expectedResetUrl); - } + @Mock + private JavaMailSender mailSender; + + @InjectMocks + private EmailService emailService; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(emailService, "frontendUrlBase", "https://test.joycrew.co.kr"); + } + + @Test + @DisplayName("[Unit] Send password reset email - Verify MailSender call") + void sendPasswordResetEmail_Success() { + // Given + String toEmail = "test@joycrew.com"; + String token = "test-token"; + String expectedResetUrl = "https://test.joycrew.co.kr/reset-password?token=" + token; + + // When + emailService.sendPasswordResetEmail(toEmail, token); + + // Then + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(SimpleMailMessage.class); + verify(mailSender, times(1)).send(messageCaptor.capture()); + + SimpleMailMessage sentMessage = messageCaptor.getValue(); + assertThat(sentMessage.getTo()).contains(toEmail); + assertThat(sentMessage.getSubject()).isEqualTo("[JoyCrew] Password Reset Instructions"); + assertThat(sentMessage.getText()).contains(expectedResetUrl); + } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/EmployeeQueryServiceTest.java b/src/test/java/com/joycrew/backend/service/EmployeeQueryServiceTest.java index fc85248..561636e 100644 --- a/src/test/java/com/joycrew/backend/service/EmployeeQueryServiceTest.java +++ b/src/test/java/com/joycrew/backend/service/EmployeeQueryServiceTest.java @@ -24,55 +24,58 @@ @ExtendWith(MockitoExtension.class) class EmployeeQueryServiceTest { - @Mock - private EntityManager em; - @Mock - private EmployeeMapper employeeMapper; + @Mock + private EntityManager em; + @Mock + private EmployeeMapper employeeMapper; - @InjectMocks - private EmployeeQueryService employeeQueryService; + @InjectMocks + private EmployeeQueryService employeeQueryService; - @Test - @DisplayName("[Unit] Get employee list - Should return with paging information") - void getEmployees_Success() { - // Given - String keyword = "test"; - int page = 0; - int size = 10; - Long currentUserId = 1L; + @Test + @DisplayName("[Unit] Get employee list - Should return with paging information") + void getEmployees_Success() { + // Given + String keyword = "test"; + int page = 0; + int size = 10; + Long currentUserId = 1L; - TypedQuery countQuery = mock(TypedQuery.class); - TypedQuery dataQuery = mock(TypedQuery.class); - Employee mockEmployee = Employee.builder().employeeId(2L).employeeName("Test User").build(); - EmployeeQueryResponse mockDto = new EmployeeQueryResponse(2L, null, "Test User", "Test Dept", "Tester"); + // Mocking TypedQuery for both count and data + TypedQuery countQuery = mock(TypedQuery.class); + TypedQuery dataQuery = mock(TypedQuery.class); + Employee mockEmployee = Employee.builder().employeeId(2L).employeeName("Test User").build(); + EmployeeQueryResponse mockDto = new EmployeeQueryResponse(2L, null, "Test User", "Test Dept", "Tester"); - // Mocking for the count query - when(em.createQuery(anyString(), eq(Long.class))).thenReturn(countQuery); - when(countQuery.setParameter(anyString(), any())).thenReturn(countQuery); - when(countQuery.getSingleResult()).thenReturn(1L); + // Mocking for the count query + when(em.createQuery(anyString(), eq(Long.class))).thenReturn(countQuery); + when(countQuery.setParameter(anyString(), any())).thenReturn(countQuery); + when(countQuery.getSingleResult()).thenReturn(1L); - // Mocking for the data query - when(em.createQuery(anyString(), eq(Employee.class))).thenReturn(dataQuery); - when(dataQuery.setParameter(anyString(), any())).thenReturn(dataQuery); - when(dataQuery.setFirstResult(anyInt())).thenReturn(dataQuery); - when(dataQuery.setMaxResults(anyInt())).thenReturn(dataQuery); - when(dataQuery.getResultList()).thenReturn(List.of(mockEmployee)); + // Mocking for the data query + when(em.createQuery(anyString(), eq(Employee.class))).thenReturn(dataQuery); + when(dataQuery.setParameter(anyString(), any())).thenReturn(dataQuery); + when(dataQuery.setFirstResult(anyInt())).thenReturn(dataQuery); + when(dataQuery.setMaxResults(anyInt())).thenReturn(dataQuery); + when(dataQuery.getResultList()).thenReturn(List.of(mockEmployee)); - // Mocking the mapper's behavior - when(employeeMapper.toEmployeeQueryResponse(any(Employee.class))).thenReturn(mockDto); + // Mocking the mapper's behavior + when(employeeMapper.toEmployeeQueryResponse(any(Employee.class))).thenReturn(mockDto); - // When - PagedEmployeeResponse response = employeeQueryService.getEmployees(keyword, page, size, currentUserId); + // When + PagedEmployeeResponse response = employeeQueryService.getEmployees(keyword, page, size, currentUserId); - // Then - assertThat(response).isNotNull(); - assertThat(response.employees()).hasSize(1); - assertThat(response.employees().get(0).employeeName()).isEqualTo("Test User"); - assertThat(response.currentPage()).isEqualTo(page); - assertThat(response.totalPages()).isEqualTo(1); - assertThat(response.isLastPage()).isTrue(); + // Then + assertThat(response).isNotNull(); + assertThat(response.employees()).hasSize(1); + assertThat(response.employees().get(0).employeeName()).isEqualTo("Test User"); + assertThat(response.currentPage()).isEqualTo(page); + assertThat(response.totalPages()).isEqualTo(1); + assertThat(response.isLastPage()).isTrue(); - verify(dataQuery, times(1)).setParameter("keyword", "%" + keyword.toLowerCase() + "%"); - verify(dataQuery, times(1)).setParameter("currentUserId", currentUserId); - } + verify(em, times(1)).createQuery(contains("SELECT COUNT(e)"), eq(Long.class)); + verify(em, times(1)).createQuery(contains("SELECT e FROM Employee e"), eq(Employee.class)); + verify(dataQuery, times(1)).setParameter("keyword", "%" + keyword.toLowerCase() + "%"); + verify(dataQuery, times(1)).setParameter("currentUserId", currentUserId); + } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java index f05ed8e..64980ca 100644 --- a/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java +++ b/src/test/java/com/joycrew/backend/service/EmployeeServiceIntegrationTest.java @@ -26,60 +26,60 @@ @Transactional class EmployeeServiceIntegrationTest { - @Autowired private EmployeeService employeeService; - @Autowired private EmployeeRepository employeeRepository; - @Autowired private WalletRepository walletRepository; - @Autowired private CompanyRepository companyRepository; - @Autowired private DepartmentRepository departmentRepository; - @Autowired private PasswordEncoder passwordEncoder; - @Autowired private EmployeeRegistrationService registrationService; + @Autowired private EmployeeService employeeService; + @Autowired private EmployeeRepository employeeRepository; + @Autowired private WalletRepository walletRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private DepartmentRepository departmentRepository; + @Autowired private PasswordEncoder passwordEncoder; + @Autowired private EmployeeRegistrationService registrationService; - private Company testCompany; - private Department testDepartment; + private Company testCompany; + private Department testDepartment; - @BeforeEach - void setUp() { - testCompany = companyRepository.save(Company.builder().companyName("Test Company").build()); - testDepartment = departmentRepository.save(Department.builder().name("Test Department").company(testCompany).build()); - } + @BeforeEach + void setUp() { + testCompany = companyRepository.save(Company.builder().companyName("Test Company").build()); + testDepartment = departmentRepository.save(Department.builder().name("Test Department").company(testCompany).build()); + } - @Test - @DisplayName("[Integration] Register new employee successfully") - void registerEmployee_Success() { - // Given - EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( - "New Employee", "new.employee@joycrew.com", "password123!", - testCompany.getCompanyName(), testDepartment.getName(), "Staff", AdminLevel.EMPLOYEE, - LocalDate.of(1998, 1, 1), "Seoul", LocalDate.now() - ); + @Test + @DisplayName("[Integration] Register new employee successfully") + void registerEmployee_Success() { + // Given + EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( + "New Employee", "new.employee@joycrew.com", "password123!", + testCompany.getCompanyName(), testDepartment.getName(), "Staff", AdminLevel.EMPLOYEE, + LocalDate.of(1998, 1, 1), "Seoul", LocalDate.now() + ); - // When - Employee savedEmployee = registrationService.registerEmployee(request); + // When + Employee savedEmployee = registrationService.registerEmployee(request); - // Then - assertThat(savedEmployee.getEmployeeId()).isNotNull(); - assertThat(savedEmployee.getEmail()).isEqualTo("new.employee@joycrew.com"); - assertThat(walletRepository.findByEmployee_EmployeeId(savedEmployee.getEmployeeId())).isPresent(); - } + // Then + assertThat(savedEmployee.getEmployeeId()).isNotNull(); + assertThat(savedEmployee.getEmail()).isEqualTo("new.employee@joycrew.com"); + assertThat(walletRepository.findByEmployee_EmployeeId(savedEmployee.getEmployeeId())).isPresent(); + } - @Test - @DisplayName("[Integration] Change employee password successfully") - void forcePasswordChange_Success() { - // Given - Employee employee = employeeRepository.save(Employee.builder() - .email("pw.change@joycrew.com") - .employeeName("Password Changer") - .passwordHash(passwordEncoder.encode("oldPassword")) - .company(testCompany) - .build()); + @Test + @DisplayName("[Integration] Change employee password successfully") + void forcePasswordChange_Success() { + // Given + Employee employee = employeeRepository.save(Employee.builder() + .email("pw.change@joycrew.com") + .employeeName("Password Changer") + .passwordHash(passwordEncoder.encode("oldPassword")) + .company(testCompany) + .build()); - PasswordChangeRequest request = new PasswordChangeRequest("newPassword123!"); + PasswordChangeRequest request = new PasswordChangeRequest("newPassword123!"); - // When - employeeService.forcePasswordChange(employee.getEmail(), request); + // When + employeeService.forcePasswordChange(employee.getEmail(), request); - // Then - Employee updatedEmployee = employeeRepository.findByEmail(employee.getEmail()).orElseThrow(); - assertThat(passwordEncoder.matches("newPassword123!", updatedEmployee.getPasswordHash())).isTrue(); - } + // Then + Employee updatedEmployee = employeeRepository.findByEmail(employee.getEmail()).orElseThrow(); + assertThat(passwordEncoder.matches("newPassword123!", updatedEmployee.getPasswordHash())).isTrue(); + } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java b/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java index 08c3aa9..5d56540 100644 --- a/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java +++ b/src/test/java/com/joycrew/backend/service/EmployeeServiceTest.java @@ -28,86 +28,86 @@ @ExtendWith(MockitoExtension.class) class EmployeeServiceTest { - @Mock private EmployeeRepository employeeRepository; - @Mock private WalletRepository walletRepository; - @Mock private PasswordEncoder passwordEncoder; - @Mock private EmployeeMapper employeeMapper; - - @InjectMocks - private EmployeeService employeeService; - - @Test - @DisplayName("[Unit] Get profile success - Wallet exists") - void getUserProfile_Success_WalletExists() { - // Given - String userEmail = "test@joycrew.com"; - Employee mockEmployee = Employee.builder().employeeId(1L).email(userEmail).employeeName("Test User").build(); - Wallet mockWallet = new Wallet(mockEmployee); - mockWallet.addPoints(200); - - UserProfileResponse mockDto = new UserProfileResponse(1L, "Test User", userEmail, null, 200, 200, AdminLevel.EMPLOYEE, null, null, null, null, null); - - when(employeeRepository.findByEmail(userEmail)).thenReturn(Optional.of(mockEmployee)); - when(walletRepository.findByEmployee_EmployeeId(1L)).thenReturn(Optional.of(mockWallet)); - when(employeeMapper.toUserProfileResponse(any(Employee.class), any(Wallet.class))).thenReturn(mockDto); - - // When - UserProfileResponse response = employeeService.getUserProfile(userEmail); - - // Then - assertThat(response).isNotNull(); - assertThat(response.name()).isEqualTo("Test User"); - assertThat(response.totalBalance()).isEqualTo(200); - } - - @Test - @DisplayName("[Unit] Get profile success - Wallet does not exist (defaults to 0)") - void getUserProfile_Success_WalletDoesNotExist() { - // Given - String userEmail = "test@joycrew.com"; - Employee mockEmployee = Employee.builder().employeeId(1L).email(userEmail).employeeName("Test User").build(); - UserProfileResponse mockDto = new UserProfileResponse(1L, "Test User", userEmail, null, 0, 0, AdminLevel.EMPLOYEE, null, null, null, null, null); - - when(employeeRepository.findByEmail(userEmail)).thenReturn(Optional.of(mockEmployee)); - when(walletRepository.findByEmployee_EmployeeId(1L)).thenReturn(Optional.empty()); - when(employeeMapper.toUserProfileResponse(any(Employee.class), any(Wallet.class))).thenReturn(mockDto); - - - // When - UserProfileResponse response = employeeService.getUserProfile(userEmail); - - // Then - assertThat(response).isNotNull(); - assertThat(response.name()).isEqualTo("Test User"); - assertThat(response.totalBalance()).isEqualTo(0); - } - - @Test - @DisplayName("[Unit] Change password success - Verifies changePassword call") - void forcePasswordChange_Success() { - // Given - String userEmail = "test@joycrew.com"; - PasswordChangeRequest request = new PasswordChangeRequest("newPassword123!"); - Employee mockEmployee = mock(Employee.class); - when(employeeRepository.findByEmail(userEmail)).thenReturn(Optional.of(mockEmployee)); - - // When - employeeService.forcePasswordChange(userEmail, request); - - // Then - verify(mockEmployee, times(1)).changePassword(request.newPassword(), passwordEncoder); - } - - @Test - @DisplayName("[Unit] Change password failure - User not found") - void forcePasswordChange_Failure_UserNotFound() { - // Given - String userEmail = "notfound@joycrew.com"; - PasswordChangeRequest request = new PasswordChangeRequest("newPassword123!"); - when(employeeRepository.findByEmail(anyString())).thenReturn(Optional.empty()); - - // When & Then - assertThatThrownBy(() -> employeeService.forcePasswordChange(userEmail, request)) - .isInstanceOf(UserNotFoundException.class); - } + @Mock private EmployeeRepository employeeRepository; + @Mock private WalletRepository walletRepository; + @Mock private PasswordEncoder passwordEncoder; + @Mock private EmployeeMapper employeeMapper; + + @InjectMocks + private EmployeeService employeeService; + + @Test + @DisplayName("[Unit] Get profile success - Wallet exists") + void getUserProfile_Success_WalletExists() { + // Given + String userEmail = "test@joycrew.com"; + Employee mockEmployee = Employee.builder().employeeId(1L).email(userEmail).employeeName("Test User").build(); + Wallet mockWallet = new Wallet(mockEmployee); + mockWallet.addPoints(200); + + UserProfileResponse mockDto = new UserProfileResponse(1L, "Test User", userEmail, null, 200, 200, AdminLevel.EMPLOYEE, null, null, null, null, null); + + when(employeeRepository.findByEmail(userEmail)).thenReturn(Optional.of(mockEmployee)); + when(walletRepository.findByEmployee_EmployeeId(1L)).thenReturn(Optional.of(mockWallet)); + when(employeeMapper.toUserProfileResponse(any(Employee.class), any(Wallet.class))).thenReturn(mockDto); + + // When + UserProfileResponse response = employeeService.getUserProfile(userEmail); + + // Then + assertThat(response).isNotNull(); + assertThat(response.name()).isEqualTo("Test User"); + assertThat(response.totalBalance()).isEqualTo(200); + } + + @Test + @DisplayName("[Unit] Get profile success - Wallet does not exist (defaults to 0)") + void getUserProfile_Success_WalletDoesNotExist() { + // Given + String userEmail = "test@joycrew.com"; + Employee mockEmployee = Employee.builder().employeeId(1L).email(userEmail).employeeName("Test User").build(); + UserProfileResponse mockDto = new UserProfileResponse(1L, "Test User", userEmail, null, 0, 0, AdminLevel.EMPLOYEE, null, null, null, null, null); + + when(employeeRepository.findByEmail(userEmail)).thenReturn(Optional.of(mockEmployee)); + when(walletRepository.findByEmployee_EmployeeId(1L)).thenReturn(Optional.empty()); + when(employeeMapper.toUserProfileResponse(any(Employee.class), any(Wallet.class))).thenReturn(mockDto); + + + // When + UserProfileResponse response = employeeService.getUserProfile(userEmail); + + // Then + assertThat(response).isNotNull(); + assertThat(response.name()).isEqualTo("Test User"); + assertThat(response.totalBalance()).isEqualTo(0); + } + + @Test + @DisplayName("[Unit] Change password success - Verifies changePassword call") + void forcePasswordChange_Success() { + // Given + String userEmail = "test@joycrew.com"; + PasswordChangeRequest request = new PasswordChangeRequest("newPassword123!"); + Employee mockEmployee = mock(Employee.class); + when(employeeRepository.findByEmail(userEmail)).thenReturn(Optional.of(mockEmployee)); + + // When + employeeService.forcePasswordChange(userEmail, request); + + // Then + verify(mockEmployee, times(1)).changePassword(request.newPassword(), passwordEncoder); + } + + @Test + @DisplayName("[Unit] Change password failure - User not found") + void forcePasswordChange_Failure_UserNotFound() { + // Given + String userEmail = "notfound@joycrew.com"; + PasswordChangeRequest request = new PasswordChangeRequest("newPassword123!"); + when(employeeRepository.findByEmail(anyString())).thenReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> employeeService.forcePasswordChange(userEmail, request)) + .isInstanceOf(UserNotFoundException.class); + } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/GiftPointServiceIntegrationTest.java b/src/test/java/com/joycrew/backend/service/GiftPointServiceIntegrationTest.java index c8720fa..4b00a9e 100644 --- a/src/test/java/com/joycrew/backend/service/GiftPointServiceIntegrationTest.java +++ b/src/test/java/com/joycrew/backend/service/GiftPointServiceIntegrationTest.java @@ -11,11 +11,11 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.ApplicationListener; import org.springframework.context.annotation.Bean; -import org.springframework.test.annotation.Commit; import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -26,79 +26,83 @@ @Transactional class GiftPointServiceIntegrationTest { - @TestConfiguration - static class TestConfig { - @Bean - TestRecognitionEventListener testRecognitionEventListener() { - return new TestRecognitionEventListener(); - } + @TestConfiguration + static class TestConfig { + @Bean + TestRecognitionEventListener testRecognitionEventListener() { + return new TestRecognitionEventListener(); } + } - static class TestRecognitionEventListener implements ApplicationListener { - private final java.util.List events = - new java.util.concurrent.CopyOnWriteArrayList<>(); - - @Override - public void onApplicationEvent(RecognitionEvent event) { - events.add(event); - } - - public java.util.List getEvents() { - return events; - } - } + static class TestRecognitionEventListener implements ApplicationListener { + private final java.util.List events = + new java.util.concurrent.CopyOnWriteArrayList<>(); - @org.springframework.beans.factory.annotation.Autowired private GiftPointService giftPointService; - @org.springframework.beans.factory.annotation.Autowired private EmployeeRegistrationService registrationService; - @org.springframework.beans.factory.annotation.Autowired private CompanyRepository companyRepository; - @org.springframework.beans.factory.annotation.Autowired private WalletRepository walletRepository; - @org.springframework.beans.factory.annotation.Autowired private TestRecognitionEventListener eventListener; - - private Long senderId, receiverId; - private Company company; - - @BeforeEach - void setUp() { - company = companyRepository.save(Company.builder().companyName("Test Inc.").build()); - - var senderRequest = new EmployeeRegistrationRequest( - "Sender", "sender@test.com", "password123!", - company.getCompanyName(), null, "Dev", AdminLevel.EMPLOYEE, - null, null, null - ); - var receiverRequest = new EmployeeRegistrationRequest( - "Receiver", "receiver@test.com", "password123!", - company.getCompanyName(), null, "Dev", AdminLevel.EMPLOYEE, - null, null, null - ); - - senderId = registrationService.registerEmployee(senderRequest).getEmployeeId(); - receiverId = registrationService.registerEmployee(receiverRequest).getEmployeeId(); - - // 초기 포인트 충전 - Wallet senderWallet = walletRepository.findByEmployee_EmployeeId(senderId).orElseThrow(); - senderWallet.addPoints(100); - walletRepository.save(senderWallet); - - // 이전 테스트 이벤트가 섞이지 않도록 초기화 - eventListener.getEvents().clear(); + @Override + public void onApplicationEvent(RecognitionEvent event) { + events.add(event); } - @Test - @Commit // 트랜잭션 커밋 후 발행(@TransactionalEventListener AFTER_COMMIT)도 검증 가능하게 - @DisplayName("[Integration] Gifting points should publish a RecognitionEvent") - void giftPoints_ShouldPublishEvent() { - // Given - var request = new GiftPointRequest(receiverId, 50, "Event Test", List.of()); - - // When - giftPointService.giftPointsToColleague("sender@test.com", request); - - // Then - long eventCount = eventListener.getEvents().stream() - .filter(e -> e.getReceiverId().equals(receiverId) && e.getPoints() == 50) - .count(); - - assertThat(eventCount).isEqualTo(1); + public java.util.List getEvents() { + return events; } -} + } + + @Autowired + private GiftPointService giftPointService; + @Autowired + private EmployeeRegistrationService registrationService; + @Autowired + private CompanyRepository companyRepository; + @Autowired + private WalletRepository walletRepository; + @Autowired + private TestRecognitionEventListener eventListener; + + private Long senderId, receiverId; + private Company company; + + @BeforeEach + void setUp() { + company = companyRepository.save(Company.builder().companyName("Test Inc.").build()); + + var senderRequest = new EmployeeRegistrationRequest( + "Sender", "sender@test.com", "password123!", + company.getCompanyName(), null, "Dev", AdminLevel.EMPLOYEE, + null, null, null + ); + var receiverRequest = new EmployeeRegistrationRequest( + "Receiver", "receiver@test.com", "password123!", + company.getCompanyName(), null, "Dev", AdminLevel.EMPLOYEE, + null, null, null + ); + + senderId = registrationService.registerEmployee(senderRequest).getEmployeeId(); + receiverId = registrationService.registerEmployee(receiverRequest).getEmployeeId(); + + // 초기 포인트 충전 + Wallet senderWallet = walletRepository.findByEmployee_EmployeeId(senderId).orElseThrow(); + senderWallet.addPoints(100); + walletRepository.save(senderWallet); + + // 이전 테스트 이벤트가 섞이지 않도록 초기화 + eventListener.getEvents().clear(); + } + + @Test + @DisplayName("[Integration] Gifting points should publish a RecognitionEvent") + void giftPoints_ShouldPublishEvent() { + // Given + var request = new GiftPointRequest(receiverId, 50, "Event Test", List.of()); + + // When + giftPointService.giftPointsToColleague("sender@test.com", request); + + // Then + long eventCount = eventListener.getEvents().stream() + .filter(e -> e.getReceiverId().equals(receiverId) && e.getPoints() == 50) + .count(); + + assertThat(eventCount).isEqualTo(1); + } +} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/GiftPointServiceTest.java b/src/test/java/com/joycrew/backend/service/GiftPointServiceTest.java index 90b7ea7..81468b1 100644 --- a/src/test/java/com/joycrew/backend/service/GiftPointServiceTest.java +++ b/src/test/java/com/joycrew/backend/service/GiftPointServiceTest.java @@ -27,56 +27,56 @@ @ExtendWith(MockitoExtension.class) class GiftPointServiceTest { - @Mock private EmployeeRepository employeeRepository; - @Mock private WalletRepository walletRepository; - @Mock private RewardPointTransactionRepository transactionRepository; - @Mock private ApplicationEventPublisher eventPublisher; - @InjectMocks private GiftPointService giftPointService; + @Mock private EmployeeRepository employeeRepository; + @Mock private WalletRepository walletRepository; + @Mock private RewardPointTransactionRepository transactionRepository; + @Mock private ApplicationEventPublisher eventPublisher; + @InjectMocks private GiftPointService giftPointService; - private Employee sender, receiver; - private Wallet senderWallet, receiverWallet; + private Employee sender, receiver; + private Wallet senderWallet, receiverWallet; - @BeforeEach - void setUp() { - sender = Employee.builder().employeeId(1L).build(); - receiver = Employee.builder().employeeId(2L).build(); - senderWallet = new Wallet(sender); - receiverWallet = new Wallet(receiver); - } + @BeforeEach + void setUp() { + sender = Employee.builder().employeeId(1L).build(); + receiver = Employee.builder().employeeId(2L).build(); + senderWallet = new Wallet(sender); + receiverWallet = new Wallet(receiver); + } - @Test - @DisplayName("[Unit] Gift points successfully") - void giftPoints_Success() { - // Given - senderWallet.addPoints(100); - GiftPointRequest request = new GiftPointRequest(2L, 50, "Thanks!", List.of()); - when(employeeRepository.findByEmail("sender@test.com")).thenReturn(Optional.of(sender)); - when(employeeRepository.findById(2L)).thenReturn(Optional.of(receiver)); - when(walletRepository.findByEmployee_EmployeeId(1L)).thenReturn(Optional.of(senderWallet)); - when(walletRepository.findByEmployee_EmployeeId(2L)).thenReturn(Optional.of(receiverWallet)); + @Test + @DisplayName("[Unit] Gift points successfully") + void giftPoints_Success() { + // Given + senderWallet.addPoints(100); + GiftPointRequest request = new GiftPointRequest(2L, 50, "Thanks!", List.of()); + when(employeeRepository.findByEmail("sender@test.com")).thenReturn(Optional.of(sender)); + when(employeeRepository.findById(2L)).thenReturn(Optional.of(receiver)); + when(walletRepository.findByEmployee_EmployeeId(1L)).thenReturn(Optional.of(senderWallet)); + when(walletRepository.findByEmployee_EmployeeId(2L)).thenReturn(Optional.of(receiverWallet)); - // When - giftPointService.giftPointsToColleague("sender@test.com", request); + // When + giftPointService.giftPointsToColleague("sender@test.com", request); - // Then - verify(transactionRepository, times(1)).save(any()); - verify(eventPublisher, times(1)).publishEvent(any()); - assertThat(senderWallet.getBalance()).isEqualTo(50); - assertThat(receiverWallet.getBalance()).isEqualTo(50); - } + // Then + verify(transactionRepository, times(1)).save(any()); + verify(eventPublisher, times(1)).publishEvent(any()); + assertThat(senderWallet.getBalance()).isEqualTo(50); + assertThat(receiverWallet.getBalance()).isEqualTo(50); + } - @Test - @DisplayName("[Unit] Gift points failure - Insufficient points") - void giftPoints_Failure_InsufficientPoints() { - // Given - GiftPointRequest request = new GiftPointRequest(2L, 50, "Thanks!", List.of()); - when(employeeRepository.findByEmail("sender@test.com")).thenReturn(Optional.of(sender)); - when(employeeRepository.findById(2L)).thenReturn(Optional.of(receiver)); - when(walletRepository.findByEmployee_EmployeeId(1L)).thenReturn(Optional.of(senderWallet)); - when(walletRepository.findByEmployee_EmployeeId(2L)).thenReturn(Optional.of(receiverWallet)); + @Test + @DisplayName("[Unit] Gift points failure - Insufficient points") + void giftPoints_Failure_InsufficientPoints() { + // Given + GiftPointRequest request = new GiftPointRequest(2L, 50, "Thanks!", List.of()); + when(employeeRepository.findByEmail("sender@test.com")).thenReturn(Optional.of(sender)); + when(employeeRepository.findById(2L)).thenReturn(Optional.of(receiver)); + when(walletRepository.findByEmployee_EmployeeId(1L)).thenReturn(Optional.of(senderWallet)); + when(walletRepository.findByEmployee_EmployeeId(2L)).thenReturn(Optional.of(receiverWallet)); - // When & Then - assertThatThrownBy(() -> giftPointService.giftPointsToColleague("sender@test.com", request)) - .isInstanceOf(InsufficientPointsException.class); - } + // When & Then + assertThatThrownBy(() -> giftPointService.giftPointsToColleague("sender@test.com", request)) + .isInstanceOf(InsufficientPointsException.class); + } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/TransactionHistoryServiceTest.java b/src/test/java/com/joycrew/backend/service/TransactionHistoryServiceTest.java index 8ea8bf7..f605fd0 100644 --- a/src/test/java/com/joycrew/backend/service/TransactionHistoryServiceTest.java +++ b/src/test/java/com/joycrew/backend/service/TransactionHistoryServiceTest.java @@ -23,48 +23,58 @@ @ExtendWith(MockitoExtension.class) class TransactionHistoryServiceTest { - @Mock - private RewardPointTransactionRepository transactionRepository; - @Mock - private EmployeeRepository employeeRepository; + @Mock + private RewardPointTransactionRepository transactionRepository; + @Mock + private EmployeeRepository employeeRepository; - @InjectMocks - private TransactionHistoryService transactionHistoryService; + @InjectMocks + private TransactionHistoryService transactionHistoryService; - @Test - @DisplayName("[Unit] Get transaction history successfully") - void getTransactionHistory_Success() { - // Given - String userEmail = "user@joycrew.com"; - Employee user = Employee.builder().employeeId(1L).employeeName("Test User").email(userEmail).build(); - Employee colleague = Employee.builder().employeeId(2L).employeeName("Colleague").email("colleague@joycrew.com").build(); + @Test + @DisplayName("[Unit] Get transaction history - Should include P2P and item purchases, but exclude admin awards") + void getTransactionHistory_Success_WithFiltering() { + // Given + String userEmail = "user@joycrew.com"; + Employee user = Employee.builder().employeeId(1L).employeeName("Test User").email(userEmail).build(); + Employee colleague = Employee.builder().employeeId(2L).employeeName("Colleague").build(); + Employee admin = Employee.builder().employeeId(99L).employeeName("Admin").build(); - RewardPointTransaction sentTx = RewardPointTransaction.builder() - .transactionId(101L).sender(user).receiver(colleague) - .pointAmount(50).type(TransactionType.AWARD_P2P) - .transactionDate(LocalDateTime.now()).build(); + RewardPointTransaction p2pSentTx = RewardPointTransaction.builder() + .transactionId(101L).sender(user).receiver(colleague) + .pointAmount(50).type(TransactionType.AWARD_P2P) + .transactionDate(LocalDateTime.now().minusDays(1)).build(); - RewardPointTransaction receivedTx = RewardPointTransaction.builder() - .transactionId(102L).sender(colleague).receiver(user) - .pointAmount(100).type(TransactionType.AWARD_P2P) - .transactionDate(LocalDateTime.now().minusDays(1)).build(); + RewardPointTransaction itemRedeemTx = RewardPointTransaction.builder() + .transactionId(102L).sender(user).receiver(null) // receiver is null for item purchases + .pointAmount(200).type(TransactionType.REDEEM_ITEM).message("Purchased: Coffee") + .transactionDate(LocalDateTime.now()).build(); - when(employeeRepository.findByEmail(userEmail)).thenReturn(Optional.of(user)); - when(transactionRepository.findBySenderOrReceiverOrderByTransactionDateDesc(user, user)) - .thenReturn(List.of(sentTx, receivedTx)); + // This transaction should be filtered out + RewardPointTransaction adminAwardTx = RewardPointTransaction.builder() + .transactionId(103L).sender(admin).receiver(user) + .pointAmount(1000).type(TransactionType.AWARD_MANAGER_SPOT) + .transactionDate(LocalDateTime.now().minusDays(2)).build(); - // When - List history = transactionHistoryService.getTransactionHistory(userEmail); + when(employeeRepository.findByEmail(userEmail)).thenReturn(Optional.of(user)); + when(transactionRepository.findBySenderOrReceiverOrderByTransactionDateDesc(user, user)) + .thenReturn(List.of(itemRedeemTx, p2pSentTx, adminAwardTx)); - // Then - assertThat(history).hasSize(2); + // When + List history = transactionHistoryService.getTransactionHistory(userEmail); - TransactionHistoryResponse sentResponse = history.get(0); - assertThat(sentResponse.amount()).isEqualTo(-50); - assertThat(sentResponse.counterparty()).isEqualTo("Colleague"); + // Then + assertThat(history).hasSize(2); + assertThat(history.stream().map(TransactionHistoryResponse::transactionId)) + .containsExactlyInAnyOrder(101L, 102L) + .doesNotContain(103L); - TransactionHistoryResponse receivedResponse = history.get(1); - assertThat(receivedResponse.amount()).isEqualTo(100); - assertThat(receivedResponse.counterparty()).isEqualTo("Colleague"); - } + TransactionHistoryResponse redeemResponse = history.stream().filter(h -> h.type() == TransactionType.REDEEM_ITEM).findFirst().get(); + assertThat(redeemResponse.amount()).isEqualTo(-200); + assertThat(redeemResponse.counterparty()).isEqualTo("Purchased: Coffee"); + + TransactionHistoryResponse p2pResponse = history.stream().filter(h -> h.type() == TransactionType.AWARD_P2P).findFirst().get(); + assertThat(p2pResponse.amount()).isEqualTo(-50); + assertThat(p2pResponse.counterparty()).isEqualTo("Colleague"); + } } \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/service/WalletServiceTest.java b/src/test/java/com/joycrew/backend/service/WalletServiceTest.java index a906eff..f3424aa 100644 --- a/src/test/java/com/joycrew/backend/service/WalletServiceTest.java +++ b/src/test/java/com/joycrew/backend/service/WalletServiceTest.java @@ -24,48 +24,48 @@ @ExtendWith(MockitoExtension.class) class WalletServiceTest { - @Mock - private WalletRepository walletRepository; - @Mock - private EmployeeRepository employeeRepository; - @Mock - private EmployeeMapper employeeMapper; + @Mock + private WalletRepository walletRepository; + @Mock + private EmployeeRepository employeeRepository; + @Mock + private EmployeeMapper employeeMapper; - @InjectMocks - private WalletService walletService; + @InjectMocks + private WalletService walletService; - @Test - @DisplayName("[Unit] Get point balance successfully") - void getPointBalance_Success() { - // Given - String userEmail = "test@joycrew.com"; - Employee mockEmployee = Employee.builder().employeeId(1L).build(); - Wallet mockWallet = new Wallet(mockEmployee); - mockWallet.addPoints(500); - PointBalanceResponse mockDto = new PointBalanceResponse(500, 500); + @Test + @DisplayName("[Unit] Get point balance successfully") + void getPointBalance_Success() { + // Given + String userEmail = "test@joycrew.com"; + Employee mockEmployee = Employee.builder().employeeId(1L).build(); + Wallet mockWallet = new Wallet(mockEmployee); + mockWallet.addPoints(500); + PointBalanceResponse mockDto = new PointBalanceResponse(500, 500); - when(employeeRepository.findByEmail(userEmail)).thenReturn(Optional.of(mockEmployee)); - when(walletRepository.findByEmployee_EmployeeId(1L)).thenReturn(Optional.of(mockWallet)); - when(employeeMapper.toPointBalanceResponse(any(Wallet.class))).thenReturn(mockDto); // Mock the mapper's behavior + when(employeeRepository.findByEmail(userEmail)).thenReturn(Optional.of(mockEmployee)); + when(walletRepository.findByEmployee_EmployeeId(1L)).thenReturn(Optional.of(mockWallet)); + when(employeeMapper.toPointBalanceResponse(any(Wallet.class))).thenReturn(mockDto); // Mock the mapper's behavior - // When - PointBalanceResponse response = walletService.getPointBalance(userEmail); + // When + PointBalanceResponse response = walletService.getPointBalance(userEmail); - // Then - assertThat(response).isNotNull(); - assertThat(response.totalBalance()).isEqualTo(500); - assertThat(response.giftableBalance()).isEqualTo(500); - } + // Then + assertThat(response).isNotNull(); + assertThat(response.totalBalance()).isEqualTo(500); + assertThat(response.giftableBalance()).isEqualTo(500); + } - @Test - @DisplayName("[Unit] Get point balance failure - User not found") - void getPointBalance_Failure_UserNotFound() { - // Given - String userEmail = "notfound@joycrew.com"; - when(employeeRepository.findByEmail(userEmail)).thenReturn(Optional.empty()); + @Test + @DisplayName("[Unit] Get point balance failure - User not found") + void getPointBalance_Failure_UserNotFound() { + // Given + String userEmail = "notfound@joycrew.com"; + when(employeeRepository.findByEmail(userEmail)).thenReturn(Optional.empty()); - // When & Then - assertThatThrownBy(() -> walletService.getPointBalance(userEmail)) - .isInstanceOf(UserNotFoundException.class); - } + // When & Then + assertThatThrownBy(() -> walletService.getPointBalance(userEmail)) + .isInstanceOf(UserNotFoundException.class); + } } \ No newline at end of file From de1967ce9f851f589c075bb6236d4199c206198c Mon Sep 17 00:00:00 2001 From: JiWon Kim Date: Fri, 15 Aug 2025 18:26:28 +0900 Subject: [PATCH 097/135] =?UTF-8?q?release=20:=20=EB=B0=9C=ED=91=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- employees.csv | 2 + k8s/deployment.yml | 9 +- me.jpg | Bin 0 -> 44642 bytes new_employee_2.csv | 2 + small_test.jpg | 1 + .../backend/config/SecurityConfig.java | 118 +++++++----------- .../controller/EmployeeQueryController.java | 3 +- .../backend/controller/UserController.java | 26 +++- .../com/joycrew/backend/entity/Employee.java | 13 +- .../repository/EmployeeRepository.java | 17 ++- .../security/JwtAuthenticationFilter.java | 64 ++++++++-- .../backend/security/UserPrincipal.java | 2 +- .../backend/service/AdminPointService.java | 40 +++--- .../service/EmployeeManagementService.java | 40 +++--- .../backend/service/EmployeeQueryService.java | 3 +- .../backend/service/StatisticsService.java | 23 +++- .../service/TransactionHistoryService.java | 53 ++++---- 17 files changed, 249 insertions(+), 167 deletions(-) create mode 100644 employees.csv create mode 100644 me.jpg create mode 100644 new_employee_2.csv create mode 100644 small_test.jpg diff --git a/employees.csv b/employees.csv new file mode 100644 index 0000000..404b5e2 --- /dev/null +++ b/employees.csv @@ -0,0 +1,2 @@ +name,email,initialPassword,companyName,departmentName,position,role,birthday,address,hireDate,phoneNumber +김지훈,kim.jihoon@example.com,newpass456,회사1,부서1,사원,EMPLOYEE,1998-10-25,서울특별시 강서구,2025-08-15,010-9999-8888 diff --git a/k8s/deployment.yml b/k8s/deployment.yml index bc6551a..27db2bd 100644 --- a/k8s/deployment.yml +++ b/k8s/deployment.yml @@ -24,6 +24,11 @@ spec: image: "566105751077.dkr.ecr.ap-northeast-2.amazonaws.com/joycrew-backend:a761bbb-1754808557" ports: - containerPort: 8082 + env: + - name: SPRING_DATASOURCE_HIKARI_CONNECTION_TIMEOUT + value: "20000" # 20 seconds + - name: SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE + value: "5" # Define resource requests and limits for better scheduling and stability. resources: requests: @@ -35,7 +40,7 @@ spec: # Readiness probe checks if the container is ready to accept traffic. readinessProbe: httpGet: - path: /actuator/health + path: / port: 8082 initialDelaySeconds: 30 periodSeconds: 10 @@ -43,7 +48,7 @@ spec: # If it fails, Kubernetes will restart the container. livenessProbe: httpGet: - path: /actuator/health + path: / port: 8082 initialDelaySeconds: 60 periodSeconds: 20 \ No newline at end of file diff --git a/me.jpg b/me.jpg new file mode 100644 index 0000000000000000000000000000000000000000..35ad773c26e47a2318d57f3d708aaa4935b04ce1 GIT binary patch literal 44642 zcmdqI1yoht)<3)thmdZhK|oTZBt$|$T0lTbx}>{XP(o5bLPQWj5lQJz;n3Zk(s1ZH z9KMZD-TU149pin!d%tgt|9`>S`?u$wYpyxx>b2Lo8oycqI;B0Wo&$iA62J}szzqN$ zLIglT1Ofj52ql2_3j=^Agz8Uh0Ac-|2L%97i2vdbwE|Fo=Lco}3Ua?c|8~cC0{|Ex z1(lnZmnRnT8-}1jfscRCdAPYhLjIEY5#>f$Kzo5BM2oai@yO@H0d;)Ttn7NqRfw&Yjzt+^z*%biL9YH#y zhpXc?ehXp}XHY>9f4#;Qzv0?zZ1NlK|HboEO$y{$28#>F*x1wj6Te-?V1u;2@znZ&9tN+f^=_}1gXp(>k#05`*K|-ml*rOt z{&)G%yWW;hwLm%+NPpyRrKt>JJP<3p*r;FY1L^?P^R|?_rvKt`c2m2yg$blPo7>Au zf*7<5n)uvV=GqU}{NLkF@Tt&QGuDpq{89j;>FxeF*A_nqqCN@C3x5KBz^&V?YXc1keL+U^D?7 z0BgXicdKFxeERK;3}6g60~UZe!1+7nj}%(J-q?dzb-)|g2JAr|mp{`<{(5T;xP$b| zzfrGgR)5ldlg9}>|CPf1mlPY|Czz`ppa|H4&u$<#2h;qafhMpFQmp=b{zD^UP^Kwp zgDn`Z-~ZS2Kd66kN`O9i{CirrKXRnU|06xRH2NcSIdmCxHdIPfG1LdBkHPChF!G`b zq2B+)j=%WPexePaEuxK}&7(~iS_L`(&2LJ;JZRBxel-Vu{tx~^Wd>#Pqe`NRg3>|B zsAQ;=04M4_Fdqq2aWH3okSg`Nh1XvBqm=&9ravtJ{wks0`7pULQ7~^}KE!1GdrCZZ zJpMoI{pIU_^yF{4n*8bSKQ#E){Qr7m1(<<)D*jm-ziR*ugO)&H&?aaHv=&+c&_f%b zmC$Zz-8KF_f04pYQOZc0n6p@b%q|SF~8|~T|Zp7bGg^K5x+@sHTQG{>o_3g z;OOOSWnt+`F9D7i=JfLRrg!hq^K$d@1Hkq8bIk()2dclu6$n}PU$mEV03h0Mb#+zq z7fqo70KN-?T|(7gG*%%1AjAfM_9|01XZPRapA^{ zAs`JX04l&!KnE}Y>#HSL&z%7ez!wMvLVyV14Uhn&0O>#`@D<1hih&BC7H9(6fG(gP z7y+ih+Oq;|0&w6MxPU+)7!X_tF@yp_2e}R5fbc?uAYu?{$YY2aLGhuA?Q4G3wnl%j!KM5kID(wU^!F`R8v$ZRDaY+)D+aOsO6|XP=`<# zP~oUZG+Z=lG!8UjGD&RLi9%T0rVyGBMb}-3JeYmQ4AFfV+>b}5R7*ic^C~CgBU9qr#G-~(B0s@A#>x| z4f`8GHxh4ryHS5*@W$$m3rqsc+nB+1k40d1jYpZ1n&u|2*wDG2#E=K2vrE}2qOu<5q1!+5n&K96G;=95xpYH zBx)gAB8Cz(5=#-A62BzQB5oyKAwef$A(1DsCW#=)C+Q`DlM<2MBh@7JBz;d>M>uB|E=Kb5r7``OVii3vLeHJRzqde@Jdl9!_3JK0%sY>ZVnMT=4xkW`n^?=HhDx9jAYLXg-nw?sW+J`!mx|{llhL%Q_#*yY7 zO$*HyEg9`YT5H<3wDq*>bi{OGbXIh4=^E%Z=t=1%=xynf>3`6}Z&BZpyXAH(<5usj z3kDVj4Td0wLWUVeEJh*5=ZtR|n;GFubWBQ2zD&7HleaN%3*EN7opc*^`-GW=S&KQ8 zxq^9(g`7p6#hWFUWttV2Rh-p{^%Ltb8#QY#9Smx zWJ#1k)L1k_bY6^J%t-8`*u3~HabxjM;)@R%ADTV<@^DpxO~P6tPhv-sThdvwO!7oZ zSjtbT@e%Zq?4$5Uz0yR|8qz7!voZ`a7Baaqa9Ms?AK6AZG&x1NSh+EI8hKOs9C^5c zpn{*m4@DeBb;T6L#mDTAogY^#p(rUR#VbuK-&VF)u22C~zyPl_)*Ps6=zp{X{Afu41Fro-3ax2>S zZuEV$Sh2XdM5v^o^mb`#8F5*7Ii%dH9A05rF<+@uIZ&lg)m;6cx~%4IO?K_A+V^#& zbusl=^{?u$8oV2h8XX(Ank<@@nhl$$TC`h+f2jWGZB=Y-Zq4Lura8C)!1 z%Q9l+)lb}?iK}$0IcvOYwd*qL{TsR)E1Qm+7h56Qgxje*tUIN<4|lua+VGV<=e?`_ z$ODRl?8AG9%|}m;W{>TTFHXWwDNeth37x^twa!;BJP;U&_99)6#X9EDP4ge7MgYyU7-`0n}IDW0}e@EbQ>KFR${tx)KHRP{%0sv3| z&goURz}d|h02E~b05d2bUk?DD-~zzY`v3|b-=Fb!alfrQuOHCCb;U=uOA|Yj-(UUH z?YA{4NcuzXzu!S?`1tN!r}?M*)fDgwT;^W~6!0I4f`S4?g@OSU4fQ&pp`-l@;28cZ zfTJ}SZd?boP`dx2?pc0}H-QtlzCssAaU~nSg z4UEmW!6^B?iBxR>&ctWp9E6EQc9Wcf@-{OID;xVgegQ$D`@&L>q-A8~9E`Q)brZFWFynz7?00mX%jj zR#i8*{Ag{1wRdz54h@ftj*U-DE-WrBul!tHTi@8*KR7%(J~=%*2X|(EGY7o>R)+sz z4#%6qP!er!AgOi##4`7io z-CMZ5cWu%yi~eT@1^vIW=ud3I!wZ=I)^`O&sa^p%V;90#K>y2yJ_M=G6|nd1 zI1h`P4YJq(?+GX=yu2YseFZdWq+bD)nMjJlW49|H1EVGVl)dB%Sklg)zE6Oxc~nwUyZ=Un2DNW&zP2^ed&2FJan=lqbtB%XYZo?3izUN zsEp=<PLS&{?eaTeIw=wp-?m3~aYm*&6!@BdeB*ND;mM zBjRwK05YpI=HWZ$aRInin$k)IN!e&N5y|7iNevD5jD5J&WI2&&&y#be4EwYq8|FU; z5R_p@%sgm1AzDvV>*BCKFwp&Spz9>&Lwt*36)mRxCQ6-<`LR~|w1&`uZNMk#q-{F5 z))T*Se}+;cV7Yvt?=e|h0A9kHuwhiKc)o6a?|bJMDp&d(y3=)jq|r%3S$g!*I0{qJm{K{<>@mm>-Vpflq*RzMa^6&NI38bJuYf5Q*v&CI zt6}~bHSBixRCZ?WKLNc>d7^xhNwe!!*(y&rJ9CrB5^Nuyeha4YxtScB|pfvQU zFEk)Xx|U*E=IfaTM0^$%^))AN*@jSMRSssAKg8R7ZxhS-8fw8S-9_~A1)AR(+yjZn zaJsoLq1Ljf`Tq0i*V2bFOj6m3Ep)T;cWA8(R$v_~PSIsqywRSssbkB|Bgb!%te3E3 zqp0ev!ghzC0Ju)fgwsNoNT2ZBhf($}NR^}`=cztr5Htk+*QYIB}8E-zZ5H`ChC zdWGjVhrc%0Tz1XI zpy7Bk+Oqa39AN!#B1hVu1tNkn=F+?83ivcxO}SbN&OsR3GOqxG-+Q2;|7c4=aZ*&J z5Y6oq(b`jQL}Hax)s7zB(Avu6{Zw7TLTwMMxa?|S*((o7+QU>9#R;s$$Op4W%Q_yB zm2Y5Iod^LwaSx%R4X+zc*;NoESWAn$k!20op6VJ-pRI2l)+8$+k4Lc;L&Fudvo2oa z{)kOVGU8P%Lk;c1DmzaCr$)wLEqZrqvCwd=(vp5dS4AZ8>ty+tV!B(j{A%S40{%?l z^73{Ym12e%FAbaL!o}D&kql;Wx=A&a?#!|lv-eHrc$TV!+JM66!M9~75h`%z)Y~ln zq5aw0SxSb*$okj6i4cr`V zcQuZcOnb?xw0|tnU60piu=&yrBYynceL3U}4s>_u=&QY=eRk%J)?B%`izdFQ^it^u z#Bs7+Z&WVC5_+Wsa!7U^3K`3_PHZzph1uHh{5WccYNcH4#9W(;8J#5;q8HX~pp>P} z?jY$(I|yY_jf*6G+}01jxecB$*-$D^cV#TQSb5uZJ&Y=WzTM9>9L+Kyd2D#dskJdsksU^WsU-(lzfqe!W-qf!7 zjFvUJYM;40{O}9K$b217U&DW5)SOBzMr_jx#+bLJJnq5Sm`<>y^CdE^^z89dyx>BP z)6pVPJv--qh@;m^a7I=*VC!?%s&@R8wSD|aFkI_31z&<0&gZxDgsuLHH~8~W&bEyb z7%Cm}iPI}pR9~)7*4SYzV*8b4Rm+z7i>%d{x!KY!d=}Av6wjQ7_i7|7X2<223#?V= zzPzXL1yjEFF7vh+;mY}rj~s1y?&N-H-h)6)v!Ns|x z(hAT&a|Iu8wbIRfSvktq>TM{=-sXhAJkC8ewb*l*Pb{R(&zg>lib>RFR;IXRp`(j>s&qt-u92VD94JWG($QL@Oo=U2dh z-NS*a;r~oWBIijmW*3oi3O()sJrRZ=xQyNoiCvsP&Maa_xTnzwQ!mpmJktcWrl;92 zmpU4O*nD>0_oYu_D$5#(Ed42mvPNnpe9)ru9wsJwk=-Mw*!Y2$0JmrpKV?hWYJf*b z*_m7cKggVa(g|?!F5JSS3M)##w^cm<);ugIQ`ACDGa9UBl$Vf1m$tg= zS~xvuQKeR>jo1#fyF7!)1m^ES$Ek(!3*txCO0JO>D<$J-pruO}~m4C-#b{&XSwLYIR(6w8#2c4q$T)Imx4bmaV4QySx* z($W<9EuMmfeb_CkP|+LI7NjdIl6AF3c1Dpc6dDU57#NXm+w0+@Z*y%+WuN9OELIs6 z_-X6~EMCIeSh;Ey7ir83?04)kG^8~`n9McQNt_tALuREwsEpfK8rf{)`#Q=O_k25-a|QF4xc+IRgaXte|a2Xd>l2=RqQDKaj_O`1;e*} zUL?|27k2Lp&X8~68*vmzpD)gsOo<(d^YSjsWWtw6F^{nsYx~`-j#*Rh+jmh4%sz;O z4vp5>#E`UDu9L+1^^&*in4WWyGr2aBHd`T?N1rWqM(?j>nM;MqMFbA-An_&AJIm}w zl}ywHKDga4f5K~Ex0V@+SOwBQU1)sM>(RFqx9O}KD*isQTR&L5&Q*oA>M$c7&&5z^ zaO%6^9(kfU!4g@7l|6?sSWG`sntOwsV6_v;HwQob9MP6Wn{?9n8c%G~;&5B9EOm+F z(IMO20JrgdKbNK_lO4B@;|i_km(OGa*Y{mvgH==dCd+cSMs&BI&{BGa_E51dv3pS; zEs<__9;^NIv0M&zslmy3aUyx2cHT+bdoIvuk09wOdLtS)ttr3!RM3#bT zzq_+sm%~Nfhx%?#Zu?rf*;ySUc9d5#c!Z@e%2Bn0mbO1W7lQDeimZCUi=))tfxIK;xd*unqrRrI@FQ zekx5`@J(TnP0?5LyfJ4M4AI<;u5Me>J{F<1K3{CUGI~&m+(~YC4hT{mKNplTyl+(^ zjkXv4gRV&BK;BYi=7`nDMOFUG^r?!v&o-q@Vz2#8QJyuw#=`q;oHiB9-u9p6Ed5jb zI~&SYx3MpX5r(CwNf#m;@l4Kg=?(*_CF2|1Une>3O@I%t(r0?e4;bi8+1@%F(q4vK z0d{k*(km>Qd~j88#^dW&gOopNVWS^AOFC8j(QoZ7 zY+x2jS7RX+Dr;EXGBh<=Uw0WOZr2btR|MPUGu?A|7@~Ws>sFqdEz1+mDbu?5Q?lzr zl!(P@g*&+pFK-TG%KBT;JkbyOg9BH9zBpqCY{3+LkK8R=18HD!?#4Akvaw(Ivf-vL z@m|+LV(^~LLjH!e@XatzhT4)3Jl)QB9YNn+;6oq9`Gc!ipssTG|Fa@{T^7nwX|%foIoGCC|ZaX-}%RWnH}InYmBotXf@fxXlRQz7w{*>l?s zbDJ1cQh5^ZwV$ayfZiYe{S&sBd#A-dTuVxD2gvU#~y89HNFVRGNNi% zzcA8u#+6MsI1zor&^cqNN54p@ruZyCz>!U!$+XzU!BVMeeW$psL#95og`9Wqta zC8z7sVXB%HNYijP!tL5@d$_LC!L$|K$$xKn^A5xC-TZFN&6TN6FPG+2r~UYG*Tp*7 zmJ@IH*JiJ=AGAkQB|9n-?=O|3d67_N_5rmYI-YNF)wMPHA?K0jn0 z<%JpJ3!yW|hxzvl5+bK$^Jab3hj)G+*b(T2P)SI2K(o7DE;(Ez_PlDGtH0$W$jos^ zV$5ug`w)ijDT<0RTVPX=KSq;O=9?m4Kg`d+XE4$6%J2=MM#sk$mVvMua*rcf$i8({ z5TE~m9phE7Lrqzc9F1h!__^liNJ{hBtm=l)9b}IW70w-42iKf2<4WHcN^N%2*uE@I zwV60Kc1@?dNI}4hBbovDrpC~RQ^SSS=d2MJAEMsk;y`gymaiN(UE;8tCZGhFl zzAxIwqXYtjvoZ^B|jn)RtY>`%sn3C<}FOE zF^@F;i4c`oU!SalHgSEme)bvmYIo*EeVH*Pl8nk5{|lNaW!iS!_#~mqjCer6d~iBk z-RcT>?(n*0L9k8KZt2u*vBNi;kB8eTCA9!m%erB|^L_`WOT{woYdMP8+>4ot%4oTl z+3b!IG&dN_0;1;v5lYd5UE}zaZNG?2-0=qM8|MBYGZL|wc zS$N+fS;1sM+5CD+XYYmwu4r3ocofln7MHqj!jF2Vi?^8)J92v*3$jEjG|mDV5q0GkNp*Rv}<$KqR8E=;`wtotTen#GHgwyuE1!Wblf z*Mz5a(~jnh*yZpQ;5<9ZGb_KsMj1FgA_MMV`GRX7G_A`ze-eLiI*wjw%bznpax|4- zSaRxj7ukI>h*D6bFHc$fMrX-JuQZ`l> z0=K*Ki%@1T-*OYGjswFC$yPYMO5)2L_vfa^!KY(O>5~>qcXRf*7Eu>})Cf%*Uq&1iMl0 zlzo-roqE=gyxaI_N0>Gh8IWLMH4E&~mXzwwvcH_VOrP(xq=!FFGx=!g{`tx%72$Hs{2`xpdy>P|wdu zBS;#IHeu8I*}Ho!_CM_Lm{m8KZ(ISeq!%1-0&mAH^`$U$1}j1k1}{vbePR^05B&tr zy`p8>;#bvE@$Qo!Hj*RIyok~@-51t{k}HT723dK-0z74<-Z+Zi9f>>XcEBB`M|~WI z%;QPlG#fO@5mjC3KcZSj7DXd;7anRO`P`b zJH`0(Y^NM(jBr`J<)b&AbLcu;pPH=376_4*tjS-2VvRRn(s7k*%?c z)#Gn1eN#XDz+lwBO7xLgw3v}lk9SWmzb+hVmV za$tTm&yUz8*f3tOY+>O065jVuX~A zafrPYdEf4=I+hi2*_$p-^E9fZ+-7w~S7YSfF4+~3IUVs}d}{ekWOEaoM4@t0D!`{= z!65jfuS}ATSKd*U?t@BqSrIN|9`(`+W1d$N9^T4)jKX;&H6y#?JMFW{l9hx+m_#mu zx2iI>tPdBtGKYKTp%FR^eoS$g*QIZ&KT!5w-(;WS$+~kJO{h}whf0J~`1ZZRn+Dol zKefM`QtX=twsV%ccDXvOd5TB*pdmv)c`h}VkjuT8ZtkzT0@BRuHxmvqsAKXSwOX8? zTFbf#Z?_7i(;&3rf$-gkBTj{mx2-w)*sq*oD!Xdd-^kH6YRz2%aK*l<_%`ukaTqQi ztlGQljzAnVz?|w%fK9~{iHGiSGNTS}?C_>|JquseO6P4nbI-NQ-4yurb0(8a3w|<# zIjIm0qt4ldS*KqRO(_f;?cs+o;gJ3)@68miVcDtN(yH8!9MjZ)y`oo)tc`x$h4p2o zRQ7z=muv3|2(D*GhzkSYXbGWU_$g&%bd}GQ4w^=EnrYp)@ zKU`n$hhb~2Q8rv)E(4DJKgI!<|_QJErLj#c%;IP{?(p;um)67AqLbFA&>?T_QJ zsA!Wq_-dqH4`i8ASsssReO|Y9)vAe8rATY~#6{Qx&9t&S2@7jq zu8-Wb#&`B3`JBd>Qqz(#&LIZrW#Q4;Sf37+DAeWW!H=p6<=!+^!*@-E;&kx4YQPDk z(E7GsOql=ejS4Ii8OvFoqOInaF$*;3PI_8lM;zGXM4clO@u&}^nwYMD8}VPn>naNK zy;=|ZUz8t5u)O1JtNhjLv*tNlyf+?CU1KtnQEn=r(PdX}%2-nk6H3hO4 zZ@49gpFL)xUZ%-TTZ&1>>K>@&bUfi;;)%U=L|f~~vF?EC-tv`t?Y36*?r-dvAlMJCc%+!*HGTmX94%9^V#(0Y8Z(S zo+u{tY_-h)l`sEgq#Jfok%6|@*{M7!_I#Rx)3mqNq_Fsw;iA>y!RlnOm$XJNZ$@qL zOH@1wA=qQBm%8_&PQGvTcUZg1E|Bgv%}A;I*pm&sCw&~FqLQ&~Af4xlzBsI)q0%|t zhCdXJj5sPBuV@w47}k82{|aVn9r3n(QvS?yo~9+ndVlw(f7|5w8Qb0DDibrd8r+vB zo@;zlgO$iS>>ckI#&1jT^j^Ho`K~?jzSudI0t{-rk)TQ6^G7~AZK^J1oxs`R)^JlK2lndK5+DRXf&lh zE|AiIhva0Ug)phxhdMu+KvRaG=@wi8YW~TjGb$9AX=IG-Ik*ERO@|+tb-gm)_Smnw zk95e570t@t4hVxpdXEhW-`<*K=+EEY84tuXdyG3Sia!SIK!+G=sg#DuMEPmwe~zw! zHE@VcmVVwpK;eiklzCTWnCf9FqTpk*w{J%~ZL(WCBG`Q(WqS4L_Rjm8l1jXrY8B2o zcbMc`v6P85?#)K4BqH`1dyTTH-m&!R`Ge&ByglFsC-jyHdpMQ^7u2q9f27A8o z#*;bPa%>4d$El9gznA4T-sPfC3rx0EWf_9URyO9F-0a7DZ}bXvtT~run(Bs$;NXuWG~ap?e; zJtuMB5B=~U@XSHYNz%GrWi8GU#~~@@29Ml%?vE6|q}PWh)k75K2QTQC-Zc7tT&l1> z;6ky<&9+uM)zXl%y=Y-CTG4YXzO^1>sM_j63!Zdp5g(uFqHko})ZhJqdv{2$O!qi4 zNyu`CI7C15#rW|hN7tv%RINv%DMMX=CkP_J<5wyZHpkoW&?x%^F}&48_((EVVec0ZK z`8vThJqch&Y4XM|C!Qn)2WyIC1izltn%qgRz+x=0E>%NNrZ zSMMqee!5ZBVx_x_Y+zh}#`>u?2qm2LlVDW^VZXqs5r1|2!S zVDu!X0ApFfc%Klp8=v)=ickw`p{q3IxZ*YN;t|A7`szw;QEK>fZYP zCQkOY$C>RF&?h9!MSJvPW1q>2>5ZfV*X>4a^<~3Mx_Jrxo&4M>re-a>Sizlsqxo%` z%ZJ+r_p_%ryt|!k1hvE2J`-lC?!CAV8-wTZlxa0r)3{pI5lPc9whUNT|ELhmOG3XZ z?vxOwh~R0<{;EC_-cZbeF`-L@F3NRY3SVGGP|M1814~tdoLcZ}`^Z{^r!d zxz@Tvv&u`pxw<^wHo@=E>~is2gY^%%yN5MmPjoaTzr@ELoHWi&2<$$skIWJ?RcMlE zBf&T9Y;XpfYx+26VL6rOBc8x?{#eiLw1B<^Lj`2Fc!>xHTEXX>srl{8NiQG309|gg zSNPnua2y}-BPG@e>J-jh9XoXyS` z++zRum73O3bWj_C*3;ucfjtcHla*c$ z`mo%$8%<4M@9cRQzjxq(+%J-SYe%`!jw*goEeFu`RMQ%AOxrq>wt&@!fqr z_jxUtnBXZ!}Zgc6~i%$AGNh2FHHA+M*&7CMk#3lP>c#M5l%h@kq4)X|Wc4c6CXZ#k!H3}300 z%4!HL$UoAN>Eo=e$LPkGl{wrr%$OtaC)cxzQ{W7FY&+h^GDcO0p>U?vR!?^`vBQ3E zD^dHg^#l8vM%wvlzz6Rsktr{`F35*MDr3>=RPcPw z!aXIer?tcUhB$iSZ`>Jw@Ld6%^mil=wkW{C`Eb};F-dCi8*D2-#=BaZ#dwkYZI?qJ z8sf2IBRwmVp^SRuaUo@5=ZR!>oIdAqvJSTy~FUSYwfNoOe;2Xr$UMF_;Uy$`?VNHFyY ze4%U4HeEuqgiTaKo|@D3x{1|#i^YB{DnxDc(W7XcFmot32v zoi4&GjCe8M)`gQTrWo+DEpcJi$J@j|nrFwhsw0)Y_j8~zj`g2wLGbb|oHh7wo5g=Q z+8mm!*a8=<-I{??a#>53oe3h&b+QWvW>r5c*exXh7%#qgur|_goU-=U|ETEC z%}TRsV{9A`I3Kz61=nfi4a6OLeXU%tv_fg^KV)qj*ShV``gpYkj9-qCBiUEl25a>% z^!foR-i3WzlYkG&S^G#u(axfk&7 z9N#mz4=y?vcV5DY=aVAD_)kB4H(pR;-hKK$)!gor#ncsmLJu*38G=S#4~^l56r0cdqxbOZM2lS9TnK2RNKgeYm_{UQz79KNAaqqkXEQQ3ghxM-@^f16 z`zaiTRx=*!C+lN^LfbQFFhXTN&rkeEVzKA(m{b_J zsrBW7#}-xBMNiD*{HJ@{1H&xBFGFUi$JAUlJ_fIEaa5G=h+kmyviq#?~ z_k5&l*x2l<*2~1yuYj26)jhmz*c{@bu%o{F2YX-i@&eXGK{%y`Z;Vpi`bUC0zAUnXHGny2y0ou zYg%|bgMN~asKh!nFTF)Qg3d)+lzFO6>FS^M9;i256e*Ues-V43i1eohKd}@JaZRKz ztn_}yp|eq$Qyh3W`PFHC+d$baY+1H2c3y8|`aEeqv}v>Jj6S?=EQ!h^HVq@Y;%wcp ze6F}+>xkq4mNVNt*cKW}kLoNOi0q@OJ}#sF$KL|y^xmldZP z3*@Cr%p8&*g8^e1VOQFDt)I!M>bc^K$KKj<3s!7yqT-G9w?~VJ-jeguCIqq8& z6@-xuQn&C<&K9c7l)l#p@$6g)6nmv{f418*q*HsmTKCRS`}mnN;{z?R))%x#EFrsF z1$nDOg}T<$a_TIpu1?WBO{C{(_|NXb<~_$*M~Y>3Tk=&D2Sk)yjGg7mYGw#+9fS8P zr*;Eh>fJl(Fb{B9uyWis=cx5L7mog3;fN2=!cYfnz>$5z(6;u64THRcQ!pg9njG74y*&v$H-P{-Ql3DxG2ehXKEOkwmgxs2B5mm4;v$gIcmR^x&tb6-{S{(1g zUYldbcr*vYELF6vy$lmH7bn+S_}rexZ+-Kg-O8L!drqw#`wU-nx{CK;Ma>y%66> zXEM9Qu{tpg94j%-v}VQOa&x2`w3Oh?JJ^?R*t9YZ)z5^)E?LdDFWw4fS`({1;w@04 z%|2B7EMTNud>9L7iE}pZsb!X14#!G&^Z#nC@NXPZHtbACP=_wPGkkWc2(LAgC9*nkDEiA=%vq^-2{yA${u+FG@qZL@+mBlSLXJ(!QQn@MPU5Y1;u zCY|%;-JZnJ3x7-dka=%(83;JXGj#SW*VSFqeNtq;Utmgpek2|utfkDZ8E4TykiFA> zYSCCQ2TxbT?O&L2x&nM1#oiSrBT&YZ zbkA4fwf&t-saHjW>fzrnG~kR)CUaZ+sHl%p*^cM~Vhul~Q4BqDf|2V%`j!kid@0MK z?uD6~h8hV+`ixD^l7|@!6K*0&_qBx#)i+Kf2!>Wf`D9<+c?cdpnt51bN;}eP)gMS1 zB*Z_=?pvc-ADvPx&ex{A<=#{g)BJV1ZP^?{c_iy&5tXb$)F;!S7==*<*qz(YEt*jZ z*qF?wDjGg3uZ3lQ&-0qXj8y|q^YGE98U?QF9e<8Im@U8UPMBz_m-yX-n((Zcu*lKb zJrGvtu%b6&=AG;fv@PElsu0 zz5OF}R-`T8CqcTJG|_24JdQbpDSYEWgfr92KAmG&koK9S*zRMqIAPaT&-BPc>>lPT zK)L>dKuCG?j3oF0lxQO|020&OARl@8epw&CiG3ma_I8Zs$UVq6PvPZf6+P}Dk6AH@ zw!3hSF(?XC^0%3Xx3a$1ol!oOboN$AWkl-HHPy77XnXH}dsU}X=7NptM&+V%37K!!FZ?6Zwc`)Yl z$$D_IM0bv%n)j_`J6=KB1WYXLL+%6h=q`g&T;D3TS5fU0hPKD&S{woBnb{0^@4tjm z--+0N^8mfidp9FfQ)IGB$xT#KdrQ}PytzF$$oW#%DF&WPWa&YcWLwU&8`w_ozB*XD zDK)agwqTqdkpCn70p*b1;NwJ^6ww1=XYk}r|7u$3uA44?=PQ?`a^X!IQeF*BpgbTCLu|(H1rAL!XKrN9MzYIn|*UFiZDBf0$u;(b}R`J^YVNmmHbIv_9Vc zvctyTSS!Yt?RK}f5T*OV?+rytyUh_{3zlT#pNDkn+jCPl$YcTY&T5oPz8JPdB6 zj2x{|`l8EFutd*r9KSy{e=F^QdQrh(NAHRwR(5Lj)hZ?7&CPR9p-4m%x}2VS-xH4Q zzKPAHqp?@!qA zNemVQQsWr>=A{#+I%i3aOb_-KKz19_)!tm}5$^3%YKYc>VAn9YGJb68Gk5U%Y^n2_OV|{hSWwkPM$>V=JQG1G7k`vv;}T>w@MQH_&Uf76me096rmFX7_O5pb>lF zuJ2b!i(TgS=5o+UC#n8Skwes<_FWhtsNvnBukXUH$uI0nEY+qXa$eR@7QGX8I4p>QTsD5e1xI)fLAXHcyU^N$Z z9@=bmmw()q=Dz(0)pr)I!}jK~oiB=8E4rO3VtA5YU^*mtB7N&cPP1DZ2##z5D14PG zgQ%k9X?^@?2c>>CzS;`T<}o3seX7nCPl@^e(Dt5DO@&*#XaEJIiqcy|P=rWTdI^ej z0jZJR1?eEY1*uX)2dUCK5$U}XdhfkT4H5_tswaEgyU*C)H|`z#p1aTavBp~Yv(`wm z<~!#*pE9W>%AYS^zwKBz(T2pH=|oFbJWA%GDPA+8X?^JX>njW6YHx79w zscA3U-tDp^+#&w8W6iYIk7}29aU_#D*nK&wqkquW_($AIp*YSWmPzs_aG~IPzIEGU z0dJghW$wPE9QnC#o)wktY-XqA0$T*Lvjxas^9o9J?MprjeMQZuGOs>eKc;A4GDgI}+z0 zU#d8_B=3vj?dekoy4j&Y9!tMfg;<5}y@YbP3qn3g$b=fD`{$X+qlGtV;j*6*cCjZ+ z@pGGgHI~$7KZ@;L{bPOwaprn?R1v4M`GjL+RNs5X?kN3K zaY7Npo)zQ5oTZ5VInDCC0VYNflHjsyx#y)xBQ!zm8rj=o!5(rGdP#L4Nk3VAKr5Bd z#DBIpx%;531$R)T4*K%GV^%jZn7IR5?oE}Hb{9SyU&dROvheUGTh8kY^g_mPN0L50 zZ^|nwl{)_YwP`VhU57^hlk*GkK$|&uZ52vdTaKj&ksl+sYg+NYn?S|d^zo=iG8$*M zpG9iVx}D62nC83+0Q~8dx^g*(@BXjVXl|sUa6;uKsONPOA$A7C+{11c(`tV2}t~|_Srxz@+S^8lr zLaR1uWCQ$1hM&^ul77FqEIWi$cwl6g3kxv_!WKbmz;AxIeiMc-F}|BkO5VPDQd8!2 zHP>uU7bff^w}s}18Kq)kJ$)5Pm?}PX)uof7`7ofP-sMu)f)( zJ(@>*si}+9oV1(K{`6?=Dde+BE%mnZyOm@JrV-jBarqxFVef9?5?b2c|f76}CX%0;{%bYYKPW zQtfLe6kH#f>O3>;fx*i)Fy3nhl3hyv)tp16vv}FS*@@PGF_%Ga2ua`(`pU}})Su5W zeENQWsAN1?NH%p2eJ#5n3(|Skw=h@XwW9xZp?1SmiX9ENW&mc*<{jzM5sy-l!22J} zM6K7^jZ)!0aq2_l+tHpqeOUSUhq1EdF!S`9cEp2+iMujV0}Ma5CKFUrs*;;SS)hb(oLzA~pA)L&a8WRXdU5 zyIyS(XQZ4n%eHFGVFCC_`$q;{gO={JyVFex?m>mTIQ;d*WHMp#NFrzkBu3n((cdY>;63swoPx#;1|C~gy=rr z3lr%!dztG*b@>d%diUVcJ^#y(kke0=(d)(rwOggI*EikUG%SE!$LZfDI~ruQ;CF?g z*(nPg`CEPxKI#*%N68F!Gs$LIo9mCa^HdF$VYv;NudkKysocz(tJ`o0 z7q=*R`UdK0eV&wb^e6ElvGWGoDZ!9b|A?>mLlBnnWSNC|4o7m36;sDNB@WN_1EoE#!a0Q7>a2hmW@y9OVual*ahF}?Mr zMu(7oowaYhc@@7UbuJB-`#pi%{ia`b@$W;0fi-O%8PG2JoH>u2p+#%$UqFF}_Bxul zIr1sK+ZK4BE{?TGf2%RbwP_##N__*bHkH!zOA_WQZ7-OE^l*avfIUUsHRPG{oY z5E)MssEwdo`a|Kf%|L^h;#{Sjh-(>bx`R4@XjA5m3zJRm50n-2}!*tZY1vnM&$G2ZGx>aN1D}^0){i+v#Zk5`RAVkMy{{H_q4e-T*9d4*T-W!1m=SmYeHZKCU35~ESz z^Y}KcndNv63cXynulvpS)<$t)_*{oH8^6 zazk~o{;6S2b>R=<%CN<{c2Dq!L;1Ae;Wbw$L1+*=fx`zs+na9E&PG=4amcRMciko@ zxedYZ{LlIq6N5^h7I^josB|bSNhLauNO_X8Y;T$&t|sd(7o2h6K_@5%_?=1WU0Pq z5wKHb0v$c6NFs%*d}8?|J3mVz=1MS0g+U1+NbM}>(-y@Y@8Phyu+Vuo7DCX>h6K5; zp!R)eZ7@6}FLmJ~PYrR3Ja=DZc_W87=Q{)URuQu?oM@ZH+Q*c{fW05H2tNhQen1rNt9Co&B16Tu~tHzX!Uoun5E=?~aZ12;bfrGq19>asG z*_}g#Q(kCA5lZ2bx74+gIW4AkG+}Sx|D%B-Y}%<>uD>5KFdV>F#nfwa0^Rt7#Nu^R zkoO_uvqMWnG$cQY0j1P~+`a%f085Rx0!O&>L#5h}vvk=N$ zT;eju@xSW6Q&tv-uTc#oh5f|eW?WZ+5)EU-P~kwTr8Ehh*iV3`w1e-P9>cgMWZu3R zk$k)8(T;b|m#%1!clqrs^_W3~=c}X;{&w3t0rPu;9j8Uz--1AJ2O)(*HX1D22GI7rr@@nzqo~B&! z(i*=>UW0aFi%3Hc>=K2b=bRh8hj1ND8JBCcrGCtBXQk5?e7A1@VC2o#)wVo{sfK?@ zLTO~?1WW|B;K4kTLq0}n*){8J1WVFP&~E!p4==?g?*BAqw2T^(72|;K?yLb11&*=R zvcvJE1Ig#}Qg=^WId2!*feTK8$smP z_vF%&*1HNH@fjRm$;-b2JV@VmN(6@jQz~?8*ume9^CJpnPA0X%!C4p%nvU;l>53_TB+f-sI^+UH zbjTnl1%Pkq-4Q-MR{YZqrWaTZt4sQ^*UB)N`%V9~qO_rbXtPz`jt!fl5vK?Vug%q= zigwFX`MvL$^DnxuN{`S$8A^3=|No^-_MeGRa4BNL?_{v>%!b3){(K*mKHx!sJ)Baa>0! z`yDo-M3TERZL#BWyDjU5d>t=Et%b8(H)F zzppi;80Ys#GxKApmsbtMfagf7+=bR08+w6&`xnZm(8$hBpPFlIicH++3Zc zg1hC?i*rj(b*h$~jrM4Emw>!l&-%7XT;b#Hak}hj9~G>y?K_?meLxR2N@~a;Un7|r zcDPH4WFf6&;b4@y=zq~nCb`SDb{|1?asE9|r-LJj(%gRY)9R2hna!BXGUv)#;8qbr z{UP}lG|_D=-;R{ik{hJyXMX^#dR|kkQ!`}Cow@Tn@#Z2n0_UWPl?2Qlyo==<@pOZK zL85X!xsl})PIT*}d3wLZ9y|`*=NI1fg8W&Mq?(-JrNTYh!Gq0GsqA#sK8pL*&cJ9M zi89c7(Qg&aDCv`P4o)9boezafJr}%+s_3y7>}z6>{1Ht1JWNg2ELnIo$iuS$l zS;O?v-aFxym#e{E9*hLTw2qYSsNyQ0Gb0%&+!6ckTL-0)ASYmCwmB*nM5Zol);fZ^ z)_zh?R7@`pd&#D``qN1Kv{4k!+{s9tbOj1TZx?=c#JSEW+1IyK?)rHHN~;~X*yVKp z-g>|c8auyYzE{i@`+b9)`;4B^lFA!b_y)GL!~Jlwn^aQKj^9bNQT^}(cx=(A42JS;{3#r$=d08`wOiX0ne=B)SdZ;34 z2m!iRj^agw_CKQg%%xHY98^SZN|e%gDLJ0t?B4fE*t;%FGZIZml@w^fXysay0re(L zJt-g3%ZY_vv})PoNL~(WP2N%NoeN<_Wi`>OLx^Msr0aK$NW>1~k#w}yNnv5Wuj5N~ zu3@0s+_Vpku!u1FF93L4VR^rD^5uknBb_AGFK-YbGVbw1!@zA$zcdAaw-dUeOiw6r zJ3g!HgtB)0r4LRtNQM=nuF9P7g0-Vt^S2LTo92d!lzR2t5=9gcd$;*V$k_|$$jPW@ zC+LQq={y%CO&8m_@ft7r6T7w9?I9N8zJ86xa~tGewtb3H+1-+jO@ej@sdjxKx^oAc zj0QZq(ZwLwV1R{?XaghdI!SYOp&M4^V$6z^jUx)N|5g8CjZeu`)Lwu_H_#Z)!8v^No`xxH`WtJ ze(M!E#fyE#!yCKWGt+SyD%}4mr}&4o;{U9qLLv52eU@hzE$!z!Pokm zQp~6L&!8$r&GfC@N2K1p%nZj}mio=nBqx;VwGxSMgl|Cjqdje8lC09;&_j6HnLv`b zvbACrrNf_hOPj~=!6=RMU5s-6&D*x)nKnE3T82Q9)bOuQUln&T<^EnMM=&}esQwtT z9K(WIpXlmfB2;5ukyb4j>Q%{cGCKA@eRzPOM{!u9e`9$wC2|MfLxbv@NrlBb`3px{ zW@fTx&&)2dUL657e%&)!RYC})^F_Nh^wV+l&seOpinXMYLp3g2okst{uUy{I3)CWP z+yV2HG+^;=@G8?Y{`!+Lf=THO5F3Wr75F{@hJhxs?GuBB7=3 z1s?AjFKv5!uan_@?5eNA3H{V!&FcX({4RHU4ZDJH^H7&+c7%e4j>Mz zouZdRUU+EE1Q7z3WDG+Vz1B;Im9n+y!ow?Doiah62DJmG-6LaqqKj{BQEBRnH$ zhckFBMLT-8joRh<+vY&^%(4D3;S;>Np=kc5pKlgtsZd_mmiSe0s6M{p1u9qzY`yRXWC{BWW*G1^7y|7ha?#?|2n>lSTA;cY=NKL zM%0+uYibVte(sOU2d^7&4%dqN7@l4?hhdzg;p9)Z()hi-dPjSAAW4eddZ_&c7`H9Z z82kkUX{@dGWRV^;R)9Bq#j2xsn84FYHT8Rwfjz##62s-c*$nD?Jw#}cM&0a(7g$r| zC8L_;4hM3ZJN(f1Y04Ap$1B(bZrhkn{j<`0mXJTMBvbGAJs9F*w&P9`C3MJvx*J{N zi8|9cgNEF(rAX(hy1k#=d-Q&h1vV`5sg@^*CG6ycXx)Pun=^U??Gr9@U}hoDG^nhd zL%H+}SB#Dk2i3KGj&0JT)^RV0S~cSjIpw)N*}jT23&fI?^6P&3W`2ppyOZ(R=&D*R zHcHeT^jxhOO(2E3oywxWWx2?3FWCJhTc^83wdXE4W+S5Wm9nTmGXuxB zXT@c{;Aecc3f{@Id_ZE|q9kT)$J`9rCcygI@U-1L{HF%{_gdu$o)x^P}6`qdUgYSA|_!MDGvom-#%~?iPuvr~o$$ zJ$j;>-k!k8uy94KU!= zj=zpgOZ>PcnMS))M|C6NBZT7VG5WSjcE@-(nC#_4qn$}Lvy2w1Bi(6UNGp@n2s}Yb zt#XTUnjeF%?rz~=3vkFPg(O2y?!Ku0s}%<5FZRAdPb~mm?6``GHxJd(Zygn8%I%l0 z(YnlJ@AtLfvLR2Y6Rx;J_qAG!EufU{+LLVkoB>RP78ZwZnB)pTx+~OVY@@XB?o}*t z4~8VY7U^vy@IRxTY32a(?ztcO44UsexL5e+X>LyA}03C2pVESy$=C zsecOSpk_oWO*1Sh3u_+1xOrGxm$9WosZJzBDAMYva>APgy__^7&86$7>yHUZ+k3nS zdZsP6pf}ViRDtJj+F0;>bMAUavlKot@g0O?+gt&3egjvZqSnP$7ogpaSxAncRKd8& z$X2aJq)phYxKO6s{U^qpuD=_}F)t4X2@YYv@BI!BKhH5Y>7H%f0EcG$8;%YCNw ztlpjBW%Vv@Xad~GT;`~^?OiJ=W&->TOz!fB`W;dE(i?w?P!kLpXsl~9S6x3tl{l=P zfx^EN!5Ht7cd40LvvBB@NU3HJ+7V2tCUyUx$A4qLLZtr-W;8_#{7-}$Mv{ILS5ZgF z$505?Wa(EP_kf&fQMcbaqARjy%gyK{0cfaul1fudB*i|R;^tX}zVmJO#G&^MNd4hZ zkxIdU+Rf-z>ALGwtH3m}JV%;U30raWv(4n0bUL0>OZHb?Z|>=& z6>U1Eco8pc|5k8wlk`OiW))y&JL*KWi}5k=>^S@owXezQ=@)-`Mcv>-su(tC&}s*i zxY>Q3T`c)MDhioH&7jhk8_ChJli0!hV|EvekM=_q}l zTXnGaOXDNv!!(%}Fk{5lcU@{5nc403GSi;-&`%A!f z!)eb&fNO>Xn*r_u!#4xH3)Q*IdW70t|3(`C+t7hySeeUF#0W?x2&2Sy$57Mi7H1&n zA=6DEkL~>Cic(p;-#RqLl7h$?A?QiL#4mKr8WiX}J2D zLJnuw!1p|^{%jBgG24I+4m|9h!N9w}#at*5wE{SrCI0+BEL&~XM;9jV9)ztKEx^0W zHzVd`GvIyMpQc`4;tF&Yb%vS5U)T=t;+-L=uGzm7u<~uPo|N@WymLt0<;+I19F<2G zMrl1mme{bKh@HQ|{@j`##OG$Sxu`?8_=CIWwIo~)4%!l~aUKPmz?0c;IL@Zz(!oF-b_7!-^Ef&@P{Ps=qYj~JbWsirxr5-4?c zZf)kLTv^Rm4GWVC1GoZ1w3wI7X!pVo(Y{YtI{6sx|!CcLc z*GPG~+%|!hU!D1)<@-0$@L_D13u~5sVaScibt>9dx{mNp)?5;k{RYyqP4<$4>5Hn22m2K=W9ey^Rr2l|0$b?z8)=AGFNQMN)jo z`dFj^w5JVbmwP}X>ifNW{BfOxj9-k8^l>$D>7qvkRoB-orLVJ&omrE>L_glUsg;LN zLdCeMNjrUY&8`i>92eRh&Hk-k>ad7Byp!PxsKp>jqIFu5fe za_WgkpJJnF&+rPj4?*ZZ=iCDp>WY`;FXlgw!%mLGg_&lO(L)rmM6VbNhwD-}^Ez@sXOY&qtP^E;yW zqxzUXI^7%M3wvafl4|PjIs)R?RG@U~F-p1nBf%N6h5$r!=x!F2L8$pa4(unz+zi&F z0qN2^c943<$0j9eN@m5@XDn9j2A9K5S82a($pRYtGvZ!S^f-D&b(SF*V|zg`$_WYE zdjg`wPj&f5$F#1iQm$smvYs11`U}X)w;DkVj8+g_Qhi8nq+Zz%(wM++}KD|fA6#gh4d+xCr{^L)hbXLAj(9Vxh>)#2gvU#t;*k;38 zjwJq6vt5PHtE8EyD66Xs8tNuCg?5o?tv3)~)Q%9P4f7^Wyk+qZivyn^&eSb0nG{dRYwuYm|*t71K?qXtRcO zl1wzHbY@DsH+h4!|1Y&-JHwWRd z>Kff_adC^}h5X!T;j~6{_FvbE%2-&{M!QW)KNK!P4rG#hrQ1n|NF20^X1;5gpd+>g zU4D6gazXLa4PDHp+@3A2G05Nf2>jlcQ+h1=!&B81Z|(OdoF_E`+r8tb6t|!ean+3o zCg%CP#AB&7ZQ<||iYAHoZ3Lom+u@?5N@^doJ0xrZu$DdP!s7Jnui=!+a0M(Bq_X;> zqLw!Zp5oX{2316vX-&J!&B<|I)ip_sj4C-SN0Je!q02pKZQ0JLR0dZ2g1neRDlS=Q zh+bhJr9^H~Fx=|R(d>~?$I4oL-cH1oA1+hpy4WsF5{=d zWegFbDbe^3w!Fwz*CA9=*Gr!&*VFNW+Y#W2n$3k>!kf|x_E2puBE@JB_3nM+n@2X* zQcYev$7KwKI4O@OZ#dphl9ux3&&SqH6x5hRd#g8=@`#(}M{~8mYlg5KjMyZc{0Yk> zp1E!}J=owYxHv7Rhga)UiwEXXM-r ztUm@Vpff~C#Z<#59s=G(mc)(5q?*6D*BAKA4Q^O(j%60xtdZ6Bekl+H|x}3pVekjBShA zHFLyQ3n4-+xe0hJ+3LgBY}j)}v)MMY$MpsxE=o4~9<12lix_>vDW{B`##ZCvkoWA_ zqv~NlXdlObIn7>CuHACt2msu~7r)gn9t z+aH&*7mC6tG^}!wz|cn0m9OhcA8YVh_hQ7hcJ9F%B{M(^uWf8}rkQy#cw$s!ayN(k?D!W zTX#SgUgpYba*f9g^Q)S;CydDXD;x+-4|O-STpw0Bxer$to;&hET(#+dn5IwOt>yV- zVVh+$6`V(dBVoB3{oq9Yu5R9Ck--<2q3dVmQK~}wiX1x=pP@VSflaaE?S!mEl^*@;p@;6As(%5x4KGHM^a~9IBP|&+ zI~<&|CtiR8}!A^}n4FH$&-8@1e%O6O?ZILX}@UDH^L0(QFM!ZXHs`02T zPSLBeNkP8o8l4YkIbU{V=ouS-5+`;|V_4b#AbT{4l#Jbw2KZSPPM+*@NX^&53XE&U z#(r9_e=PoDr_p&U^eHRY^t2{1`b|t)9xxQ|a`q?td$MVShgsLjXPlj8mYw(jjiX>R za0_DIOmzODD@jn2{B^f%z8=D4N=?Sfv*Ta$)&HlM`VZ)!|9qBOY_zh1ybQqS0`(Me z*vfXT9?kt!`{n}@JW-E$gZBX%&cHw{e~b47 zKKzEeXO0qIGVw>;UjWVxh?mbW(V5qf_s!l+{l=)XRb60YD%-+0XHSHpwbIQ?zOV9& zAMH(|h0EX1L~aTA3SF!7urxxxVM5orGA=!-@a3fL`@Uq6wd;04zR#Rz_ziG@gQ-HI zl*f-OE^lo?x098ZP}Kh5#!A-Ob@{8eG}hTfW#?SAj(~Qog6I}>`e$7`7scwT$r3-luXQRO=3q{;+z=$H z)jN}4H!WQ0DJ7+Gt=+4P#yj)GX3fcY!B+N2@6BNHvn@wSQDQkM$=VBX9KUV>NTS zU5-;$hPj^+=~T?qd#%J(Vg-#RlN7Mu(0tEB!0X^p>vJHfo^QMY%pGz$dPAJG9sZ|7 zPA}_K#w=E^oXx~xmDBQS^B;t0-2Z<3TX&NG4d%euh``sbDJ&??S1knNnuE|1ZLCJT z;qK0D`Q1Cn4ygYGv-_Blyw7m&O<9GIm3b`HVT+ z)^a^gr-uD|Yw?o@`oM2K(q@JDpPv!oAQ&?xN;iPvMm4vMeKX9=-|y)>iJd(~8VRoY zjR`?ckv8cSVwt|Ji96Fn#X?qD)sCY9o*5pTXSo)0w`{Gb>4?98qK3sqpz2)>mecSc zDjm43TmRN`*;jTn!)mZZz%i^w`g`ZCtABj2{#B{$o9BoH6*je*C6*IhcJ?QA= zds{IXtEKL07CTv^t4`Qg9xq<}JQS#LBf-%nGOvt)Dn2zH4c=3JD$2y2Y zg=`72CldRIm$NaxoK%~s2jY-isF^jFRq3}8L!JwAEFGy`6`1HO4U0LWI`t^gFS`Zv zRFtTz%jO_sadbU|_4(Gcb!!Qj2#cf5-=EWNPEsL;T!nW%$PvTr-FMH9Esmnr6`CWy zn_9>k5^nzJ@w?{{x=w=(Em&2^_6Y5hCdL^w~LBVM5g~Hs<8((in_(W|8^ep?qdyu}twd8_%_4N3RmJ z*>Fa_Of)sY=Zy?o>THl=dX4o`X}B4sNq;hOY0u#e;%|;iKaO!Xj|2N;arVI@oW2a| z$qKHu_1|)ypf_0?D#}_DLC@Hr%82eE-GY*}=UGXi+bg-(K#%j&51-9vYZ%^H5Waga z#a`g27A>ZZkv@Nhnu#?I)JGN6){zl@3TD-6?^C-_5T*s|-g?)fUh^VCq~r|c2u=#% z;db&T)0C&Hj6~;mIfZ!yg z#U>9l=$vod@0E{aWpZa0^-=Im14&hd;^S4iEpx0?8R7w2X9@|9D6J;tV7!JD%Vq2I zz6k?>_P!c&iP!Wh+GJxw88Hzdhz7MNfs&qQ-Aa+_B0tjYJ#qHRUO#$0{e@bYqKXXxl3$>P7zy#7h~`nSa8|IEelpMi!W z@1;IL(F5>w?`{M2RfqWxzVi1aTe{O)b9kX3Zx%UQeWUNA`w<3#-r_gYADMw1_T}ny zkE=K%vSMzDLph*T#SGLZo)M>Ha@I}6_azZU6~!LZMx3t! zAflsjI_I3~FDnTwCP)?a2F=@L70ExcG!MeemLwCm8!x(=Kn46#Usa2!&1`6Fq3(f|sHFVk6{IL%`wo?nD#n}jVOjc4aewgcDoJhYZi#_GjG#~Ss0y#wxB8g8u81F}ZE~At7md|y}T8FcjxmJ!t1HM!>`!wQvW&J`nWjs&|wR$BN)FgMYSfQe2R(tO*mA;Z71 zBYu?DXKi1rsF`=CB}bl@FDk`XhYvOd7A*N%m&lLt?tm3fGIT_h9El1SIL)}6neXR;9p_Z87m9JlaIQl2OrC>9coX=^?DX&Zi@ z*7v@UHT-Fr>J!rK7jzdTX{~lOBkOamK|Hj}2fBhzho(D)CdvVe>_hP<<%A}$1jl>q z2vbztm#V1Qo?!P6$DDQ(S+Gmn)jmi%u!kLWx5hj9+P)rrOAoE526FvVwIxX5PZ5I(Y$XTLqL3^^ zahM8&M5Fd{)2I*;Z(&2hwln8ix^lsHl}C~vXM$U5dF3xhw>Uuz!jr$5RLkvkd%v@b zlt!uw<|r8eS(!+WcKTWJnr|7YA8hE#&L;U;kac+ppqEL1z=beT-{ z(0lB46ZKwxdjRmvMx;krq*m;(0^+g=T;{+f4Y;!Tm?Xd}EEj^PMJC}l+%(a|e8 zgkCX?Tr)jPIyovJaPf0IDiX0cT2;J_$996JJ~t*+UIRQjS32K@v;F*xLLA+n@-x1o z+GI{V{fAq?`fYMRo8|DMl?mR0El*fogJcbgQ8~)G%&PmY@VJm8J8*q}J^>oW;SA>@ zpNYbG#KgZqIQ1m@nU)qU(GOz)fz*8$s;@ubSG-(PIC_5piYu^AKeMn2WeVD<^fk@r zuj+AgX>UqaB=uu^o>LYwk=mFgzIv|YsA~3+CJD)j#QSRSRZD)PW9FAVuDoM9QU7FX zs2g(~N2fvOK&A%p1T7qqsHVo-#+epHns&%~DfeG1zI_Uf@=(KkA!f4WAFA2DfxDFPB<8CYq=X z*7-`PAOdGcMJE>=%uMbs3~_w1aSRbLjg@9Cb>s99I1m~VjHTOX9@R!Hrub>?Y#RLV z5|OsHR+5DcfWtyJ*$*HBg`uH9)I?VR1#?3HFGhEc|+SP z0lK`!n4sEvus&%jsl3+F4dQSo;Dl5BQR3G-_ zgmXOlUri(bf7{j1Wwev_Y5h=pb01y;Wp?6y1PEV(6_P2}s983}!H6M`dF_D=1$gNh3(1{w3P2JmFQtGr0Znl^JZA93M@{>=0z+6Kdd z!cL=O^(=xV%s^8`4%g#<0Y7H@umit;l_T2QNRc3}I?Gm){LU+pIiek0 zg*88#?L=_{Z%fi|U`rTc5u^Qc)(6m#&o`-cyM7^7^C#^UVR&|zPT22LCL#9V;YMAy zc;Z<_xG(Hmk65Jv{&GRgTS1?e`yVgEqvwi~7M;!0AqpMCW_-yG_*E4qbOYF3Ox=Ty zEw{3rpum$c2bJOhRr@lx*gAm-!OSl;S47B_WrfSH?M1r%j7+(}b&VI#y(sy9eMLk# zBu~H>7ZX`0sz=fX-r!x`C3=Z{tSydtq-vkY`MpKoVmfBf3c`@$2$IoC-*U+|(PT)m zE0w4HNd%xR-^3d&hWzY_ydC}H1Tp*iHdf{1w8#Dzh?y39>bMMRw5u2ef<7`9p_u0E zpLZgo^D!6p+w8;RtQ2F*iSL8n-yxj5AJ6wvPct+=Pw|UF{ox9Ui5;fkzCC@3?Sj4j zr_D>Y4h+mQdIB`X!92?yOr@7tCV4=n2mh=F=4S?%#BOiInWC7>(1%+*#t ztN=Z2Gb=>x#`d<^=fOHhjIu?JqHF1RBs|gLRJQPd0|r#Q9P%U2ilbPr_FG@)c__`( zGt;ZO9xPIKVFg=iBb~2-g&x|D;=f>3?UyP~`amwg&pm~k>wtB`JBXd&0QPHvz6|%B zmNP4uP5(jDwQfBE$t@CXvI3=3(PCWM-VRHH*FA@-D-|;6+pFc?99R_#>n0OK{H`Xy z(N$QjnY^Q|vw{~ZTj5XV6eyH#2-L1l_|`wS9O#r)Xok^FVF{7(9Mb4`A$W+@lMzSy z8I)`+E$MvS647FU4c%v#uH4k>sHt2Y*PpI!ChQs5T=oRsS}%p{uedA|hcHhE2FkJh znAuL{z#+)zQegQIw%ajGquUk@9)Nw?7D$={tD7i?M4tm zgB&@}N7`M`g->_Nb>(a+&5m>KhYR;U#8E9}AoHXQ5of;7Yg>`afesi71rp|6s9rsDzKFQx^7O}Pj#7?@8Nw% z)rMr)1mAml>6SVb>k-fgeL#aZ(4h4f;HJ6cbc-@7YJyC(s@~={(~d7$wb7ulqs`Xq zT$+fd(bthL*Pyffk5n-a;2@z6Z$*d!dCA(+xEgl)B<553>8|}poGr30JYb@YUiS6I zuj=7=y}WPjr8w2mL2eZxv>0N<3H(v&*coVGfh?FrJxy3+RdEp)I?6r6K2 zKfURZe5YRYr#OC_=fjV{JvDK43k9UE_rrDFlrh;*x_#juA`XTxIWZB}JHo{uXr!Y7 z?dW{L_0~^jlleMQRIMiI=Tz?ove~Hr0>A{(HNN;u(LsJ%T8y57k)~yyB}-&qw7#U~ zr7=A%<@UR<=M0*AQ0b)&Ba#&^;eV*v+jWxDq5K_Q-_5X?Z{CbkFCk<9txuNrAW{p; zV)SElqJi`671MFCU9|nlqD9jn52K@B;`(akY#{4wv->6wtS+qH+8sFW^@Vw+);tt( zJxRSc^#ZPlDJ{09_G1tg{1&3Dp=DzEsdPa>coqF7$-YCzm;89Kz0sk|2yd_T!FcGE zWR`tQ;Oz#%5Mnu6q5Rk3qf8dnJm{-2#11)e;#58`ygd1~u?89yyX@9ivhDBe&80fB zBBRj)(EDjvegZ>;UsauQN<@meKh|SNq&=l=m8iON(&fMx*}X4Hs1bDd!|71C^a)@` zBKR}G*j}lI9!m(})_ZxJVQsgqxKmpO`Qf-Q789nm*?Sh;&S*we$1+dCe=YOf-XbB>QB;%o z!QIPSYpo9=)``4JT5K+c3-2PnMutn3rW4g{Kj)Qs{jN1(GK|xSg^)nHhXGQA)JTb^ z#m)U6OX^(x6jL4tYtKksW?Ki10sT2Q+D)3@6dDczM)?mD&~fGRd|yELvNMGLKZ zKgmL9x1zaC*7ZAMK@yiqdJv2$HcFk&{*emYhJ!0X(~IE1URAED{etYY`=F{dV)gxj z*d;?nZ3@Zd$nN0FgYobHhQ>7UHyqV^>p(v7485PHnl2qR>L_^KrU=w@u6n|$&+DC^ zkYE#7cj!K|%}D6gnbM(QRNX-j>wB2rjRM4YxKVi+(oO}_aUeJMV3HY9>$lf3`VH8p zf86w>oH;MUtDiSDt* zkkcGD;4m9IZM%`BKxO(`UF z0(LD3{&>p#F2f{C;NmDQ|Mjvr!8Kli8QL%>5q}v-H!xQyciZaE(PBHeAWU0I68fyR zKy@)BpbCFmC7L!di>-Dh*WC1aE68`zf=)zK>cqzSL-4}Q&xAVxv*J5zUQY#dd-y$b z*j==RSiQcsalF zTkq@D)_*Tov`BF(a3i@X`dvTW{DN^-i;JmT5>%m;h>@@MJ*|#>6#Glz`Ry%C)0jxX zEnTlWs#BSwRz$6Z^@ApYX<=yzeDWr7?{<2B>lt`7+$q`sdJIEmzoV zUKZff^Mx+TNUVMUT0H|hXD2l zwAe6}8$4}#3@Srt0mnap2ioVI;@PX%mJ*_JpO`j2irp8bCU@EyIMfYa=WBU<4YfU! z&B-;B&i6#06H~D%_V|9E`+Xq+B0(Wgq3m_hnCXFKowoL5odPVdjlG}$M4hP?YKR+x zdP6togSiu_C+=Z<#UZlnTcl<&?+Jp9_pQk^NjlG}hJ>I<(J0d{csiYKWQu-iFC3St zCX*$cGk5L1pw^c2>`*dyQ198-hc&hk=X`RyYWs*~Yw+14X zRa>+D!W6oHyFA{%i0Op=fDtbByYtsjtwlpr@zR%dqs*BqW9>n^cB)+5(%YOjKM;c- zvsq;Y!#40n6(r1Ng-u;DO*>zbcSmzT7nOc(HNkInY1yCefQ3?I_e?iyNcIOSCHR8) z!iQC+-Oe@<++mKfkzJ!QdI!_o^zH(F@|MbzP^u`sVsMq|PR3s!SmX2%{twGMT{3PFXwh@T;CBMvMR31wE@conv z$l|KP>79Beg&WO`6#A<~^f3E!zU9eo*wu?CRMQ5N;p5F>bIy|=YZ6{X-2ymcox`(M zX6w`$h`m>W2kYv+;^COme#tV&7>$CBmK;!(l8qimdLRu@EdRp7G_izm=kiy9qaCkF z>>!s7`g28$#f|~*9vLEJ&MO&zFfZh~XnwyjSubeqEI8~@3CYhhz>fHo`_;>z3-Wto zM_q`IRN6GRdU0V-5k|B5r#1EI!BK@5Nug4W&x^QRyc?K+J$~!9`O1TeA>}L6If#yZ zYq!^+tqI|T4?PPN@D-p?#D-u?mgYlfzLp(gJOls0_CYE5>G;qafJR+%*NN}OhShjT zX@fwKmc37e!Nzc_esZ?Cc5>@Vm%nh(4k!lL^^OZ2qn@j<`XhzzaP+id4 zN?!QlQ1}u1AW3nDBXS0)6lY(_U);WN4BAARxf-K^236#6^f}h0h_wp7CjR`S!<49& z^hI)u>rp(zw-NBP$^W@t@X%;ZELb0Vm*KR=)yZth;6cInF|+ceS=?>CinLfowjp8!P#nB?+ zt>>j0@R1AmtzTp{eI+-byP-c<)fE(qSNil{O5RhY?3>#^66)J1OLz5P+bUg=S9&0> zE6$=4{nDpPnvI0iQ8(JDqJ#AJ2!}R9@zx=;K51fxdUp#HLqG!DKIAvJ(xA4cHYjyfhg);HLYo))z8o5?6*1_|4;I~Itg|;5> z;IC|J?V9Nvo%}l=7?%m3vQ(#t6!0YuETJQ`;c5q8`NU{l6OB1ewHsu3(d%ZJ<=cKf z;3bG}yW7AaV8-hZ&xBr~3#cmiVBq`-Ba6c}wAtxR^TJ*(1Um zN3%=ILAS8np5r`4$?v0WtISyBOSus=qp5aqMKd3|8*}kwP{@El6ocDH<^--HhmtTO&`-nJsuO}RxRJRK&VLZXG7w5Ep?%1gOn+q!<6g!^Tr}=B z3NaBNsHwOWLPC%|3%PCyNBtxc?c;^J1lTor(b<&x{GxGd!!)A3OWSN6N?x?eP#hFm zH-%D0b5#kjS!uPhyO&{qnl`;B#jjsyqTxP0iQA?$C1O={Nl7)*4(Sk9cWot0%s?`e zVs6^FS6CC%J@}|G0j|Aqon&nxGRjU1@4Bo>`xs5Gcq4AiYx%T`rWfI`+>z~or@QI& zc7Qw7$5K$#MMf%^?zuRcz;tpxGW9OZL=tXg^|{mMlT37#9?ym^IioESTg*w(vC0)8 z9ts6Vg)af#8=5_qann{=4D)A9V{`@;$Mvo4Y zoEl_Zf=h97;`(_Vd$P0E3GYf@Mi)0)@iG-2k7UsMbXm>hG6Po|pDSzk$u89R$WP>| z=1@s;Ixnkb+eDXGN(c&0ySD_53<{NiJj}C}=vG8I@%8mglx}d7*oG>fzuUVf_JSgT zS@fEYWFu!n-#tz&D9Z4jmx8@WD8ZGcVY|y$%AQFS1_>n&)CM@0J^e72cm&I;8IRIY zyvw*<=6JD`pxb8nrquD~sc_gQ5nox!bBahKuFzLYkh@CAf`o2n7IEz*)pYJ{m$78G zvD(Mp1Zytpg+b$mD|n+Vc(SOEBiCMEKZn)eBI77K#y-~f5!_IPDZWzmysCw6NujgM zSetb9LEWAXSAk?cD#XGwS^jKJE|wps{OO@zX<97JxLbMQ>RAi{^tI_Z$7`}Z-o3h& zg`EY~*1g11ciPHY@Di6y+>8S_>OSZn2)?zP2Mw`eYJulTD|xM(fBMg7317z`j>%MV zEBXW;$eC8e!}p_qqLW_Dijw6=0DrfSLGfC<=DGN_XbS!}0;M~@&%9;T7!vvl$%Sgr zCyUWmL@BNBqYi~wu$9P~QQ-vrxtWR{nzU;5pO%^ovmCZUzxer}*D%}sJOx+4N!AJN zkgn_yTH5M?Q%ugX-E{^XU96)Cw?tHlOM6H?yi}sY66Xqu)H{!$^bhFI*B_q z5TiyHVa63BU$P7`31nwq_!>8_U+V$Bl#y$bP&G`r&f{^RyTCXv`<3Wl>>&3yCJz>4 znI$_R!N?&n?X#GybBcoYvzX@~XQIAov)(CRTngvwFMYukBebLxq?ZQn_UXgWV289* z5L_UA1B5BvPj`-2=MrvLd}D?{fnvd!f3&qJw4^t*a80&b2Mbce_t1PnnCcC@fppK8hdm zy_zZzDxM7Upy{jEkJnWvs+%2F^+#%YXo&=fT>GBT#D=^snl`dcFpJ}wm@MBvt;C_L zzOhp_4MVyv>dlFx8B!daAmw`Q~Y%J|a+cUzu5IG1_l1qjZe9uge*Q0H|M6FeWZ_W`q zg&dwYV8ggRX`5&(dD1bJVu8DCL6LsPzE#A;StWLHbJ?hk4;q)5C>mkfW9gm0Nnqie zmnKp4=uCv)VoIQ&^i@sN`M?Cd26mdEKpyKq$nz6+^trhj+hWJ!4yNV+ zT%cb(P54-k2dIq*ht)Xz$<1#goYPMd3ukgJoSa@Oj!%N5Z}(6>IGus@Is)Ci0A>zD z{scUj*=J^%4PdR5Z9Tl#nx6FV+lE$?06>jl!+&%J`=fK!(!eQySTUX&r_8Dan!el~u5WnPHGmb$u3Dw6SNcDosK5Iv|>UYB}`1VICrgi%MArGIjZ9(`{ z_wNx=aqGmXNy$lxFNig?8_@h)eD5P75Lup`P|!!|q;k5n7b^$W0|yfA2_?XAMF2X7 z*FyL={bPtLfMv26LT&!lndPs7U`$H*;Gi!~zfhNoVVkvVuf^|H_!%E^jRNBKriHri z$|M3GupY#WhOhTheGmHkf)%=8i3^*+e)PBS;6wAb|E%`P;pNuIAfR_wy%zE4@o(|M z6)#0lD@{LZl&_-qCH^SSH)*%x)xYB#@7jigl>+NE`Tv%%?(j#zA%Y;Ac(1t-Q2^k^ z4(tn*cK8FA+L6C?ZETld$3Q&%^zV$}igxc1Og>Z|1>)yEO3fi4EzsQCKL)WP^jjOS ztLxQMKjbb*koJIr=YL>i-m)dr+L7p^UD2FGc@^rmE8YA*jUb0-?b`DK!aM$7M`4sA z_J;NWmrc7X-F=ZFf$Lr|K>MLTv!2BSHy(LMuIREt}H#2aSJ&TlhY^f00IMAEirGHk8LgVcjb_>45Q%5s>SR zQ}6o#H48w(KmOxA*%NLRJ37;bHQVvjgy`jZ~x%X2Ot~}-|N4H&w;lrTVpZX)tdWoz}O>ee!qa~a(|#i zE|S-nssNC5Kj~k)Lyh`h9LW(NnaNF*d?qe)A8{omgfk5Awfq;%FxFjLJlGez?i>R= zvijG4=(?-5h1ulnF-t^Y(k|da4Eo2Qo{4`kZNVASA`=Qx {}) - .csrf(csrf -> csrf.disable()) - .headers(headers -> headers - .frameOptions(frameOptions -> frameOptions.disable())) - .authorizeHttpRequests(auth -> auth - .requestMatchers( - "/", - "/h2-console/**", - "/api/auth/login", - "/api/auth/password-reset/request", - "/api/auth/password-reset/confirm", - "/v3/api-docs/**", - "/swagger-ui/**", - "/swagger-ui.html" - ).permitAll() - .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll() - .requestMatchers(HttpMethod.GET, "/api/crawl/**").permitAll() - .requestMatchers("/api/admin/**").hasRole(AdminLevel.SUPER_ADMIN.name()) - .anyRequest().authenticated() - ) - .exceptionHandling(exceptions -> exceptions - .authenticationEntryPoint((request, response, authException) -> { - response.setStatus(HttpStatus.UNAUTHORIZED.value()); - response.setContentType("application/json;charset=UTF-8"); - String jsonResponse = objectMapper.writeValueAsString( - new ErrorResponse( - "UNAUTHENTICATED", - "Authentication is required. Please log in.", - LocalDateTime.now(), - request.getRequestURI() - ) - ); - response.getWriter().write(jsonResponse); - }) - .accessDeniedHandler((request, response, accessDeniedException) -> { - response.setStatus(HttpStatus.FORBIDDEN.value()); - response.setContentType("application/json;charset=UTF-8"); - String jsonResponse = objectMapper.writeValueAsString( - new ErrorResponse( - "ACCESS_DENIED", - "You do not have permission to access this resource.", - LocalDateTime.now(), - request.getRequestURI() - ) - ); - response.getWriter().write(jsonResponse); - }) - ) - .sessionManagement(session -> session - .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, employeeDetailsService), - UsernamePasswordAuthenticationFilter.class); + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf(csrf -> csrf.disable()) + .headers(headers -> headers + .frameOptions(frameOptions -> frameOptions.disable())) + .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers( + "/", + "/error", // 에러 경로 허용 + "/h2-console/**", + "/api/auth/login", + "/api/auth/password-reset/request", + "/api/auth/password-reset/confirm", + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html" + ).permitAll() + .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/crawl/**").permitAll() + .requestMatchers("/api/admin/employees").permitAll() + .requestMatchers("/api/admin/**").hasAuthority(AdminLevel.SUPER_ADMIN.name()) + .anyRequest().authenticated() + ) - return http.build(); - } + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, employeeDetailsService), + UsernamePasswordAuthenticationFilter.class); - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); + return http.build(); } @Bean - public CorsFilter corsFilter() { + public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); - config.addAllowedOriginPattern("http://localhost:3000"); - config.addAllowedOriginPattern("http://localhost:5173"); - config.addAllowedOriginPattern("https://www.joycrew.co.kr"); - config.addAllowedOriginPattern("https://joycrew.co.kr"); - config.addAllowedOriginPattern("http://localhost:8082"); - config.addAllowedHeader("*"); - config.addAllowedMethod("*"); + config.setAllowedOriginPatterns(Arrays.asList( + "http://localhost:3000", + "http://localhost:5173", + "http://localhost:8082", + "https://joycrew.co.kr", + "https://www.joycrew.co.kr", + "https://api.joycrew.co.kr" + )); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); - return new CorsFilter(source); + return source; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); } @Bean diff --git a/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java b/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java index 2238b6c..5d3d3df 100644 --- a/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java +++ b/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java @@ -1,6 +1,7 @@ package com.joycrew.backend.controller; import com.joycrew.backend.dto.PagedEmployeeResponse; +import com.joycrew.backend.entity.enums.AccessStatus; import com.joycrew.backend.security.UserPrincipal; import com.joycrew.backend.service.EmployeeQueryService; import io.swagger.v3.oas.annotations.Operation; @@ -48,7 +49,7 @@ public ResponseEntity searchEmployees( @RequestParam(defaultValue = "20") int size, @AuthenticationPrincipal UserPrincipal principal ) { - PagedEmployeeResponse response = employeeQueryService.getEmployees(keyword, page, size, principal.getEmployee().getEmployeeId()); + PagedEmployeeResponse response = employeeQueryService.getEmployees(keyword, page, size, principal.getEmployee().getEmployeeId(), AccessStatus.ACTIVE); return ResponseEntity.ok(response); } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/controller/UserController.java b/src/main/java/com/joycrew/backend/controller/UserController.java index b9af83b..f1c20db 100644 --- a/src/main/java/com/joycrew/backend/controller/UserController.java +++ b/src/main/java/com/joycrew/backend/controller/UserController.java @@ -4,6 +4,10 @@ import com.joycrew.backend.security.UserPrincipal; import com.joycrew.backend.service.EmployeeService; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Encoding; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -40,17 +44,29 @@ public ResponseEntity forceChangePassword( return ResponseEntity.ok(new SuccessResponse("Password changed successfully.")); } - @Operation(summary = "Update my information", description = "Send profile data as 'request' part and image as 'profileImage' part in a multipart/form-data request.", security = @SecurityRequirement(name = "Authorization")) + @Operation(summary = "Update my information", + description = "Send profile data as 'request' part and image as 'profileImage' part in multipart/form-data request.", + security = @SecurityRequirement(name = "Authorization")) @PatchMapping(value = "/profile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity updateMyProfile( - @AuthenticationPrincipal UserPrincipal principal, - @RequestPart("request") UserProfileUpdateRequest request, - @RequestPart(value = "profileImage", required = false) MultipartFile profileImage) { + @AuthenticationPrincipal UserPrincipal principal, + @Parameter(description = "User profile update data", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + @RequestPart("request") UserProfileUpdateRequest request, + @Parameter(description = "Profile image file (optional)", + content = @Content(mediaType = "image/*")) + @RequestPart(value = "profileImage", required = false) MultipartFile profileImage) { + + // 빈 파일 체크 추가 + MultipartFile imageFile = (profileImage != null && !profileImage.isEmpty()) ? profileImage : null; - employeeService.updateUserProfile(principal.getUsername(), request, profileImage); + employeeService.updateUserProfile(principal.getUsername(), request, imageFile); return ResponseEntity.ok(new SuccessResponse("Your information has been updated successfully.")); } + + + @Operation(summary = "Verify current password", security = @SecurityRequirement(name = "Authorization")) @PostMapping("/password/verify") public ResponseEntity verifyPassword( diff --git a/src/main/java/com/joycrew/backend/entity/Employee.java b/src/main/java/com/joycrew/backend/entity/Employee.java index 0d93fce..8fe585a 100644 --- a/src/main/java/com/joycrew/backend/entity/Employee.java +++ b/src/main/java/com/joycrew/backend/entity/Employee.java @@ -3,12 +3,14 @@ import com.joycrew.backend.entity.enums.AdminLevel; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.Where; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.crypto.password.PasswordEncoder; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; @Entity @@ -16,6 +18,7 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) +@Where(clause = "status = 'ACTIVE'") @Builder public class Employee { @@ -101,9 +104,11 @@ protected void onCreate() { @PreUpdate protected void onUpdate() { + if (this.status == null || !Arrays.asList("ACTIVE", "INACTIVE", "PENDING").contains(this.status)) { + this.status = "ACTIVE"; + } this.updatedAt = LocalDateTime.now(); } - public void changePassword(String rawPassword, PasswordEncoder encoder) { this.passwordHash = encoder.encode(rawPassword); } @@ -121,7 +126,11 @@ public void updateRole(AdminLevel newRole) { } public void updateStatus(String newStatus) { - this.status = newStatus; + if (newStatus != null && Arrays.asList("ACTIVE", "INACTIVE", "PENDING").contains(newStatus)) { + this.status = newStatus; + } else { + this.status = "ACTIVE"; + } } public void updateProfileImageUrl(String newUrl) { diff --git a/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java b/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java index dcc2e17..69b22b1 100644 --- a/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java +++ b/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java @@ -10,8 +10,23 @@ import java.util.Optional; public interface EmployeeRepository extends JpaRepository { + Optional findByEmail(String email); - @Query("SELECT e FROM Employee e WHERE e.employeeName LIKE %:keyword% OR e.email LIKE %:keyword% OR e.department.name LIKE %:keyword%") + @Query(""" + SELECT e + FROM Employee e + WHERE e.employeeName LIKE %:keyword% + OR e.email LIKE %:keyword% + OR e.department.name LIKE %:keyword% + """) Page findByKeyword(@Param("keyword") String keyword, Pageable pageable); + + @Query(""" + SELECT e + FROM Employee e + JOIN FETCH e.company + WHERE e.employeeId = :id + """) + Optional findByIdWithCompany(@Param("id") Long id); } diff --git a/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java b/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java index 5f4a2e2..dca415a 100644 --- a/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java @@ -2,6 +2,10 @@ import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.JwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -9,13 +13,12 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.util.AntPathMatcher; import org.springframework.web.filter.OncePerRequestFilter; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import java.util.Arrays; +import java.util.List; @Slf4j @RequiredArgsConstructor @@ -23,16 +26,50 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; private final UserDetailsService userDetailsService; + // AntPathMatcher를 사용하여 URL 패턴을 비교합니다. (e.g., /api/docs/**) + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + + // 1. 여기에 JWT 토큰 검사를 건너뛸 경로 목록을 정의합니다. + private static final List EXCLUDE_URLS = Arrays.asList( + "/", + "/h2-console/**", + "/api/auth/login", + "/api/auth/password-reset/**", + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/api/products/**", + "/api/crawl/**", + // 최초 관리자 등록을 위해 이 경로를 필터 예외 목록에 추가합니다. + "/api/admin/employees" + ); @Override protected void doFilterInternal(HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain) - throws ServletException, IOException { + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + + // 2. 현재 요청 경로가 EXCLUDE_URLS 목록에 포함되는지 확인합니다. + String path = request.getServletPath(); + boolean isExcluded = EXCLUDE_URLS.stream() + .anyMatch(excludeUrl -> pathMatcher.match(excludeUrl, path)); + + // 3. 예외 목록에 포함된 경로라면, 필터 로직을 실행하지 않고 즉시 다음 필터로 넘깁니다. + if (isExcluded) { + log.info("JWT Filter bypassed for path: {}", path); + filterChain.doFilter(request, response); + return; + } + + // --- 아래는 기존 필터 로직과 동일합니다 --- + + log.info("===== JWT Filter Executed for path: {} =====", path); String authHeader = request.getHeader("Authorization"); - if (authHeader == null || !authHeader.startsWith("Bearer ") || SecurityContextHolder.getContext().getAuthentication() != null) { + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + log.warn("Authorization header is missing or invalid for protected path: {}", path); filterChain.doFilter(request, response); return; } @@ -47,18 +84,19 @@ protected void doFilterInternal(HttpServletRequest request, log.warn("Invalid JWT token: {}", e.getMessage()); } - if (email != null) { + if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = this.userDetailsService.loadUserByUsername(email); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( - userDetails, - null, - userDetails.getAuthorities() + userDetails, + null, + userDetails.getAuthorities() ); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); + log.info("User '{}' authenticated successfully.", email); } filterChain.doFilter(request, response); } -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/security/UserPrincipal.java b/src/main/java/com/joycrew/backend/security/UserPrincipal.java index e0886e3..e9f4a2e 100644 --- a/src/main/java/com/joycrew/backend/security/UserPrincipal.java +++ b/src/main/java/com/joycrew/backend/security/UserPrincipal.java @@ -20,7 +20,7 @@ public UserPrincipal(Employee employee) { @Override public Collection getAuthorities() { - return Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + employee.getRole().name())); + return Collections.singletonList(new SimpleGrantedAuthority(employee.getRole().name())); } @Override diff --git a/src/main/java/com/joycrew/backend/service/AdminPointService.java b/src/main/java/com/joycrew/backend/service/AdminPointService.java index 1a8da4f..66ad44c 100644 --- a/src/main/java/com/joycrew/backend/service/AdminPointService.java +++ b/src/main/java/com/joycrew/backend/service/AdminPointService.java @@ -32,11 +32,18 @@ public class AdminPointService { private final CompanyRepository companyRepository; public void distributePoints(AdminPointDistributionRequest request, Employee admin) { + // Admin 다시 조회 (Company까지 join fetch) + Employee managedAdmin = employeeRepository.findByIdWithCompany(admin.getEmployeeId()) + .orElseThrow(() -> new UserNotFoundException("Admin not found")); + + Company company = managedAdmin.getCompany(); + + // 총 변화량 계산 int netPointsChange = request.distributions().stream() - .mapToInt(PointDistributionDetail::points) - .sum(); + .mapToInt(PointDistributionDetail::points) + .sum(); - Company company = admin.getCompany(); + // 회사 예산 반영 if (netPointsChange > 0) { company.spendBudget(netPointsChange); } else if (netPointsChange < 0) { @@ -44,21 +51,25 @@ public void distributePoints(AdminPointDistributionRequest request, Employee adm } companyRepository.save(company); + // 지급 대상 직원 ID 목록 List employeeIds = request.distributions().stream() - .map(PointDistributionDetail::employeeId) - .toList(); + .map(PointDistributionDetail::employeeId) + .toList(); + // 직원 목록 조회 Map employeeMap = employeeRepository.findAllById(employeeIds).stream() - .collect(Collectors.toMap(Employee::getEmployeeId, Function.identity())); + .collect(Collectors.toMap(Employee::getEmployeeId, Function.identity())); + // 일부 직원이 없으면 예외 if (employeeMap.size() != employeeIds.size()) { throw new UserNotFoundException("Could not find some of the requested employees. Please verify the IDs."); } + // 각 직원별 지급/차감 실행 for (PointDistributionDetail detail : request.distributions()) { Employee employee = employeeMap.get(detail.employeeId()); Wallet wallet = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) - .orElseThrow(() -> new IllegalStateException("Wallet not found for employee: " + employee.getEmployeeName())); + .orElseThrow(() -> new IllegalStateException("Wallet not found for employee: " + employee.getEmployeeName())); int pointsToProcess = detail.points(); @@ -68,16 +79,17 @@ public void distributePoints(AdminPointDistributionRequest request, Employee adm wallet.revokePoints(Math.abs(pointsToProcess)); } + // 트랜잭션 기록 저장 if (pointsToProcess != 0) { RewardPointTransaction transaction = RewardPointTransaction.builder() - .sender(admin) - .receiver(employee) - .pointAmount(pointsToProcess) - .message(request.message()) - .type(TransactionType.AWARD_MANAGER_SPOT) - .build(); + .sender(managedAdmin) + .receiver(employee) + .pointAmount(pointsToProcess) + .message(request.message()) + .type(TransactionType.AWARD_MANAGER_SPOT) + .build(); transactionRepository.save(transaction); } } } -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/service/EmployeeManagementService.java b/src/main/java/com/joycrew/backend/service/EmployeeManagementService.java index e1ccc99..7d14502 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeManagementService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeManagementService.java @@ -34,8 +34,8 @@ public AdminPagedEmployeeResponse searchEmployees(String keyword, int page, int StringBuilder whereClause = new StringBuilder("WHERE 1=1 "); if (keyword != null && !keyword.isBlank()) { whereClause.append("AND (LOWER(e.employeeName) LIKE :keyword ") - .append("OR LOWER(e.email) LIKE :keyword ") - .append("OR LOWER(d.name) LIKE :keyword) "); + .append("OR LOWER(e.email) LIKE :keyword ") + .append("OR LOWER(d.name) LIKE :keyword) "); } String countJpql = "SELECT COUNT(e) FROM Employee e LEFT JOIN e.department d " + whereClause; @@ -47,39 +47,39 @@ public AdminPagedEmployeeResponse searchEmployees(String keyword, int page, int int totalPages = (int) Math.ceil((double) total / size); String dataJpql = "SELECT e FROM Employee e " + - "LEFT JOIN FETCH e.department d " + - "LEFT JOIN FETCH e.company c " + - whereClause + - "ORDER BY e.employeeName ASC"; + "LEFT JOIN FETCH e.department d " + + "LEFT JOIN FETCH e.company c " + + whereClause + + "ORDER BY e.employeeName ASC"; TypedQuery dataQuery = em.createQuery(dataJpql, Employee.class) - .setFirstResult(page * size) - .setMaxResults(size); + .setFirstResult(page * size) + .setMaxResults(size); if (keyword != null && !keyword.isBlank()) { dataQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); } List employees = dataQuery.getResultList().stream() - .map(employeeMapper::toAdminEmployeeQueryResponse) // Use Mapper - .toList(); + .map(employeeMapper::toAdminEmployeeQueryResponse) // Use Mapper + .toList(); return new AdminPagedEmployeeResponse( - employees, - page, // Return 0-based page index for consistency - totalPages, - page >= totalPages - 1 + employees, + page, + totalPages, + page >= totalPages - 1 ); } public Employee updateEmployee(Long employeeId, AdminEmployeeUpdateRequest request) { Employee employee = employeeRepository.findById(employeeId) - .orElseThrow(() -> new UserNotFoundException("Employee not found with ID: " + employeeId)); + .orElseThrow(() -> new UserNotFoundException("Employee not found with ID: " + employeeId)); if (request.name() != null) { employee.updateName(request.name()); } if (request.departmentId() != null) { Department department = departmentRepository.findById(request.departmentId()) - .orElseThrow(() -> new IllegalArgumentException("Department not found with ID: " + request.departmentId())); + .orElseThrow(() -> new IllegalArgumentException("Department not found with ID: " + request.departmentId())); employee.assignToDepartment(department); } if (request.position() != null) { @@ -96,14 +96,14 @@ public Employee updateEmployee(Long employeeId, AdminEmployeeUpdateRequest reque public void deactivateEmployee(Long employeeId) { Employee employee = employeeRepository.findById(employeeId) - .orElseThrow(() -> new UserNotFoundException("Employee not found with ID: " + employeeId)); - employee.updateStatus("DELETED"); + .orElseThrow(() -> new UserNotFoundException("Employee not found with ID: " + employeeId)); + employee.updateStatus("INACTIVE"); } @Transactional(readOnly = true) public List getAllEmployees() { return employeeRepository.findAll().stream() - .map(employeeMapper::toAdminEmployeeQueryResponse) // Use Mapper - .toList(); + .map(employeeMapper::toAdminEmployeeQueryResponse) // Use Mapper + .toList(); } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java b/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java index 4f9c385..1e142e0 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java @@ -3,6 +3,7 @@ import com.joycrew.backend.dto.EmployeeQueryResponse; import com.joycrew.backend.dto.PagedEmployeeResponse; import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.enums.AccessStatus; import com.joycrew.backend.service.mapper.EmployeeMapper; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; @@ -24,7 +25,7 @@ public class EmployeeQueryService { private final EntityManager em; private final EmployeeMapper employeeMapper; - public PagedEmployeeResponse getEmployees(String keyword, int page, int size, Long currentUserId) { + public PagedEmployeeResponse getEmployees(String keyword, int page, int size, Long currentUserId, AccessStatus accessStatus) { StringBuilder whereClause = new StringBuilder(); boolean hasKeyword = StringUtils.hasText(keyword); diff --git a/src/main/java/com/joycrew/backend/service/StatisticsService.java b/src/main/java/com/joycrew/backend/service/StatisticsService.java index 3019cd0..25682bb 100644 --- a/src/main/java/com/joycrew/backend/service/StatisticsService.java +++ b/src/main/java/com/joycrew/backend/service/StatisticsService.java @@ -5,6 +5,7 @@ import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.RewardPointTransaction; import com.joycrew.backend.entity.enums.Tag; +import com.joycrew.backend.entity.enums.TransactionType; import com.joycrew.backend.exception.UserNotFoundException; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.RewardPointTransactionRepository; @@ -30,14 +31,15 @@ public PointStatisticsResponse getPointStatistics(String userEmail) { Employee user = employeeRepository.findByEmail(userEmail) .orElseThrow(() -> new UserNotFoundException("User not found with email: " + userEmail)); - // Use a query that fetches related entities to avoid N+1 problems + // Fetch all transactions involving the user (sender or receiver) List allTransactions = transactionRepository.findBySenderOrReceiverOrderByTransactionDateDesc(user, user); List receivedHistory = new ArrayList<>(); List sentHistory = new ArrayList<>(); + // Process transactions, excluding REDEEM_ITEM for history for (RewardPointTransaction tx : allTransactions) { - if (user.equals(tx.getReceiver())) { + if (user.equals(tx.getReceiver()) && tx.getType() != TransactionType.REDEEM_ITEM) { Employee sender = tx.getSender(); receivedHistory.add(TransactionHistoryResponse.builder() .transactionId(tx.getTransactionId()) @@ -49,7 +51,7 @@ public PointStatisticsResponse getPointStatistics(String userEmail) { .counterpartyProfileImageUrl(sender != null ? sender.getProfileImageUrl() : null) .counterpartyDepartmentName(sender != null && sender.getDepartment() != null ? sender.getDepartment().getName() : null) .build()); - } else if (user.equals(tx.getSender())) { + } else if (user.equals(tx.getSender()) && tx.getType() != TransactionType.REDEEM_ITEM) { Employee receiver = tx.getReceiver(); sentHistory.add(TransactionHistoryResponse.builder() .transactionId(tx.getTransactionId()) @@ -64,11 +66,20 @@ public PointStatisticsResponse getPointStatistics(String userEmail) { } } - int totalReceived = receivedHistory.stream().mapToInt(TransactionHistoryResponse::amount).sum(); - int totalSent = sentHistory.stream().mapToInt(TransactionHistoryResponse::amount).sum(); + // Calculate total points including all transaction types + int totalReceived = allTransactions.stream() + .filter(tx -> user.equals(tx.getReceiver())) + .mapToInt(RewardPointTransaction::getPointAmount) + .sum(); + int totalSent = allTransactions.stream() + .filter(tx -> user.equals(tx.getSender())) + .mapToInt(tx -> tx.getPointAmount()) + .sum(); + + // Calculate tag statistics (only for received transactions, excluding REDEEM_ITEM) Map tagStatsMap = allTransactions.stream() - .filter(tx -> user.equals(tx.getReceiver()) && tx.getTags() != null) + .filter(tx -> user.equals(tx.getReceiver()) && tx.getTags() != null && tx.getType() != TransactionType.REDEEM_ITEM) .flatMap(tx -> tx.getTags().stream()) .collect(Collectors.groupingBy(tag -> tag, Collectors.counting())); diff --git a/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java b/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java index b60f54c..bffd6a6 100644 --- a/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java +++ b/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java @@ -23,41 +23,32 @@ public class TransactionHistoryService { public List getTransactionHistory(String userEmail) { Employee user = employeeRepository.findByEmail(userEmail) - .orElseThrow(() -> new UserNotFoundException("User not found with email: " + userEmail)); + .orElseThrow(() -> new UserNotFoundException("User not found with email: " + userEmail)); - // Define which transaction types are considered personal history List personalTransactionTypes = List.of( - TransactionType.AWARD_P2P, - TransactionType.REDEEM_ITEM + TransactionType.AWARD_P2P ); return transactionRepository.findBySenderOrReceiverOrderByTransactionDateDesc(user, user) - .stream() - // Filter only for personal transaction types - .filter(tx -> personalTransactionTypes.contains(tx.getType())) - .map(tx -> { - boolean isSender = user.equals(tx.getSender()); - // For item redemption, the user is the sender of points, so amount should be negative. - int amount = isSender ? -tx.getPointAmount() : tx.getPointAmount(); - - String counterparty; - if (tx.getType() == TransactionType.REDEEM_ITEM) { - counterparty = tx.getMessage() != null ? tx.getMessage() : "상품 구매"; - } else { // AWARD_P2P - counterparty = isSender - ? tx.getReceiver().getEmployeeName() - : tx.getSender().getEmployeeName(); - } - - return TransactionHistoryResponse.builder() - .transactionId(tx.getTransactionId()) - .type(tx.getType()) - .amount(amount) - .counterparty(counterparty) - .message(tx.getMessage()) - .transactionDate(tx.getTransactionDate()) - .build(); - }) - .collect(Collectors.toList()); + .stream() + .filter(tx -> personalTransactionTypes.contains(tx.getType())) + .map(tx -> { + boolean isSender = user.equals(tx.getSender()); + int amount = isSender ? -tx.getPointAmount() : tx.getPointAmount(); + + String counterparty = isSender + ? tx.getReceiver().getEmployeeName() + : tx.getSender().getEmployeeName(); + + return TransactionHistoryResponse.builder() + .transactionId(tx.getTransactionId()) + .type(tx.getType()) + .amount(amount) + .counterparty(counterparty) + .message(tx.getMessage()) + .transactionDate(tx.getTransactionDate()) + .build(); + }) + .collect(Collectors.toList()); } } \ No newline at end of file From 647ab5b8976f881142649a157cdc5ecef8e53140 Mon Sep 17 00:00:00 2001 From: yeoEun Date: Thu, 18 Sep 2025 20:36:05 +0900 Subject: [PATCH 098/135] feat: NicePass-kyc --- .../backend/auth/AccountRecoveryService.java | 29 +++++ .../backend/config/NicePassConfig.java | 20 ++++ .../backend/config/NicePassProperties.java | 19 +++ .../backend/config/SecurityConfig.java | 4 +- .../backend/kyc/KycPassController.java | 35 ++++++ .../joycrew/backend/kyc/KycPassService.java | 109 ++++++++++++++++++ .../joycrew/backend/kyc/NicePassClient.java | 70 +++++++++++ .../backend/kyc/crypto/NiceCrypto.java | 10 ++ .../backend/kyc/crypto/NiceCryptoImpl.java | 83 +++++++++++++ .../joycrew/backend/kyc/dto/FindIdResult.java | 10 ++ .../backend/kyc/dto/NiceStartResponse.java | 13 +++ .../backend/kyc/store/InMemoryKycStore.java | 44 +++++++ .../repository/EmployeeRepository.java | 2 + .../backend/service/EmployeeService.java | 5 + .../com/joycrew/backend/util/MaskingUtil.java | 14 +++ src/main/resources/application.yml | 18 +++ 16 files changed, 484 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/joycrew/backend/auth/AccountRecoveryService.java create mode 100644 src/main/java/com/joycrew/backend/config/NicePassConfig.java create mode 100644 src/main/java/com/joycrew/backend/config/NicePassProperties.java create mode 100644 src/main/java/com/joycrew/backend/kyc/KycPassController.java create mode 100644 src/main/java/com/joycrew/backend/kyc/KycPassService.java create mode 100644 src/main/java/com/joycrew/backend/kyc/NicePassClient.java create mode 100644 src/main/java/com/joycrew/backend/kyc/crypto/NiceCrypto.java create mode 100644 src/main/java/com/joycrew/backend/kyc/crypto/NiceCryptoImpl.java create mode 100644 src/main/java/com/joycrew/backend/kyc/dto/FindIdResult.java create mode 100644 src/main/java/com/joycrew/backend/kyc/dto/NiceStartResponse.java create mode 100644 src/main/java/com/joycrew/backend/kyc/store/InMemoryKycStore.java create mode 100644 src/main/java/com/joycrew/backend/util/MaskingUtil.java diff --git a/src/main/java/com/joycrew/backend/auth/AccountRecoveryService.java b/src/main/java/com/joycrew/backend/auth/AccountRecoveryService.java new file mode 100644 index 0000000..ad1aee2 --- /dev/null +++ b/src/main/java/com/joycrew/backend/auth/AccountRecoveryService.java @@ -0,0 +1,29 @@ +package com.joycrew.backend.auth; + +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.repository.EmployeeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class AccountRecoveryService { + + private final EmployeeRepository employeeRepository; + + /** + * PASS 복호화 결과에서 받은 신원정보로 로그인 아이디(사내에선 보통 email)를 조회. + * - CI를 쓰고 싶다면 Employee 엔티티에 ci 필드 추가 후 findByCi 사용 권장. + */ + public Optional findLoginIdByIdentity(String name, LocalDate birthday, String mobileNo) { + // 가장 보편: 휴대폰 번호로 1차 후보 → 이름/생일 2차 검증 + Optional byPhone = employeeRepository.findByPhoneNumber(mobileNo); + return byPhone + .filter(e -> (e.getName() == null || e.getName().equals(name))) + .filter(e -> (e.getBirthday() == null || e.getBirthday().equals(birthday))) + .map(Employee::getEmail); // 시스템에서 '아이디'가 이메일일 경우 + } +} diff --git a/src/main/java/com/joycrew/backend/config/NicePassConfig.java b/src/main/java/com/joycrew/backend/config/NicePassConfig.java new file mode 100644 index 0000000..9c7384f --- /dev/null +++ b/src/main/java/com/joycrew/backend/config/NicePassConfig.java @@ -0,0 +1,20 @@ +package com.joycrew.backend.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +@Configuration +@EnableConfigurationProperties(NicePassProperties.class) +public class NicePassConfig { + + @Bean + public RestTemplate niceRestTemplate(NicePassProperties props) { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(props.getTimeoutMs()); + factory.setReadTimeout(props.getTimeoutMs()); + return new RestTemplate(factory); + } +} diff --git a/src/main/java/com/joycrew/backend/config/NicePassProperties.java b/src/main/java/com/joycrew/backend/config/NicePassProperties.java new file mode 100644 index 0000000..30a7f7c --- /dev/null +++ b/src/main/java/com/joycrew/backend/config/NicePassProperties.java @@ -0,0 +1,19 @@ +package com.joycrew.backend.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter @Setter +@ConfigurationProperties(prefix = "nice.pass") +public class NicePassProperties { + private String baseUrl; + private String standardWindowUrl; + private String tokenVersionId; + private String clientId; + private String clientSecret; + private String productId; + private String returnUrl; + private int timeoutMs = 7000; + private int reqTtlSeconds = 600; +} diff --git a/src/main/java/com/joycrew/backend/config/SecurityConfig.java b/src/main/java/com/joycrew/backend/config/SecurityConfig.java index 23d193f..2dbbbb0 100644 --- a/src/main/java/com/joycrew/backend/config/SecurityConfig.java +++ b/src/main/java/com/joycrew/backend/config/SecurityConfig.java @@ -53,7 +53,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/api/auth/password-reset/confirm", "/v3/api-docs/**", "/swagger-ui/**", - "/swagger-ui.html" + "/swagger-ui.html", + "/api/kyc/pass/**", + "/api/auth/find-id/**" ).permitAll() .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/crawl/**").permitAll() diff --git a/src/main/java/com/joycrew/backend/kyc/KycPassController.java b/src/main/java/com/joycrew/backend/kyc/KycPassController.java new file mode 100644 index 0000000..df28511 --- /dev/null +++ b/src/main/java/com/joycrew/backend/kyc/KycPassController.java @@ -0,0 +1,35 @@ +package com.joycrew.backend.kyc; + +import com.joycrew.backend.kyc.dto.FindIdResult; +import com.joycrew.backend.kyc.dto.NiceStartResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/kyc/pass") +@RequiredArgsConstructor +public class KycPassController { + + private final KycPassService service; + + // 1) 프론트: 아이디찾기 시작 → 표준창 파라미터 수급 + @PostMapping("/start/find-id") + public NiceStartResponse startFindId() { + return service.startFindId(); + } + + // 2) PASS 콜백 (NICE가 호출) + @RequestMapping("/callback") + public FindIdResult callback( + @RequestParam("token_version_id") String tokenVersionId, + @RequestParam("enc_data") String encData, + @RequestParam("integrity_value") String integrityValue, + @RequestParam(value = "req_dtim", required = false) String reqDtim, + @RequestParam(value = "req_no", required = false) String reqNo + ) { + if (reqNo == null || reqDtim == null) { + throw new IllegalStateException("요청정보 누락(req_no/req_dtim)"); + } + return service.handleCallback(tokenVersionId, encData, integrityValue, reqNo, reqDtim); + } +} diff --git a/src/main/java/com/joycrew/backend/kyc/KycPassService.java b/src/main/java/com/joycrew/backend/kyc/KycPassService.java new file mode 100644 index 0000000..a293a89 --- /dev/null +++ b/src/main/java/com/joycrew/backend/kyc/KycPassService.java @@ -0,0 +1,109 @@ +package com.joycrew.backend.kyc; + +import com.joycrew.backend.auth.AccountRecoveryService; +import com.joycrew.backend.config.NicePassProperties; +import com.joycrew.backend.kyc.crypto.NiceCrypto; +import com.joycrew.backend.kyc.dto.FindIdResult; +import com.joycrew.backend.kyc.dto.NiceStartResponse; +import com.joycrew.backend.kyc.store.InMemoryKycStore; +import com.joycrew.backend.util.MaskingUtil; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +@Service +public class KycPassService { + + private final NicePassClient client; + private final NiceCrypto crypto; + private final NicePassProperties props; + private final AccountRecoveryService accountRecoveryService; + private final InMemoryKycStore store; + + private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + private static final DateTimeFormatter BIRTH_FMT = DateTimeFormatter.ofPattern("yyyyMMdd"); + + public KycPassService( + NicePassClient client, + NiceCrypto crypto, + NicePassProperties props, + AccountRecoveryService accountRecoveryService + ) { + this.client = client; + this.crypto = crypto; + this.props = props; + this.accountRecoveryService = accountRecoveryService; + this.store = new InMemoryKycStore(props.getReqTtlSeconds()); + } + + public NiceStartResponse startFindId() { + String reqDtim = LocalDateTime.now().format(TS); + String reqNo = genReqNo(); + + String accessToken = client.getAccessToken(); + Map cryptoToken = client.getCryptoToken(accessToken); + + String tokenVal = (String) cryptoToken.getOrDefault("tokenVal", cryptoToken.get("token_value")); + String tokenVersionId = (String) cryptoToken.getOrDefault("tokenVersionId", props.getTokenVersionId()); + + Map body = Map.of( + "dataHeader", Map.of("CNTY_CD", "ko"), + "dataBody", Map.of( + "req_dtim", reqDtim, + "req_no", reqNo, + "enc_mode", "1", + "return_url", props.getReturnUrl(), + "product_id", props.getProductId() + ) + ); + + var pack = crypto.encryptRequest(tokenVal, reqDtim, reqNo, body); + store.put(reqNo, tokenVal); + + return NiceStartResponse.builder() + .tokenVersionId(tokenVersionId) + .encData(pack.encData()) // ✅ record 기본 접근자 + .integrityValue(pack.integrityValue()) // ✅ record 기본 접근자 + .requestNo(reqNo) + .standardWindowUrl(props.getStandardWindowUrl()) + .build(); + } + + public FindIdResult handleCallback(String tokenVersionId, String encData, String integrityValue, String reqNo, String reqDtim) { + String tokenVal = store.takeIfFresh(reqNo); + if (tokenVal == null) throw new IllegalStateException("요청번호가 만료되었거나 유효하지 않습니다."); + + Map result = crypto.decryptCallback(tokenVal, reqDtim, reqNo, encData); + + String name = str(result, "name"); + String mobileNo = str(result, "mobile_no"); + String birthStr = str(result, "birthday"); + LocalDate birth = parseBirth(birthStr); + + Optional loginId = accountRecoveryService.findLoginIdByIdentity(name, birth, mobileNo); + + return FindIdResult.builder() + .matched(loginId.isPresent()) + .maskedLoginId(MaskingUtil.maskEmail(loginId.orElse(null))) + .build(); + } + + private static String str(Map m, String k) { + Object v = m.get(k); + return v == null ? null : String.valueOf(v); + } + + private static LocalDate parseBirth(String yyyymmdd) { + if (yyyymmdd == null || yyyymmdd.length() != 8) return null; + return LocalDate.parse(yyyymmdd, BIRTH_FMT); + } + + private String genReqNo() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 30); + } +} diff --git a/src/main/java/com/joycrew/backend/kyc/NicePassClient.java b/src/main/java/com/joycrew/backend/kyc/NicePassClient.java new file mode 100644 index 0000000..5b6a68f --- /dev/null +++ b/src/main/java/com/joycrew/backend/kyc/NicePassClient.java @@ -0,0 +1,70 @@ +package com.joycrew.backend.kyc; + +import com.joycrew.backend.config.NicePassProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class NicePassClient { + + private final RestTemplate restTemplate; + private final NicePassProperties props; + + // 1) 기관 액세스 토큰 발급 (client_credentials) + public String getAccessToken() { + String basic = props.getClientId() + ":" + props.getClientSecret(); + String auth = "Basic " + Base64.getEncoder().encodeToString(basic.getBytes(StandardCharsets.UTF_8)); + + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("grant_type", "client_credentials"); + form.add("scope", "default"); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.set("Authorization", auth); + + HttpEntity> entity = new HttpEntity<>(form, headers); + ResponseEntity res = restTemplate.postForEntity( + props.getBaseUrl() + "/digital/niceid/oauth/oauth/token", + entity, Map.class); + + Map body = res.getBody(); + if (body == null || body.get("access_token") == null) { + throw new IllegalStateException("Failed to get NICE access token"); + } + return String.valueOf(body.get("access_token")); + } + + // 2) 암호화 토큰(crypto token) 요청 (상품/버전에 따라 path/필드 차이 가능) + public Map getCryptoToken(String accessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(accessToken); + + Map payload = Map.of( + "productId", props.getProductId(), + "tokenVersionId", props.getTokenVersionId() + ); + + HttpEntity> entity = new HttpEntity<>(payload, headers); + ResponseEntity res = restTemplate.postForEntity( + props.getBaseUrl() + "/digital/niceid/api/v1.0/common/crypto/token", + entity, Map.class); + + Map body = res.getBody(); + if (body == null) { + throw new IllegalStateException("Failed to get NICE crypto token"); + } + //noinspection unchecked + return (Map) body; + } +} diff --git a/src/main/java/com/joycrew/backend/kyc/crypto/NiceCrypto.java b/src/main/java/com/joycrew/backend/kyc/crypto/NiceCrypto.java new file mode 100644 index 0000000..b938ef4 --- /dev/null +++ b/src/main/java/com/joycrew/backend/kyc/crypto/NiceCrypto.java @@ -0,0 +1,10 @@ +package com.joycrew.backend.kyc.crypto; + +import java.util.Map; + +public interface NiceCrypto { + EncPack encryptRequest(String tokenVal, String reqDtim, String reqNo, Map requestBodyJson); + Map decryptCallback(String tokenVal, String reqDtim, String reqNo, String encData); + + record EncPack(String encData, String integrityValue) {} +} diff --git a/src/main/java/com/joycrew/backend/kyc/crypto/NiceCryptoImpl.java b/src/main/java/com/joycrew/backend/kyc/crypto/NiceCryptoImpl.java new file mode 100644 index 0000000..0f8a8fb --- /dev/null +++ b/src/main/java/com/joycrew/backend/kyc/crypto/NiceCryptoImpl.java @@ -0,0 +1,83 @@ +package com.joycrew.backend.kyc.crypto; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class NiceCryptoImpl implements NiceCrypto { + + private final ObjectMapper objectMapper; + + @Override + public EncPack encryptRequest(String tokenVal, String reqDtim, String reqNo, Map requestBodyJson) { + try { + Keys keys = deriveKeys(tokenVal, reqDtim, reqNo); + + byte[] plain = objectMapper.writeValueAsBytes(requestBodyJson); + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + cipher.init(Cipher.ENCRYPT_MODE, keys.keySpec(), new IvParameterSpec(keys.ivBytes())); + byte[] encrypted = cipher.doFinal(plain); + + String encData = Base64.getEncoder().encodeToString(encrypted); + + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(keys.hmacKeyBytes(), "HmacSHA256")); + byte[] h = mac.doFinal(encData.getBytes(StandardCharsets.UTF_8)); + String integrity = Base64.getEncoder().encodeToString(h); + + return new EncPack(encData, integrity); + } catch (Exception e) { + throw new IllegalStateException("NICE 암호화 실패", e); + } + } + + @Override + public Map decryptCallback(String tokenVal, String reqDtim, String reqNo, String encData) { + try { + Keys keys = deriveKeys(tokenVal, reqDtim, reqNo); + + byte[] cipherBytes = Base64.getDecoder().decode(encData); + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + cipher.init(Cipher.DECRYPT_MODE, keys.keySpec(), new IvParameterSpec(keys.ivBytes())); + byte[] plain = cipher.doFinal(cipherBytes); + + return objectMapper.readValue(plain, Map.class); + } catch (Exception e) { + throw new IllegalStateException("NICE 복호화 실패", e); + } + } + + // ====== 여기에 NICE 제공 샘플코드 '그대로' 붙여넣기 ====== + private Keys deriveKeys(String tokenVal, String reqDtim, String reqNo) throws Exception { + /* + TODO: NICE 가이드의 키 파생 규칙(AES 256 Key, IV, HMAC Key 생성)을 정확히 구현하세요. + - 일반적으로 tokenVal + req_dtim + req_no 등을 조합해 key/iv/hmacKey를 파생 + - 상품/버전마다 세부식 상이 → 공급사 샘플 기준 구현 + */ + throw new UnsupportedOperationException("Implement key derivation as per NICE sample."); + } + // ==================================================== + + private static byte[] sha256(byte[] in) throws Exception { + MessageDigest d = MessageDigest.getInstance("SHA-256"); + return d.digest(in); + } + + // ⚠ record 기본 접근자(key(), iv(), hmacKey())와 이름 겹치지 않도록 변경 + private record Keys(byte[] key, byte[] iv, byte[] hmacKey) { + SecretKeySpec keySpec() { return new SecretKeySpec(key, "AES"); } + byte[] ivBytes() { return iv; } + byte[] hmacKeyBytes() { return hmacKey; } + } +} diff --git a/src/main/java/com/joycrew/backend/kyc/dto/FindIdResult.java b/src/main/java/com/joycrew/backend/kyc/dto/FindIdResult.java new file mode 100644 index 0000000..bc6974f --- /dev/null +++ b/src/main/java/com/joycrew/backend/kyc/dto/FindIdResult.java @@ -0,0 +1,10 @@ +package com.joycrew.backend.kyc.dto; + +import lombok.*; + +@Getter @Setter @Builder +@AllArgsConstructor @NoArgsConstructor +public class FindIdResult { + private boolean matched; + private String maskedLoginId; // 이메일 아이디를 마스킹해 반환 +} diff --git a/src/main/java/com/joycrew/backend/kyc/dto/NiceStartResponse.java b/src/main/java/com/joycrew/backend/kyc/dto/NiceStartResponse.java new file mode 100644 index 0000000..8a3f89d --- /dev/null +++ b/src/main/java/com/joycrew/backend/kyc/dto/NiceStartResponse.java @@ -0,0 +1,13 @@ +package com.joycrew.backend.kyc.dto; + +import lombok.*; + +@Getter @Setter @Builder +@AllArgsConstructor @NoArgsConstructor +public class NiceStartResponse { + private String tokenVersionId; + private String encData; + private String integrityValue; + private String requestNo; + private String standardWindowUrl; +} diff --git a/src/main/java/com/joycrew/backend/kyc/store/InMemoryKycStore.java b/src/main/java/com/joycrew/backend/kyc/store/InMemoryKycStore.java new file mode 100644 index 0000000..de35e27 --- /dev/null +++ b/src/main/java/com/joycrew/backend/kyc/store/InMemoryKycStore.java @@ -0,0 +1,44 @@ +package com.joycrew.backend.kyc.store; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.Instant; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +public class InMemoryKycStore { + @Getter + @AllArgsConstructor + public static class Entry { + private final String tokenVal; + private final Instant createdAt; + } + + private final Map map = new ConcurrentHashMap<>(); + private final long ttlSeconds; + + public InMemoryKycStore(long ttlSeconds) { + this.ttlSeconds = ttlSeconds; + } + + public void put(String reqNo, String tokenVal) { + Objects.requireNonNull(reqNo); + Objects.requireNonNull(tokenVal); + map.put(reqNo, new Entry(tokenVal, Instant.now())); + } + + public String takeIfFresh(String reqNo) { + Entry e = map.remove(reqNo); + if (e == null) return null; + if (Instant.now().isAfter(e.getCreatedAt().plusSeconds(ttlSeconds))) return null; + return e.getTokenVal(); + } + + // 청소용 (선택) + public void purgeExpired() { + Instant now = Instant.now(); + map.entrySet().removeIf(en -> now.isAfter(en.getValue().getCreatedAt().plusSeconds(ttlSeconds))); + } +} diff --git a/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java b/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java index 69b22b1..2006b5e 100644 --- a/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java +++ b/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java @@ -29,4 +29,6 @@ public interface EmployeeRepository extends JpaRepository { WHERE e.employeeId = :id """) Optional findByIdWithCompany(@Param("id") Long id); + + Optional findByPhoneNumber(String phoneNumber); } diff --git a/src/main/java/com/joycrew/backend/service/EmployeeService.java b/src/main/java/com/joycrew/backend/service/EmployeeService.java index bd2cd60..c9d8395 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeService.java @@ -27,6 +27,11 @@ public class EmployeeService { private final EmployeeMapper employeeMapper; private final S3FileStorageService s3FileStorageService; + private Employee getEmployeeOrThrow(Long employeeId) { + return employeeRepository.findById(employeeId) + .orElseThrow(() -> new UserNotFoundException("Employee not found: " + employeeId)); + } + @Transactional(readOnly = true) public UserProfileResponse getUserProfile(String userEmail) { Employee employee = employeeRepository.findByEmail(userEmail) diff --git a/src/main/java/com/joycrew/backend/util/MaskingUtil.java b/src/main/java/com/joycrew/backend/util/MaskingUtil.java new file mode 100644 index 0000000..ea78e06 --- /dev/null +++ b/src/main/java/com/joycrew/backend/util/MaskingUtil.java @@ -0,0 +1,14 @@ +package com.joycrew.backend.util; + +public final class MaskingUtil { + private MaskingUtil() {} + + public static String maskEmail(String email) { + if (email == null) return null; + int at = email.indexOf('@'); + if (at <= 0) return email; + String id = email.substring(0, at); + if (id.length() <= 2) return id.charAt(0) + "*" + email.substring(at); + return id.substring(0, 2) + "***" + email.substring(at - 1, at) + email.substring(at); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a50febc..fc05b49 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -61,3 +61,21 @@ jobs: recent-view-cleanup: enabled: true cron: "0 0 3 * * *" + +nice: + pass: + # NICE 접속/표준창 + base-url: https://svc.niceapi.co.kr:22001 + standard-window-url: https://nice.checkplus.co.kr/CheckPlusSafeModel/service.cb + token-version-id: v2 + + # 콘솔에서 받은 값들 (환경변수 연동 권장) + client-id: ${NICE_CLIENT_ID} + client-secret: ${NICE_CLIENT_SECRET} + product-id: ${NICE_PRODUCT_ID} + + # 인증 완료 콜백 URL(외부에서 접근 가능해야 함) + return-url: https://api.joycrew.co.kr/api/kyc/pass/callback + + # 타임아웃 등 + timeout-ms: 7000 \ No newline at end of file From 032cc577bb7de387858eb0c4721ccd983da84d98 Mon Sep 17 00:00:00 2001 From: yeoEun Date: Thu, 18 Sep 2025 21:54:54 +0900 Subject: [PATCH 099/135] feat : notification --- .../backend/auth/AccountRecoveryService.java | 2 +- .../backend/config/NicePassConfig.java | 1 + .../controller/NotificationController.java | 69 ++++++++++++++ .../backend/dto/NotificationResponse.java | 23 +++++ .../joycrew/backend/entity/Notification.java | 62 +++++++++++++ .../entity/enums/NotificationType.java | 5 + .../backend/event/NotificationListener.java | 64 +++++++++++-- .../joycrew/backend/kyc/KycPassService.java | 1 - .../joycrew/backend/kyc/NicePassClient.java | 1 - .../{config => kyc}/NicePassProperties.java | 2 +- .../repository/NotificationRepository.java | 16 ++++ .../backend/service/NotificationService.java | 93 +++++++++++++++++++ .../service/NotificationSseService.java | 55 +++++++++++ 13 files changed, 381 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/joycrew/backend/controller/NotificationController.java create mode 100644 src/main/java/com/joycrew/backend/dto/NotificationResponse.java create mode 100644 src/main/java/com/joycrew/backend/entity/Notification.java create mode 100644 src/main/java/com/joycrew/backend/entity/enums/NotificationType.java rename src/main/java/com/joycrew/backend/{config => kyc}/NicePassProperties.java (93%) create mode 100644 src/main/java/com/joycrew/backend/repository/NotificationRepository.java create mode 100644 src/main/java/com/joycrew/backend/service/NotificationService.java create mode 100644 src/main/java/com/joycrew/backend/service/NotificationSseService.java diff --git a/src/main/java/com/joycrew/backend/auth/AccountRecoveryService.java b/src/main/java/com/joycrew/backend/auth/AccountRecoveryService.java index ad1aee2..054d44a 100644 --- a/src/main/java/com/joycrew/backend/auth/AccountRecoveryService.java +++ b/src/main/java/com/joycrew/backend/auth/AccountRecoveryService.java @@ -22,7 +22,7 @@ public Optional findLoginIdByIdentity(String name, LocalDate birthday, S // 가장 보편: 휴대폰 번호로 1차 후보 → 이름/생일 2차 검증 Optional byPhone = employeeRepository.findByPhoneNumber(mobileNo); return byPhone - .filter(e -> (e.getName() == null || e.getName().equals(name))) + .filter(e -> (e.getEmployeeName() == null || e.getEmployeeName().equals(name))) .filter(e -> (e.getBirthday() == null || e.getBirthday().equals(birthday))) .map(Employee::getEmail); // 시스템에서 '아이디'가 이메일일 경우 } diff --git a/src/main/java/com/joycrew/backend/config/NicePassConfig.java b/src/main/java/com/joycrew/backend/config/NicePassConfig.java index 9c7384f..2e538cb 100644 --- a/src/main/java/com/joycrew/backend/config/NicePassConfig.java +++ b/src/main/java/com/joycrew/backend/config/NicePassConfig.java @@ -1,5 +1,6 @@ package com.joycrew.backend.config; +import com.joycrew.backend.kyc.NicePassProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/com/joycrew/backend/controller/NotificationController.java b/src/main/java/com/joycrew/backend/controller/NotificationController.java new file mode 100644 index 0000000..ae76ea2 --- /dev/null +++ b/src/main/java/com/joycrew/backend/controller/NotificationController.java @@ -0,0 +1,69 @@ +package com.joycrew.backend.controller; + +import com.joycrew.backend.dto.NotificationResponse; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.security.UserPrincipal; +import com.joycrew.backend.service.NotificationService; +import com.joycrew.backend.service.NotificationSseService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; + +import java.util.List; + +@RestController +@RequestMapping("/api/notifications") +@RequiredArgsConstructor +public class NotificationController { + + private final NotificationService notificationService; + private final NotificationSseService sseService; + private final EmployeeRepository employeeRepository; + + @Operation(summary = "미확인 알림 가져오기", security = @SecurityRequirement(name = "Authorization")) + @GetMapping + public ResponseEntity> getUnreadForToast( + @AuthenticationPrincipal UserPrincipal principal, + @RequestParam(defaultValue = "false") boolean all, + @RequestParam(defaultValue = "72") int sinceHours + ) { + List list = all + ? notificationService.getRecentSince(principal.getUsername(), sinceHours) + : notificationService.getUnreadForToast(principal.getUsername()); + return ResponseEntity.ok(list); + } + + @Operation(summary = "알림 읽음 처리", security = @SecurityRequirement(name = "Authorization")) + @PostMapping("/{id}/read") + public ResponseEntity markRead( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long id + ) { + notificationService.markRead(id, principal.getUsername()); + return ResponseEntity.noContent().build(); + } + + @Operation(summary = "SSE 구독 (실시간 알림 스트림)", security = @SecurityRequirement(name = "Authorization")) + @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter stream(@AuthenticationPrincipal UserPrincipal principal) { + if (principal == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + String email = principal.getUsername(); + + Employee me = employeeRepository.findByEmail(email) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED)); + if (Boolean.FALSE.equals(me.getAppNotificationEnabled())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Notifications disabled"); + } + return sseService.subscribe(email); + } +} diff --git a/src/main/java/com/joycrew/backend/dto/NotificationResponse.java b/src/main/java/com/joycrew/backend/dto/NotificationResponse.java new file mode 100644 index 0000000..1a2a492 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/NotificationResponse.java @@ -0,0 +1,23 @@ +// com.joycrew.backend.dto.NotificationResponse.java +package com.joycrew.backend.dto; + +import com.joycrew.backend.entity.enums.NotificationType; +import lombok.Builder; + +import java.time.LocalDateTime; + +@Builder +public record NotificationResponse( + Long id, + NotificationType type, + String title, + String content, + Integer pointAmount, + boolean read, + LocalDateTime createdAt, + LocalDateTime expiresAt, + Long actorId, + String actorEmail, + String actorName, + Long recipientId +) {} diff --git a/src/main/java/com/joycrew/backend/entity/Notification.java b/src/main/java/com/joycrew/backend/entity/Notification.java new file mode 100644 index 0000000..ee1ffdd --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/Notification.java @@ -0,0 +1,62 @@ +package com.joycrew.backend.entity; + +import com.joycrew.backend.entity.enums.NotificationType; +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "notification", indexes = { + @Index(name = "idx_notification_recipient_created_at", columnList = "recipient_id, created_at DESC"), + @Index(name = "idx_notification_unread", columnList = "recipient_id, is_read") +}) +@Getter @Setter +@NoArgsConstructor @AllArgsConstructor @Builder +public class Notification { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 알림 받는 사람 (선물 수신자) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "recipient_id", nullable = false) + private Employee recipient; + + // 선물 보낸 사람 (선택) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "actor_id") + private Employee actor; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 40) + private NotificationType type; + + @Column(name = "title", length = 100) + private String title; // 예: "동료로부터 포인트 선물!" + + @Lob + @Column(name = "content") + private String content; // 예: "홍길동님이 50P와 메시지를 보냈어요: 수고했어요!" + + @Column(name = "point_amount") + private Integer pointAmount; + + @Column(name = "is_read", nullable = false) + private boolean read; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "expires_at") + private LocalDateTime expiresAt; // 프론트 토스트용 TTL(예: 생성 후 24시간) + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + if (this.expiresAt == null) { + this.expiresAt = this.createdAt.plusHours(24); + } + } + + public void markRead() { this.read = true; } +} diff --git a/src/main/java/com/joycrew/backend/entity/enums/NotificationType.java b/src/main/java/com/joycrew/backend/entity/enums/NotificationType.java new file mode 100644 index 0000000..fe44aab --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/enums/NotificationType.java @@ -0,0 +1,5 @@ +package com.joycrew.backend.entity.enums; + +public enum NotificationType { + GIFT_RECEIVED +} diff --git a/src/main/java/com/joycrew/backend/event/NotificationListener.java b/src/main/java/com/joycrew/backend/event/NotificationListener.java index d1ec29a..1c1c1bd 100644 --- a/src/main/java/com/joycrew/backend/event/NotificationListener.java +++ b/src/main/java/com/joycrew/backend/event/NotificationListener.java @@ -1,5 +1,12 @@ package com.joycrew.backend.event; +import com.joycrew.backend.dto.NotificationResponse; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Notification; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.service.NotificationService; +import com.joycrew.backend.service.NotificationSseService; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; @@ -7,20 +14,59 @@ @Slf4j @Component +@RequiredArgsConstructor public class NotificationListener { + private final NotificationService notificationService; + private final NotificationSseService sseService; + private final EmployeeRepository employeeRepository; + @Async @EventListener public void handleRecognitionEvent(RecognitionEvent event) { - log.info("Recognition event received. Starting asynchronous processing."); try { - // Simulate a delay for notification processing (e.g., sending a push notification). - Thread.sleep(2000); - log.info("User {} gifted {} points to user {}. Message: {}", - event.getSenderId(), event.getPoints(), event.getReceiverId(), event.getMessage()); - } catch (InterruptedException e) { - log.error("Error occurred while processing notification", e); - Thread.currentThread().interrupt(); + Long senderId = event.getSenderId(); + Long receiverId = event.getReceiverId(); + + Employee sender = (senderId != null) ? employeeRepository.findById(senderId).orElse(null) : null; + Employee receiver = (receiverId != null) ? employeeRepository.findById(receiverId).orElse(null) : null; + if (receiver == null) { + log.warn("RecognitionEvent: receiver not found (receiverId={})", receiverId); + return; + } + + // 1) 알림 저장(DB) + Notification saved = notificationService.createGiftNotification( + sender, receiver, event.getPoints(), event.getMessage()); + + // 2) DTO(payload) 구성 (record + @Builder) + NotificationResponse payload = NotificationResponse.builder() + .id(saved.getId()) + .type(saved.getType()) + .title(saved.getTitle()) + .content(saved.getContent()) + .pointAmount(saved.getPointAmount()) + .read(saved.isRead()) + .createdAt(saved.getCreatedAt()) + .expiresAt(saved.getExpiresAt()) + .actorId(saved.getActor() != null ? saved.getActor().getEmployeeId() : null) + .actorEmail(saved.getActor() != null ? saved.getActor().getEmail() : null) + .actorName(saved.getActor() != null ? saved.getActor().getEmployeeName() : null) + .recipientId(saved.getRecipient() != null ? saved.getRecipient().getEmployeeId() : null) + .build(); + + // 3) 수신자 쪽 앱 알림 OFF면 푸시 생략(옵션) + if (Boolean.FALSE.equals(receiver.getAppNotificationEnabled())) { + log.info("Notifications disabled for user={}, skip SSE push", receiver.getEmail()); + return; + } + + // 4) 실시간 SSE 푸시 (채널: 수신자 이메일) + sseService.push(receiver.getEmail(), payload); + log.info("Pushed gift notification to {}", receiver.getEmail()); + + } catch (Exception e) { + log.error("Notification handling failed", e); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/kyc/KycPassService.java b/src/main/java/com/joycrew/backend/kyc/KycPassService.java index a293a89..15084cc 100644 --- a/src/main/java/com/joycrew/backend/kyc/KycPassService.java +++ b/src/main/java/com/joycrew/backend/kyc/KycPassService.java @@ -1,7 +1,6 @@ package com.joycrew.backend.kyc; import com.joycrew.backend.auth.AccountRecoveryService; -import com.joycrew.backend.config.NicePassProperties; import com.joycrew.backend.kyc.crypto.NiceCrypto; import com.joycrew.backend.kyc.dto.FindIdResult; import com.joycrew.backend.kyc.dto.NiceStartResponse; diff --git a/src/main/java/com/joycrew/backend/kyc/NicePassClient.java b/src/main/java/com/joycrew/backend/kyc/NicePassClient.java index 5b6a68f..de07c46 100644 --- a/src/main/java/com/joycrew/backend/kyc/NicePassClient.java +++ b/src/main/java/com/joycrew/backend/kyc/NicePassClient.java @@ -1,6 +1,5 @@ package com.joycrew.backend.kyc; -import com.joycrew.backend.config.NicePassProperties; import lombok.RequiredArgsConstructor; import org.springframework.http.*; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/joycrew/backend/config/NicePassProperties.java b/src/main/java/com/joycrew/backend/kyc/NicePassProperties.java similarity index 93% rename from src/main/java/com/joycrew/backend/config/NicePassProperties.java rename to src/main/java/com/joycrew/backend/kyc/NicePassProperties.java index 30a7f7c..f9bcce4 100644 --- a/src/main/java/com/joycrew/backend/config/NicePassProperties.java +++ b/src/main/java/com/joycrew/backend/kyc/NicePassProperties.java @@ -1,4 +1,4 @@ -package com.joycrew.backend.config; +package com.joycrew.backend.kyc; import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/com/joycrew/backend/repository/NotificationRepository.java b/src/main/java/com/joycrew/backend/repository/NotificationRepository.java new file mode 100644 index 0000000..347da6c --- /dev/null +++ b/src/main/java/com/joycrew/backend/repository/NotificationRepository.java @@ -0,0 +1,16 @@ +package com.joycrew.backend.repository; + +import com.joycrew.backend.entity.Notification; +import com.joycrew.backend.entity.Employee; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.List; + +public interface NotificationRepository extends JpaRepository { + List findTop50ByRecipientAndReadFalseAndExpiresAtAfterOrderByCreatedAtDesc( + Employee recipient, LocalDateTime now); + + List findTop100ByRecipientAndCreatedAtAfterOrderByCreatedAtDesc( + Employee recipient, LocalDateTime after); +} diff --git a/src/main/java/com/joycrew/backend/service/NotificationService.java b/src/main/java/com/joycrew/backend/service/NotificationService.java new file mode 100644 index 0000000..3cf7c23 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/NotificationService.java @@ -0,0 +1,93 @@ +// com.joycrew.backend.service.NotificationService.java +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.NotificationResponse; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Notification; +import com.joycrew.backend.entity.enums.NotificationType; +import com.joycrew.backend.exception.UserNotFoundException; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.repository.NotificationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class NotificationService { + + private final NotificationRepository notificationRepository; + private final EmployeeRepository employeeRepository; + + @Transactional + public Notification createGiftNotification(Employee sender, Employee receiver, int points, String message) { + String actorName = sender != null ? sender.getEmployeeName() : null; + String actorEmail = sender != null ? sender.getEmail() : null; + + String who = actorName != null && !actorName.isBlank() + ? actorName + : (actorEmail != null ? actorEmail : "동료"); + + Notification n = Notification.builder() + .recipient(receiver) + .actor(sender) + .type(NotificationType.GIFT_RECEIVED) + .title("포인트 선물 도착") + .content(who + "님이 " + points + "P를 보냈어요" + + ((message != null && !message.isBlank()) ? (": " + message) : "")) + .pointAmount(points) + .read(false) + .build(); + + return notificationRepository.save(n); + } + + @Transactional(readOnly = true) + public List getUnreadForToast(String userEmail) { + Employee emp = employeeRepository.findByEmail(userEmail) + .orElseThrow(() -> new UserNotFoundException("User not found.")); + return notificationRepository + .findTop50ByRecipientAndReadFalseAndExpiresAtAfterOrderByCreatedAtDesc(emp, LocalDateTime.now()) + .stream().map(this::toDto).toList(); + } + + @Transactional(readOnly = true) + public List getRecentSince(String userEmail, int hours) { + Employee emp = employeeRepository.findByEmail(userEmail) + .orElseThrow(() -> new UserNotFoundException("User not found.")); + return notificationRepository + .findTop100ByRecipientAndCreatedAtAfterOrderByCreatedAtDesc(emp, LocalDateTime.now().minusHours(hours)) + .stream().map(this::toDto).toList(); + } + + @Transactional + public void markRead(Long notifId, String userEmail) { + Employee emp = employeeRepository.findByEmail(userEmail) + .orElseThrow(() -> new UserNotFoundException("User not found.")); + Notification n = notificationRepository.findById(notifId) + .orElseThrow(() -> new IllegalArgumentException("Notification not found.")); + if (!n.getRecipient().getEmployeeId().equals(emp.getEmployeeId())) + throw new IllegalStateException("Forbidden."); + n.markRead(); + } + + private NotificationResponse toDto(Notification n) { + return NotificationResponse.builder() + .id(n.getId()) + .type(n.getType()) + .title(n.getTitle()) + .content(n.getContent()) + .pointAmount(n.getPointAmount()) + .read(n.isRead()) + .createdAt(n.getCreatedAt()) + .expiresAt(n.getExpiresAt()) + .actorId(n.getActor() != null ? n.getActor().getEmployeeId() : null) + .actorEmail(n.getActor() != null ? n.getActor().getEmail() : null) + .actorName(n.getActor() != null ? n.getActor().getEmployeeName() : null) // 👈 이름 사용 + .recipientId(n.getRecipient() != null ? n.getRecipient().getEmployeeId() : null) + .build(); + } +} diff --git a/src/main/java/com/joycrew/backend/service/NotificationSseService.java b/src/main/java/com/joycrew/backend/service/NotificationSseService.java new file mode 100644 index 0000000..64a154a --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/NotificationSseService.java @@ -0,0 +1,55 @@ +// com.joycrew.backend.service.NotificationSseService.java +package com.joycrew.backend.service; + +import jakarta.annotation.PreDestroy; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +@Service +public class NotificationSseService { + + // email을 채널 키로 사용 + private final Map> emitters = new ConcurrentHashMap<>(); + private static final long TIMEOUT_MS = Duration.ofMinutes(30).toMillis(); + + public SseEmitter subscribe(String userEmail) { + SseEmitter emitter = new SseEmitter(TIMEOUT_MS); + emitters.computeIfAbsent(userEmail, k -> new CopyOnWriteArrayList<>()).add(emitter); + + emitter.onCompletion(() -> remove(userEmail, emitter)); + emitter.onTimeout(() -> remove(userEmail, emitter)); + emitter.onError(e -> remove(userEmail, emitter)); + + try { emitter.send(SseEmitter.event().name("connected").data("ok").reconnectTime(3000)); } + catch (IOException ignored) {} + + return emitter; + } + + public void push(String userEmail, Object payload) { + List list = emitters.getOrDefault(userEmail, new CopyOnWriteArrayList<>()); + for (SseEmitter em : list) { + try { + em.send(SseEmitter.event().name("notification").data(payload, MediaType.APPLICATION_JSON)); + } catch (IOException e) { + remove(userEmail, em); + } + } + } + + private void remove(String userEmail, SseEmitter em) { + List list = emitters.get(userEmail); + if (list != null) list.remove(em); + } + + @PreDestroy + public void shutdown() { emitters.clear(); } +} From ea1a7f654149ec50646762724cf570fd5d383756 Mon Sep 17 00:00:00 2001 From: yeoEun Date: Mon, 29 Sep 2025 22:58:09 +0900 Subject: [PATCH 100/135] feat:solapi --- .gitignore | 2 + .../backend/auth/AccountRecoveryService.java | 29 ----- .../backend/config/NicePassConfig.java | 21 ---- .../backend/config/SecurityConfig.java | 6 +- .../com/joycrew/backend/config/SmsConfig.java | 22 ++++ .../controller/AccountLookupController.java | 47 ++++++++ .../backend/controller/KycController.java | 29 +++++ .../dto/kyc/EmailsByPhoneResponse.java | 10 ++ .../backend/dto/kyc/PhoneStartRequest.java | 9 ++ .../backend/dto/kyc/PhoneStartResponse.java | 6 + .../backend/dto/kyc/PhoneVerifyRequest.java | 8 ++ .../backend/dto/kyc/PhoneVerifyResponse.java | 6 + .../backend/entity/PhoneVerification.java | 47 ++++++++ .../backend/kyc/KycPassController.java | 35 ------ .../joycrew/backend/kyc/KycPassService.java | 108 ------------------ .../joycrew/backend/kyc/NicePassClient.java | 69 ----------- .../backend/kyc/NicePassProperties.java | 19 --- .../backend/kyc/crypto/NiceCrypto.java | 10 -- .../backend/kyc/crypto/NiceCryptoImpl.java | 83 -------------- .../joycrew/backend/kyc/dto/FindIdResult.java | 10 -- .../backend/kyc/dto/NiceStartResponse.java | 13 --- .../backend/kyc/store/InMemoryKycStore.java | 44 ------- .../repository/EmployeeRepository.java | 3 +- .../PhoneVerificationRepository.java | 10 ++ .../backend/service/EmployeeService.java | 5 - .../backend/service/KycTokenService.java | 50 ++++++++ .../service/PhoneVerificationService.java | 86 ++++++++++++++ .../backend/service/sms/ConsoleSmsSender.java | 13 +++ .../backend/service/sms/SmsSender.java | 5 + .../backend/service/sms/SolapiProps.java | 15 +++ .../backend/service/sms/SolapiSmsSender.java | 93 +++++++++++++++ .../com/joycrew/backend/util/EmailMasker.java | 13 +++ .../com/joycrew/backend/util/MaskingUtil.java | 14 --- src/main/resources/application.yml | 40 ++++--- .../controller/ProductControllerTest.java | 74 ------------ 35 files changed, 498 insertions(+), 556 deletions(-) delete mode 100644 src/main/java/com/joycrew/backend/auth/AccountRecoveryService.java delete mode 100644 src/main/java/com/joycrew/backend/config/NicePassConfig.java create mode 100644 src/main/java/com/joycrew/backend/config/SmsConfig.java create mode 100644 src/main/java/com/joycrew/backend/controller/AccountLookupController.java create mode 100644 src/main/java/com/joycrew/backend/controller/KycController.java create mode 100644 src/main/java/com/joycrew/backend/dto/kyc/EmailsByPhoneResponse.java create mode 100644 src/main/java/com/joycrew/backend/dto/kyc/PhoneStartRequest.java create mode 100644 src/main/java/com/joycrew/backend/dto/kyc/PhoneStartResponse.java create mode 100644 src/main/java/com/joycrew/backend/dto/kyc/PhoneVerifyRequest.java create mode 100644 src/main/java/com/joycrew/backend/dto/kyc/PhoneVerifyResponse.java create mode 100644 src/main/java/com/joycrew/backend/entity/PhoneVerification.java delete mode 100644 src/main/java/com/joycrew/backend/kyc/KycPassController.java delete mode 100644 src/main/java/com/joycrew/backend/kyc/KycPassService.java delete mode 100644 src/main/java/com/joycrew/backend/kyc/NicePassClient.java delete mode 100644 src/main/java/com/joycrew/backend/kyc/NicePassProperties.java delete mode 100644 src/main/java/com/joycrew/backend/kyc/crypto/NiceCrypto.java delete mode 100644 src/main/java/com/joycrew/backend/kyc/crypto/NiceCryptoImpl.java delete mode 100644 src/main/java/com/joycrew/backend/kyc/dto/FindIdResult.java delete mode 100644 src/main/java/com/joycrew/backend/kyc/dto/NiceStartResponse.java delete mode 100644 src/main/java/com/joycrew/backend/kyc/store/InMemoryKycStore.java create mode 100644 src/main/java/com/joycrew/backend/repository/PhoneVerificationRepository.java create mode 100644 src/main/java/com/joycrew/backend/service/KycTokenService.java create mode 100644 src/main/java/com/joycrew/backend/service/PhoneVerificationService.java create mode 100644 src/main/java/com/joycrew/backend/service/sms/ConsoleSmsSender.java create mode 100644 src/main/java/com/joycrew/backend/service/sms/SmsSender.java create mode 100644 src/main/java/com/joycrew/backend/service/sms/SolapiProps.java create mode 100644 src/main/java/com/joycrew/backend/service/sms/SolapiSmsSender.java create mode 100644 src/main/java/com/joycrew/backend/util/EmailMasker.java delete mode 100644 src/main/java/com/joycrew/backend/util/MaskingUtil.java delete mode 100644 src/test/java/com/joycrew/backend/controller/ProductControllerTest.java diff --git a/.gitignore b/.gitignore index ee9a037..046e293 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ build/ out/ /target/ +# 환경변수 +.env # =================================================================== # IDE-specific files # diff --git a/src/main/java/com/joycrew/backend/auth/AccountRecoveryService.java b/src/main/java/com/joycrew/backend/auth/AccountRecoveryService.java deleted file mode 100644 index 054d44a..0000000 --- a/src/main/java/com/joycrew/backend/auth/AccountRecoveryService.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.joycrew.backend.auth; - -import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.repository.EmployeeRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.time.LocalDate; -import java.util.Optional; - -@Service -@RequiredArgsConstructor -public class AccountRecoveryService { - - private final EmployeeRepository employeeRepository; - - /** - * PASS 복호화 결과에서 받은 신원정보로 로그인 아이디(사내에선 보통 email)를 조회. - * - CI를 쓰고 싶다면 Employee 엔티티에 ci 필드 추가 후 findByCi 사용 권장. - */ - public Optional findLoginIdByIdentity(String name, LocalDate birthday, String mobileNo) { - // 가장 보편: 휴대폰 번호로 1차 후보 → 이름/생일 2차 검증 - Optional byPhone = employeeRepository.findByPhoneNumber(mobileNo); - return byPhone - .filter(e -> (e.getEmployeeName() == null || e.getEmployeeName().equals(name))) - .filter(e -> (e.getBirthday() == null || e.getBirthday().equals(birthday))) - .map(Employee::getEmail); // 시스템에서 '아이디'가 이메일일 경우 - } -} diff --git a/src/main/java/com/joycrew/backend/config/NicePassConfig.java b/src/main/java/com/joycrew/backend/config/NicePassConfig.java deleted file mode 100644 index 2e538cb..0000000 --- a/src/main/java/com/joycrew/backend/config/NicePassConfig.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.joycrew.backend.config; - -import com.joycrew.backend.kyc.NicePassProperties; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.client.SimpleClientHttpRequestFactory; -import org.springframework.web.client.RestTemplate; - -@Configuration -@EnableConfigurationProperties(NicePassProperties.class) -public class NicePassConfig { - - @Bean - public RestTemplate niceRestTemplate(NicePassProperties props) { - SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); - factory.setConnectTimeout(props.getTimeoutMs()); - factory.setReadTimeout(props.getTimeoutMs()); - return new RestTemplate(factory); - } -} diff --git a/src/main/java/com/joycrew/backend/config/SecurityConfig.java b/src/main/java/com/joycrew/backend/config/SecurityConfig.java index 2dbbbb0..7c56e56 100644 --- a/src/main/java/com/joycrew/backend/config/SecurityConfig.java +++ b/src/main/java/com/joycrew/backend/config/SecurityConfig.java @@ -54,8 +54,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", - "/api/kyc/pass/**", - "/api/auth/find-id/**" + "/kyc/phone/**", + "/accounts/emails/by-phone" ).permitAll() .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/crawl/**").permitAll() @@ -104,4 +104,4 @@ public AuthenticationManager authenticationManager(PasswordEncoder passwordEncod authenticationProvider.setPasswordEncoder(passwordEncoder); return new ProviderManager(authenticationProvider); } -} +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/config/SmsConfig.java b/src/main/java/com/joycrew/backend/config/SmsConfig.java new file mode 100644 index 0000000..8c7c624 --- /dev/null +++ b/src/main/java/com/joycrew/backend/config/SmsConfig.java @@ -0,0 +1,22 @@ +package com.joycrew.backend.config; + +import com.joycrew.backend.service.sms.SmsSender; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +@Configuration +public class SmsConfig { + + @Value("${app.sms.provider:console}") + String provider; + + @Bean + @Primary + public SmsSender smsSender(@Qualifier("consoleSmsSender") SmsSender console, + @Qualifier("solapiSmsSender") SmsSender solapi) { + return "solapi".equalsIgnoreCase(provider) ? solapi : console; + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/controller/AccountLookupController.java b/src/main/java/com/joycrew/backend/controller/AccountLookupController.java new file mode 100644 index 0000000..231f541 --- /dev/null +++ b/src/main/java/com/joycrew/backend/controller/AccountLookupController.java @@ -0,0 +1,47 @@ +package com.joycrew.backend.controller; + +import com.joycrew.backend.dto.kyc.EmailsByPhoneResponse; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.service.KycTokenService; +import com.joycrew.backend.util.EmailMasker; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; + +@RestController +@RequestMapping("/accounts/emails") +@RequiredArgsConstructor +public class AccountLookupController { + + private final KycTokenService kycTokenService; + private final EmployeeRepository employeeRepo; + + @GetMapping("/by-phone") + public ResponseEntity emailsByPhone( + @RequestHeader("x-kyc-token") String kycToken) { + + String phone = kycTokenService.validateAndExtractPhone(kycToken); + + // EmployeeRepository.findByPhoneNumber(phone) 가 List 라고 가정 + List emails = employeeRepo.findByPhoneNumber(phone).stream() + .flatMap(e -> Stream.of(e.getEmail(), e.getPersonalEmail())) + .filter(Objects::nonNull) + .map(EmailMasker::mask) + .distinct() + .toList(); + + int count = emails.size(); + String message = (count == 0) ? "등록된 이메일이 없습니다." : null; + + return ResponseEntity.ok(new EmailsByPhoneResponse( + true, + count, + emails, + message + )); + } +} diff --git a/src/main/java/com/joycrew/backend/controller/KycController.java b/src/main/java/com/joycrew/backend/controller/KycController.java new file mode 100644 index 0000000..b13d730 --- /dev/null +++ b/src/main/java/com/joycrew/backend/controller/KycController.java @@ -0,0 +1,29 @@ +package com.joycrew.backend.controller; + +import com.joycrew.backend.dto.kyc.PhoneStartRequest; +import com.joycrew.backend.dto.kyc.PhoneStartResponse; +import com.joycrew.backend.dto.kyc.PhoneVerifyRequest; +import com.joycrew.backend.dto.kyc.PhoneVerifyResponse; +import com.joycrew.backend.service.PhoneVerificationService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/kyc/phone") +@RequiredArgsConstructor +public class KycController { + private final PhoneVerificationService svc; + + @PostMapping("/start") + public PhoneStartResponse start(@RequestBody @Valid PhoneStartRequest req) { + var r = svc.start(req.phone()); + return new PhoneStartResponse(r.requestId(), r.resendAvailableInSec()); + } + + @PostMapping("/verify") + public PhoneVerifyResponse verify(@RequestBody @Valid PhoneVerifyRequest req) { + var r = svc.verify(req.requestId(), req.code()); + return new PhoneVerifyResponse(r.verified(), r.kycToken()); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/kyc/EmailsByPhoneResponse.java b/src/main/java/com/joycrew/backend/dto/kyc/EmailsByPhoneResponse.java new file mode 100644 index 0000000..9abda5d --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/kyc/EmailsByPhoneResponse.java @@ -0,0 +1,10 @@ +package com.joycrew.backend.dto.kyc; + +import java.util.List; + +public record EmailsByPhoneResponse( + boolean success, + int count, + List emails, + String message +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/kyc/PhoneStartRequest.java b/src/main/java/com/joycrew/backend/dto/kyc/PhoneStartRequest.java new file mode 100644 index 0000000..2859916 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/kyc/PhoneStartRequest.java @@ -0,0 +1,9 @@ +package com.joycrew.backend.dto.kyc; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record PhoneStartRequest( + @NotBlank + @Pattern(regexp = "^[0-9]{10,11}$") String phone +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/kyc/PhoneStartResponse.java b/src/main/java/com/joycrew/backend/dto/kyc/PhoneStartResponse.java new file mode 100644 index 0000000..7f76659 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/kyc/PhoneStartResponse.java @@ -0,0 +1,6 @@ +package com.joycrew.backend.dto.kyc; + +public record PhoneStartResponse( + String requestId, + int resendAvailableInSec +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/kyc/PhoneVerifyRequest.java b/src/main/java/com/joycrew/backend/dto/kyc/PhoneVerifyRequest.java new file mode 100644 index 0000000..0104dd2 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/kyc/PhoneVerifyRequest.java @@ -0,0 +1,8 @@ +package com.joycrew.backend.dto.kyc; + +import jakarta.validation.constraints.NotBlank; + +public record PhoneVerifyRequest( + @NotBlank String requestId, + @NotBlank String code +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/kyc/PhoneVerifyResponse.java b/src/main/java/com/joycrew/backend/dto/kyc/PhoneVerifyResponse.java new file mode 100644 index 0000000..9726e2f --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/kyc/PhoneVerifyResponse.java @@ -0,0 +1,6 @@ +package com.joycrew.backend.dto.kyc; + +public record PhoneVerifyResponse( + boolean verified, + String kycToken +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/PhoneVerification.java b/src/main/java/com/joycrew/backend/entity/PhoneVerification.java new file mode 100644 index 0000000..f919267 --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/PhoneVerification.java @@ -0,0 +1,47 @@ +package com.joycrew.backend.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "phone_verification", + indexes = { + @Index(name = "idx_pv_phone_created", columnList = "phone, createdAt") + }) +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class PhoneVerification { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 20) + private String phone; + + @Column(nullable = false, length = 100) + private String codeHash; // BCrypt hash + + @Column(nullable = false) + private LocalDateTime expiresAt; + + @Column(nullable = false) + private int attempts; + + @Column(nullable = false) + private int maxAttempts; + + @Column(nullable = false) + private LocalDateTime createdAt; + + private LocalDateTime lastSentAt; + + @Column(nullable = false, unique = true, length = 36) + private String requestId; // UUID 문자열 + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private Status status; + + public enum Status { PENDING, VERIFIED, EXPIRED, BLOCKED } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/kyc/KycPassController.java b/src/main/java/com/joycrew/backend/kyc/KycPassController.java deleted file mode 100644 index df28511..0000000 --- a/src/main/java/com/joycrew/backend/kyc/KycPassController.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.joycrew.backend.kyc; - -import com.joycrew.backend.kyc.dto.FindIdResult; -import com.joycrew.backend.kyc.dto.NiceStartResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/api/kyc/pass") -@RequiredArgsConstructor -public class KycPassController { - - private final KycPassService service; - - // 1) 프론트: 아이디찾기 시작 → 표준창 파라미터 수급 - @PostMapping("/start/find-id") - public NiceStartResponse startFindId() { - return service.startFindId(); - } - - // 2) PASS 콜백 (NICE가 호출) - @RequestMapping("/callback") - public FindIdResult callback( - @RequestParam("token_version_id") String tokenVersionId, - @RequestParam("enc_data") String encData, - @RequestParam("integrity_value") String integrityValue, - @RequestParam(value = "req_dtim", required = false) String reqDtim, - @RequestParam(value = "req_no", required = false) String reqNo - ) { - if (reqNo == null || reqDtim == null) { - throw new IllegalStateException("요청정보 누락(req_no/req_dtim)"); - } - return service.handleCallback(tokenVersionId, encData, integrityValue, reqNo, reqDtim); - } -} diff --git a/src/main/java/com/joycrew/backend/kyc/KycPassService.java b/src/main/java/com/joycrew/backend/kyc/KycPassService.java deleted file mode 100644 index 15084cc..0000000 --- a/src/main/java/com/joycrew/backend/kyc/KycPassService.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.joycrew.backend.kyc; - -import com.joycrew.backend.auth.AccountRecoveryService; -import com.joycrew.backend.kyc.crypto.NiceCrypto; -import com.joycrew.backend.kyc.dto.FindIdResult; -import com.joycrew.backend.kyc.dto.NiceStartResponse; -import com.joycrew.backend.kyc.store.InMemoryKycStore; -import com.joycrew.backend.util.MaskingUtil; -import org.springframework.stereotype.Service; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; - -@Service -public class KycPassService { - - private final NicePassClient client; - private final NiceCrypto crypto; - private final NicePassProperties props; - private final AccountRecoveryService accountRecoveryService; - private final InMemoryKycStore store; - - private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); - private static final DateTimeFormatter BIRTH_FMT = DateTimeFormatter.ofPattern("yyyyMMdd"); - - public KycPassService( - NicePassClient client, - NiceCrypto crypto, - NicePassProperties props, - AccountRecoveryService accountRecoveryService - ) { - this.client = client; - this.crypto = crypto; - this.props = props; - this.accountRecoveryService = accountRecoveryService; - this.store = new InMemoryKycStore(props.getReqTtlSeconds()); - } - - public NiceStartResponse startFindId() { - String reqDtim = LocalDateTime.now().format(TS); - String reqNo = genReqNo(); - - String accessToken = client.getAccessToken(); - Map cryptoToken = client.getCryptoToken(accessToken); - - String tokenVal = (String) cryptoToken.getOrDefault("tokenVal", cryptoToken.get("token_value")); - String tokenVersionId = (String) cryptoToken.getOrDefault("tokenVersionId", props.getTokenVersionId()); - - Map body = Map.of( - "dataHeader", Map.of("CNTY_CD", "ko"), - "dataBody", Map.of( - "req_dtim", reqDtim, - "req_no", reqNo, - "enc_mode", "1", - "return_url", props.getReturnUrl(), - "product_id", props.getProductId() - ) - ); - - var pack = crypto.encryptRequest(tokenVal, reqDtim, reqNo, body); - store.put(reqNo, tokenVal); - - return NiceStartResponse.builder() - .tokenVersionId(tokenVersionId) - .encData(pack.encData()) // ✅ record 기본 접근자 - .integrityValue(pack.integrityValue()) // ✅ record 기본 접근자 - .requestNo(reqNo) - .standardWindowUrl(props.getStandardWindowUrl()) - .build(); - } - - public FindIdResult handleCallback(String tokenVersionId, String encData, String integrityValue, String reqNo, String reqDtim) { - String tokenVal = store.takeIfFresh(reqNo); - if (tokenVal == null) throw new IllegalStateException("요청번호가 만료되었거나 유효하지 않습니다."); - - Map result = crypto.decryptCallback(tokenVal, reqDtim, reqNo, encData); - - String name = str(result, "name"); - String mobileNo = str(result, "mobile_no"); - String birthStr = str(result, "birthday"); - LocalDate birth = parseBirth(birthStr); - - Optional loginId = accountRecoveryService.findLoginIdByIdentity(name, birth, mobileNo); - - return FindIdResult.builder() - .matched(loginId.isPresent()) - .maskedLoginId(MaskingUtil.maskEmail(loginId.orElse(null))) - .build(); - } - - private static String str(Map m, String k) { - Object v = m.get(k); - return v == null ? null : String.valueOf(v); - } - - private static LocalDate parseBirth(String yyyymmdd) { - if (yyyymmdd == null || yyyymmdd.length() != 8) return null; - return LocalDate.parse(yyyymmdd, BIRTH_FMT); - } - - private String genReqNo() { - return UUID.randomUUID().toString().replace("-", "").substring(0, 30); - } -} diff --git a/src/main/java/com/joycrew/backend/kyc/NicePassClient.java b/src/main/java/com/joycrew/backend/kyc/NicePassClient.java deleted file mode 100644 index de07c46..0000000 --- a/src/main/java/com/joycrew/backend/kyc/NicePassClient.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.joycrew.backend.kyc; - -import lombok.RequiredArgsConstructor; -import org.springframework.http.*; -import org.springframework.stereotype.Component; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestTemplate; - -import java.nio.charset.StandardCharsets; -import java.util.Base64; -import java.util.Map; - -@Component -@RequiredArgsConstructor -public class NicePassClient { - - private final RestTemplate restTemplate; - private final NicePassProperties props; - - // 1) 기관 액세스 토큰 발급 (client_credentials) - public String getAccessToken() { - String basic = props.getClientId() + ":" + props.getClientSecret(); - String auth = "Basic " + Base64.getEncoder().encodeToString(basic.getBytes(StandardCharsets.UTF_8)); - - MultiValueMap form = new LinkedMultiValueMap<>(); - form.add("grant_type", "client_credentials"); - form.add("scope", "default"); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - headers.set("Authorization", auth); - - HttpEntity> entity = new HttpEntity<>(form, headers); - ResponseEntity res = restTemplate.postForEntity( - props.getBaseUrl() + "/digital/niceid/oauth/oauth/token", - entity, Map.class); - - Map body = res.getBody(); - if (body == null || body.get("access_token") == null) { - throw new IllegalStateException("Failed to get NICE access token"); - } - return String.valueOf(body.get("access_token")); - } - - // 2) 암호화 토큰(crypto token) 요청 (상품/버전에 따라 path/필드 차이 가능) - public Map getCryptoToken(String accessToken) { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.setBearerAuth(accessToken); - - Map payload = Map.of( - "productId", props.getProductId(), - "tokenVersionId", props.getTokenVersionId() - ); - - HttpEntity> entity = new HttpEntity<>(payload, headers); - ResponseEntity res = restTemplate.postForEntity( - props.getBaseUrl() + "/digital/niceid/api/v1.0/common/crypto/token", - entity, Map.class); - - Map body = res.getBody(); - if (body == null) { - throw new IllegalStateException("Failed to get NICE crypto token"); - } - //noinspection unchecked - return (Map) body; - } -} diff --git a/src/main/java/com/joycrew/backend/kyc/NicePassProperties.java b/src/main/java/com/joycrew/backend/kyc/NicePassProperties.java deleted file mode 100644 index f9bcce4..0000000 --- a/src/main/java/com/joycrew/backend/kyc/NicePassProperties.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.joycrew.backend.kyc; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; - -@Getter @Setter -@ConfigurationProperties(prefix = "nice.pass") -public class NicePassProperties { - private String baseUrl; - private String standardWindowUrl; - private String tokenVersionId; - private String clientId; - private String clientSecret; - private String productId; - private String returnUrl; - private int timeoutMs = 7000; - private int reqTtlSeconds = 600; -} diff --git a/src/main/java/com/joycrew/backend/kyc/crypto/NiceCrypto.java b/src/main/java/com/joycrew/backend/kyc/crypto/NiceCrypto.java deleted file mode 100644 index b938ef4..0000000 --- a/src/main/java/com/joycrew/backend/kyc/crypto/NiceCrypto.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.joycrew.backend.kyc.crypto; - -import java.util.Map; - -public interface NiceCrypto { - EncPack encryptRequest(String tokenVal, String reqDtim, String reqNo, Map requestBodyJson); - Map decryptCallback(String tokenVal, String reqDtim, String reqNo, String encData); - - record EncPack(String encData, String integrityValue) {} -} diff --git a/src/main/java/com/joycrew/backend/kyc/crypto/NiceCryptoImpl.java b/src/main/java/com/joycrew/backend/kyc/crypto/NiceCryptoImpl.java deleted file mode 100644 index 0f8a8fb..0000000 --- a/src/main/java/com/joycrew/backend/kyc/crypto/NiceCryptoImpl.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.joycrew.backend.kyc.crypto; - -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import javax.crypto.Cipher; -import javax.crypto.Mac; -import javax.crypto.spec.IvParameterSpec; -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.Base64; -import java.util.Map; - -@Component -@RequiredArgsConstructor -public class NiceCryptoImpl implements NiceCrypto { - - private final ObjectMapper objectMapper; - - @Override - public EncPack encryptRequest(String tokenVal, String reqDtim, String reqNo, Map requestBodyJson) { - try { - Keys keys = deriveKeys(tokenVal, reqDtim, reqNo); - - byte[] plain = objectMapper.writeValueAsBytes(requestBodyJson); - Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); - cipher.init(Cipher.ENCRYPT_MODE, keys.keySpec(), new IvParameterSpec(keys.ivBytes())); - byte[] encrypted = cipher.doFinal(plain); - - String encData = Base64.getEncoder().encodeToString(encrypted); - - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(keys.hmacKeyBytes(), "HmacSHA256")); - byte[] h = mac.doFinal(encData.getBytes(StandardCharsets.UTF_8)); - String integrity = Base64.getEncoder().encodeToString(h); - - return new EncPack(encData, integrity); - } catch (Exception e) { - throw new IllegalStateException("NICE 암호화 실패", e); - } - } - - @Override - public Map decryptCallback(String tokenVal, String reqDtim, String reqNo, String encData) { - try { - Keys keys = deriveKeys(tokenVal, reqDtim, reqNo); - - byte[] cipherBytes = Base64.getDecoder().decode(encData); - Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); - cipher.init(Cipher.DECRYPT_MODE, keys.keySpec(), new IvParameterSpec(keys.ivBytes())); - byte[] plain = cipher.doFinal(cipherBytes); - - return objectMapper.readValue(plain, Map.class); - } catch (Exception e) { - throw new IllegalStateException("NICE 복호화 실패", e); - } - } - - // ====== 여기에 NICE 제공 샘플코드 '그대로' 붙여넣기 ====== - private Keys deriveKeys(String tokenVal, String reqDtim, String reqNo) throws Exception { - /* - TODO: NICE 가이드의 키 파생 규칙(AES 256 Key, IV, HMAC Key 생성)을 정확히 구현하세요. - - 일반적으로 tokenVal + req_dtim + req_no 등을 조합해 key/iv/hmacKey를 파생 - - 상품/버전마다 세부식 상이 → 공급사 샘플 기준 구현 - */ - throw new UnsupportedOperationException("Implement key derivation as per NICE sample."); - } - // ==================================================== - - private static byte[] sha256(byte[] in) throws Exception { - MessageDigest d = MessageDigest.getInstance("SHA-256"); - return d.digest(in); - } - - // ⚠ record 기본 접근자(key(), iv(), hmacKey())와 이름 겹치지 않도록 변경 - private record Keys(byte[] key, byte[] iv, byte[] hmacKey) { - SecretKeySpec keySpec() { return new SecretKeySpec(key, "AES"); } - byte[] ivBytes() { return iv; } - byte[] hmacKeyBytes() { return hmacKey; } - } -} diff --git a/src/main/java/com/joycrew/backend/kyc/dto/FindIdResult.java b/src/main/java/com/joycrew/backend/kyc/dto/FindIdResult.java deleted file mode 100644 index bc6974f..0000000 --- a/src/main/java/com/joycrew/backend/kyc/dto/FindIdResult.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.joycrew.backend.kyc.dto; - -import lombok.*; - -@Getter @Setter @Builder -@AllArgsConstructor @NoArgsConstructor -public class FindIdResult { - private boolean matched; - private String maskedLoginId; // 이메일 아이디를 마스킹해 반환 -} diff --git a/src/main/java/com/joycrew/backend/kyc/dto/NiceStartResponse.java b/src/main/java/com/joycrew/backend/kyc/dto/NiceStartResponse.java deleted file mode 100644 index 8a3f89d..0000000 --- a/src/main/java/com/joycrew/backend/kyc/dto/NiceStartResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.joycrew.backend.kyc.dto; - -import lombok.*; - -@Getter @Setter @Builder -@AllArgsConstructor @NoArgsConstructor -public class NiceStartResponse { - private String tokenVersionId; - private String encData; - private String integrityValue; - private String requestNo; - private String standardWindowUrl; -} diff --git a/src/main/java/com/joycrew/backend/kyc/store/InMemoryKycStore.java b/src/main/java/com/joycrew/backend/kyc/store/InMemoryKycStore.java deleted file mode 100644 index de35e27..0000000 --- a/src/main/java/com/joycrew/backend/kyc/store/InMemoryKycStore.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.joycrew.backend.kyc.store; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -import java.time.Instant; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.ConcurrentHashMap; - -public class InMemoryKycStore { - @Getter - @AllArgsConstructor - public static class Entry { - private final String tokenVal; - private final Instant createdAt; - } - - private final Map map = new ConcurrentHashMap<>(); - private final long ttlSeconds; - - public InMemoryKycStore(long ttlSeconds) { - this.ttlSeconds = ttlSeconds; - } - - public void put(String reqNo, String tokenVal) { - Objects.requireNonNull(reqNo); - Objects.requireNonNull(tokenVal); - map.put(reqNo, new Entry(tokenVal, Instant.now())); - } - - public String takeIfFresh(String reqNo) { - Entry e = map.remove(reqNo); - if (e == null) return null; - if (Instant.now().isAfter(e.getCreatedAt().plusSeconds(ttlSeconds))) return null; - return e.getTokenVal(); - } - - // 청소용 (선택) - public void purgeExpired() { - Instant now = Instant.now(); - map.entrySet().removeIf(en -> now.isAfter(en.getValue().getCreatedAt().plusSeconds(ttlSeconds))); - } -} diff --git a/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java b/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java index 2006b5e..e668e1b 100644 --- a/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java +++ b/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java @@ -7,6 +7,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; public interface EmployeeRepository extends JpaRepository { @@ -30,5 +31,5 @@ public interface EmployeeRepository extends JpaRepository { """) Optional findByIdWithCompany(@Param("id") Long id); - Optional findByPhoneNumber(String phoneNumber); + List findByPhoneNumber(String phoneNumber); } diff --git a/src/main/java/com/joycrew/backend/repository/PhoneVerificationRepository.java b/src/main/java/com/joycrew/backend/repository/PhoneVerificationRepository.java new file mode 100644 index 0000000..2b3441f --- /dev/null +++ b/src/main/java/com/joycrew/backend/repository/PhoneVerificationRepository.java @@ -0,0 +1,10 @@ +package com.joycrew.backend.repository; + +import com.joycrew.backend.entity.PhoneVerification; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface PhoneVerificationRepository extends JpaRepository { + Optional findByRequestId(String requestId); +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/EmployeeService.java b/src/main/java/com/joycrew/backend/service/EmployeeService.java index c9d8395..bd2cd60 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeService.java @@ -27,11 +27,6 @@ public class EmployeeService { private final EmployeeMapper employeeMapper; private final S3FileStorageService s3FileStorageService; - private Employee getEmployeeOrThrow(Long employeeId) { - return employeeRepository.findById(employeeId) - .orElseThrow(() -> new UserNotFoundException("Employee not found: " + employeeId)); - } - @Transactional(readOnly = true) public UserProfileResponse getUserProfile(String userEmail) { Employee employee = employeeRepository.findByEmail(userEmail) diff --git a/src/main/java/com/joycrew/backend/service/KycTokenService.java b/src/main/java/com/joycrew/backend/service/KycTokenService.java new file mode 100644 index 0000000..b97a711 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/KycTokenService.java @@ -0,0 +1,50 @@ +package com.joycrew.backend.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; + +@Service +@RequiredArgsConstructor +public class KycTokenService { + + @Value("${app.kyc.token-secret}") private String secret; + @Value("${app.kyc.token-ttl-minutes:10}") private int ttlMin; + + public String create(String phone) { + long now = Instant.now().getEpochSecond(); + long exp = now + ttlMin * 60L; + String payload = phone + "|" + now + "|" + exp; + String sig = hmac(payload); + return Base64.getUrlEncoder().withoutPadding() + .encodeToString((payload + "|" + sig).getBytes(StandardCharsets.UTF_8)); + } + + public String validateAndExtractPhone(String token) { + var raw = new String(Base64.getUrlDecoder().decode(token), StandardCharsets.UTF_8); + String[] p = raw.split("\\|"); + if (p.length != 4) throw new IllegalArgumentException("bad token"); + String phone = p[0]; + long exp = Long.parseLong(p[2]); + String sig = p[3]; + String expected = hmac(p[0] + "|" + p[1] + "|" + p[2]); + if (!expected.equals(sig)) throw new IllegalArgumentException("invalid signature"); + if (Instant.now().getEpochSecond() > exp) throw new IllegalArgumentException("expired"); + return phone; + } + + private String hmac(String msg) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return Base64.getUrlEncoder().withoutPadding().encodeToString( + mac.doFinal(msg.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { throw new RuntimeException(e); } + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/PhoneVerificationService.java b/src/main/java/com/joycrew/backend/service/PhoneVerificationService.java new file mode 100644 index 0000000..6339904 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/PhoneVerificationService.java @@ -0,0 +1,86 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.entity.PhoneVerification; +import com.joycrew.backend.repository.PhoneVerificationRepository; +import com.joycrew.backend.service.sms.SmsSender; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.bcrypt.BCrypt; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.Random; +import java.util.UUID; + +import static org.springframework.http.HttpStatus.*; + +@Service +@RequiredArgsConstructor +public class PhoneVerificationService { + + private final PhoneVerificationRepository repo; + private final @Qualifier("smsSender") SmsSender sms; + private final KycTokenService kycTokenService; + + @Value("${otp.ttl-minutes:5}") private int ttlMin; + @Value("${otp.max-attempts:5}") private int maxAttempts; + @Value("${otp.resend-cooldown-seconds:30}") private int cooldownSec; + + private final Random random = new Random(); + + public StartResult start(String phone) { + String code = String.format("%06d", random.nextInt(1_000_000)); + String hash = BCrypt.hashpw(code, BCrypt.gensalt()); + var now = LocalDateTime.now(); + var pv = PhoneVerification.builder() + .phone(phone).codeHash(hash) + .expiresAt(now.plusMinutes(ttlMin)) + .attempts(0).maxAttempts(maxAttempts) + .createdAt(now).lastSentAt(now) + .requestId(UUID.randomUUID().toString()) + .status(PhoneVerification.Status.PENDING) + .build(); + repo.save(pv); + + sms.send(phone, "[JoyCrew] 인증번호: " + code + " (유효 " + ttlMin + "분)"); + return new StartResult(pv.getRequestId(), cooldownSec); + } + + public VerifyResult verify(String requestId, String code) { + var pv = repo.findByRequestId(requestId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "request not found")); + + if (pv.getStatus() != PhoneVerification.Status.PENDING) + throw new ResponseStatusException(BAD_REQUEST, "already used"); + + var now = LocalDateTime.now(); + if (now.isAfter(pv.getExpiresAt())) { + pv.setStatus(PhoneVerification.Status.EXPIRED); + repo.save(pv); + throw new ResponseStatusException(BAD_REQUEST, "expired"); + } + if (pv.getAttempts() >= pv.getMaxAttempts()) { + pv.setStatus(PhoneVerification.Status.BLOCKED); + repo.save(pv); + throw new ResponseStatusException(TOO_MANY_REQUESTS, "too many attempts"); + } + + pv.setAttempts(pv.getAttempts() + 1); + boolean ok = BCrypt.checkpw(code, pv.getCodeHash()); + if (!ok) { + repo.save(pv); + throw new ResponseStatusException(UNAUTHORIZED, "invalid code"); + } + + pv.setStatus(PhoneVerification.Status.VERIFIED); + repo.save(pv); + + String token = kycTokenService.create(pv.getPhone()); + return new VerifyResult(true, token); + } + + public record StartResult(String requestId, int resendAvailableInSec) {} + public record VerifyResult(boolean verified, String kycToken) {} +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/sms/ConsoleSmsSender.java b/src/main/java/com/joycrew/backend/service/sms/ConsoleSmsSender.java new file mode 100644 index 0000000..3f44616 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/sms/ConsoleSmsSender.java @@ -0,0 +1,13 @@ +package com.joycrew.backend.service.sms; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component("consoleSmsSender") +public class ConsoleSmsSender implements SmsSender { + @Override + public void send(String toPhone, String message) { + log.info("[SMS:CONSOLE] to={}, body={}", toPhone, message); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/sms/SmsSender.java b/src/main/java/com/joycrew/backend/service/sms/SmsSender.java new file mode 100644 index 0000000..6a90d7b --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/sms/SmsSender.java @@ -0,0 +1,5 @@ +package com.joycrew.backend.service.sms; + +public interface SmsSender { + void send(String toPhone, String message); +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/sms/SolapiProps.java b/src/main/java/com/joycrew/backend/service/sms/SolapiProps.java new file mode 100644 index 0000000..96e4528 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/sms/SolapiProps.java @@ -0,0 +1,15 @@ +package com.joycrew.backend.service.sms; + +import lombok.Getter; import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter @Setter +@Component +@ConfigurationProperties("solapi") +public class SolapiProps { + private String apiKey; + private String apiSecret; + private String baseUrl; + private String fromNumber; // yml에선 app.sms.from-number, 주입은 아래 Config에서 +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/sms/SolapiSmsSender.java b/src/main/java/com/joycrew/backend/service/sms/SolapiSmsSender.java new file mode 100644 index 0000000..108c32b --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/sms/SolapiSmsSender.java @@ -0,0 +1,93 @@ +package com.joycrew.backend.service.sms; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import jakarta.annotation.PostConstruct; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.*; + +@Slf4j +@Component("solapiSmsSender") +@RequiredArgsConstructor +public class SolapiSmsSender implements SmsSender { + + private final SolapiProps props; // apiKey, apiSecret, baseUrl 바인딩되어 있다고 가정 + private final RestClient client = RestClient.create(); + + // app.sms.from-number 에서 바인딩 받는 버전이라면 그대로 사용 + @org.springframework.beans.factory.annotation.Value("${app.sms.from-number:}") + private String fromNumber; + + @PostConstruct + void check() { + log.info("[SMS CONFIG] solapi baseUrl={}, hasKey={}, hasSecret={}, from={}", + props.getBaseUrl(), props.getApiKey()!=null, props.getApiSecret()!=null, fromNumber); + } + + @Override + public void send(String toPhone, String message) { + // ---- 사전 검증 ---- + if (isBlank(fromNumber)) throw new IllegalStateException("app.sms.from-number is missing. Use digits only."); + if (isBlank(props.getApiKey()) || isBlank(props.getApiSecret())) throw new IllegalStateException("solapi api-key/secret missing."); + if (isBlank(props.getBaseUrl())) throw new IllegalStateException("solapi base-url missing (e.g., https://api.solapi.com)."); + + // ---- 값 정리 ---- + String to = toPhone.replaceAll("\\D", ""); + String from = fromNumber.replaceAll("\\D", ""); + + // ---- Authorization: HMAC-SHA256 ... 생성 ---- + String dateTime = Instant.now().toString(); // ISO8601 UTC (예: 2025-09-11T13:45:00Z) + String salt = UUID.randomUUID().toString().replace("-", ""); // 12~64자 임의 문자열 + String signature = hmacSha256Hex(props.getApiSecret(), dateTime + salt); + String authHeader = "HMAC-SHA256 apiKey=%s, date=%s, salt=%s, signature=%s" + .formatted(props.getApiKey(), dateTime, salt, signature); + + // ---- Body: send-many/detail 스펙에 맞춤 ---- + Map msg = new HashMap<>(); + msg.put("to", to); + msg.put("from", from); + msg.put("text", message); + + Map body = new HashMap<>(); + body.put("messages", List.of(msg)); + + String url = props.getBaseUrl() + "/messages/v4/send-many/detail"; + + try { + var res = client.post() + .uri(url) + .header("Authorization", authHeader) // << HMAC 인증 + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .body(Map.class); + + log.info("[SOLAPI] sent to={}, res={}", to, res); + } catch (org.springframework.web.client.RestClientResponseException e) { + log.error("[SOLAPI] {} {} body={}", e.getRawStatusCode(), e.getStatusText(), e.getResponseBodyAsString()); + throw e; + } catch (Exception e) { + log.error("[SOLAPI] send failed", e); + throw e; + } + } + + private static String hmacSha256Hex(String secret, String data) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(raw); + } catch (Exception e) { + throw new IllegalStateException("Failed to create HMAC-SHA256 signature", e); + } + } + private static boolean isBlank(String s) { return s == null || s.trim().isEmpty(); } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/util/EmailMasker.java b/src/main/java/com/joycrew/backend/util/EmailMasker.java new file mode 100644 index 0000000..c847457 --- /dev/null +++ b/src/main/java/com/joycrew/backend/util/EmailMasker.java @@ -0,0 +1,13 @@ +package com.joycrew.backend.util; + +public class EmailMasker { + public static String mask(String email) { + if (email == null || email.isBlank()) return null; + int at = email.indexOf('@'); + if (at <= 1) return "*".repeat(Math.max(1, at)) + email.substring(Math.max(at, 0)); + String name = email.substring(0, at); + String domain = email.substring(at); + String masked = name.charAt(0) + "***" + name.charAt(name.length()-1); + return masked + domain; + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/util/MaskingUtil.java b/src/main/java/com/joycrew/backend/util/MaskingUtil.java deleted file mode 100644 index ea78e06..0000000 --- a/src/main/java/com/joycrew/backend/util/MaskingUtil.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.joycrew.backend.util; - -public final class MaskingUtil { - private MaskingUtil() {} - - public static String maskEmail(String email) { - if (email == null) return null; - int at = email.indexOf('@'); - if (at <= 0) return email; - String id = email.substring(0, at); - if (id.length() <= 2) return id.charAt(0) + "*" + email.substring(at); - return id.substring(0, 2) + "***" + email.substring(at - 1, at) + email.substring(at); - } -} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fc05b49..7ab6cc5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -50,6 +50,28 @@ app: # Default frontend URL, primarily for the 'dev' environment. frontend-url: http://localhost:3000 + # KYC / SMS verification (defaults for all profiles) + sms: + provider: solapi + from-number: "01044907174" + + kyc: + # KYC 토큰 서명키(32바이트+ 권장). 환경변수로 주입 가능. + token-secret: ${KYC_TOKEN_SECRET:CHANGE_ME_TO_LONG_RANDOM_SECRET} + # KYC 토큰 유효시간(분) + token-ttl-minutes: 10 + +solapi: + api-key: ${SOLAPI_API_KEY} + api-secret: ${SOLAPI_API_SECRET} + base-url: "https://api.solapi.com" + +# OTP policy (코드 유효/재전송/시도 제한) +otp: + ttl-minutes: 5 # OTP 유효시간(분) + max-attempts: 5 # 최대 시도 횟수 + resend-cooldown-seconds: 30 # 재전송 쿨다운(초) + # AWS specific settings aws: region: 'ap-northeast-2' @@ -61,21 +83,3 @@ jobs: recent-view-cleanup: enabled: true cron: "0 0 3 * * *" - -nice: - pass: - # NICE 접속/표준창 - base-url: https://svc.niceapi.co.kr:22001 - standard-window-url: https://nice.checkplus.co.kr/CheckPlusSafeModel/service.cb - token-version-id: v2 - - # 콘솔에서 받은 값들 (환경변수 연동 권장) - client-id: ${NICE_CLIENT_ID} - client-secret: ${NICE_CLIENT_SECRET} - product-id: ${NICE_PRODUCT_ID} - - # 인증 완료 콜백 URL(외부에서 접근 가능해야 함) - return-url: https://api.joycrew.co.kr/api/kyc/pass/callback - - # 타임아웃 등 - timeout-ms: 7000 \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/ProductControllerTest.java b/src/test/java/com/joycrew/backend/controller/ProductControllerTest.java deleted file mode 100644 index d4cf252..0000000 --- a/src/test/java/com/joycrew/backend/controller/ProductControllerTest.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.joycrew.backend.controller; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.joycrew.backend.dto.PagedProductResponse; -import com.joycrew.backend.security.EmployeeDetailsService; -import com.joycrew.backend.security.JwtUtil; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.web.servlet.MockMvc; - -import java.util.Collections; - -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@WebMvcTest(controllers = ProductController.class, excludeAutoConfiguration = SecurityAutoConfiguration.class) -class ProductControllerTest { - - @Autowired - private MockMvc mockMvc; - @Autowired - private ObjectMapper objectMapper; - - @MockBean - private ProductService productService; - @MockBean - private JwtUtil jwtUtil; - @MockBean - private EmployeeDetailsService employeeDetailsService; - - @Test - @DisplayName("GET /api/products - Should return all products") - void getAllProducts_Success() throws Exception { - // Given - PagedProductResponse mockResponse = new PagedProductResponse( - Collections.emptyList(), 0, 20, 0, 0, false, false - ); - when(productService.getAllProducts(anyInt(), anyInt())).thenReturn(mockResponse); - - // When & Then - mockMvc.perform(get("/api/products") - .param("page", "0") - .param("size", "20")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content").isArray()); - } - - @Test - @DisplayName("GET /api/products/search - Should return products matching the keyword") - void searchProductsByName_Success() throws Exception { - // Given - PagedProductResponse mockResponse = new PagedProductResponse( - Collections.emptyList(), 0, 20, 0, 0, false, false - ); - when(productService.searchProductsByName(anyString(), anyInt(), anyInt())) - .thenReturn(mockResponse); - - // When & Then - mockMvc.perform(get("/api/products/search") - .param("keyword", "Test") - .param("page", "0") - .param("size", "20")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content").isArray()); - } -} \ No newline at end of file From 176cbeee2150dc6a2aed4346f9b058494e806e6b Mon Sep 17 00:00:00 2001 From: Jiwon Kim Date: Tue, 30 Sep 2025 12:05:34 +0900 Subject: [PATCH 101/135] =?UTF-8?q?release=20:=20=EC=9E=84=EC=8B=9C=20EC2?= =?UTF-8?q?=20=EB=B0=B0=ED=8F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci-cd.yml | 130 +++++++++---------- employees.csv | 2 - iam-policy.json | 251 ------------------------------------ me.jpg | Bin 44642 -> 0 bytes new_employee_2.csv | 2 - small_test.jpg | 1 - 6 files changed, 65 insertions(+), 321 deletions(-) delete mode 100644 employees.csv delete mode 100644 iam-policy.json delete mode 100644 me.jpg delete mode 100644 new_employee_2.csv delete mode 100644 small_test.jpg diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 8660625..447e32b 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -1,89 +1,89 @@ -# Workflow name -name: JoyCrew Backend CI/CD +name: Deploy to EC2 (Self-contained) -# Triggers the workflow on pushes to the develop and main branches on: push: branches: - - develop - main -# Environment variables available to all jobs -env: - AWS_REGION: ap-northeast-2 - ECR_REPOSITORY: joycrew-backend - EKS_CLUSTER_NAME: joycrew-cluster - jobs: - # =================================================================== - # CI Job: Runs on pushes to 'develop' to build and test the code. - # =================================================================== - build-and-test: - name: Build and Test - # Only run this job for the 'develop' branch - if: github.ref == 'refs/heads/develop' + build-and-deploy: runs-on: ubuntu-latest + steps: - - name: Checkout repository - uses: actions/checkout@v4 + # 1. 소스 코드 체크아웃 + - name: Checkout source code + uses: actions/checkout@v3 + # 2. JDK 17 설정 - name: Set up JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v3 with: java-version: '17' distribution: 'temurin' - cache: gradle + # 3. gradlew 실행 권한 추가 - name: Grant execute permission for gradlew - run: chmod +x gradlew + run: chmod +x ./gradlew - - name: Build and run tests with Gradle - run: ./gradlew build - - # =================================================================== - # CD Job: Runs on pushes to 'main' to build a Docker image and deploy to EKS. - # =================================================================== - deploy: - name: Build, Push, and Deploy - # Only run this job for the 'main' branch - if: github.ref == 'refs/heads/main' - runs-on: ubuntu-latest + # 4. Gradle로 프로젝트 빌드 + - name: Build with Gradle + run: ./gradlew build -x test - # These permissions are needed to interact with GitHub's OIDC Token endpoint. - permissions: - id-token: write - contents: read - - steps: - - name: Checkout repository - uses: actions/checkout@v4 + # 5. 빌드된 JAR 파일을 EC2로 전송 (Secret 이름을 SSH_HOST/USER/KEY로 변경) + - name: Transfer JAR to EC2 + uses: appleboy/scp-action@v0.1.4 + with: + host: ${{ secrets.SSH_HOST }} # SSH_HOST 사용 + username: ${{ secrets.SSH_USER }} # SSH_USER 사용 + key: ${{ secrets.SSH_KEY }} # SSH_KEY 사용 + source: "build/libs/*.jar" + target: "/home/${{ secrets.SSH_USER }}" # 대상 경로 수정 - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 + # 6. EC2에 접속하여 배포 명령 실행 (Secret 이름 및 AWS 인증 주입) + - name: Deploy on EC2 + uses: appleboy/ssh-action@v0.1.10 with: - # Use an IAM role for authentication instead of long-lived access keys - role-to-assume: ${{ secrets.AWS_IAM_ROLE_ARN }} - aws-region: ${{ env.AWS_REGION }} + host: ${{ secrets.SSH_HOST }} # SSH_HOST 사용 + username: ${{ secrets.SSH_USER }} # SSH_USER 사용 + key: ${{ secrets.SSH_KEY }} # SSH_KEY 사용 + + # AWS CLI 사용을 위해 인증 정보를 환경 변수로 주입합니다. + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - - name: Login to Amazon ECR - id: login-ecr - uses: aws-actions/amazon-ecr-login@v2 + script: | + # -------------------------------------------------- + # AWS Secrets Manager 설정 + # -------------------------------------------------- + SECRET_NAME="security" # 👈 Secrets Manager 이름 "security"로 확정 + AWS_REGION="ap-northeast-2" # 👈 ARN에 따른 리전 확정 - - name: Build, tag, and push image to Amazon ECR - id: build-image - env: - ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} - IMAGE_TAG: ${{ github.sha }} - run: | - # Build the Docker image for the amd64 architecture (for EKS nodes) - docker buildx build --platform linux/amd64 -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG --push . - echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT + # -------------------------------------------------- + # 배포 로직 시작 + # -------------------------------------------------- + echo "> Secrets Manager에서 환경변수를 가져옵니다." + # env 블록을 통해 주입된 AWS_ACCESS_KEY_ID와 AWS_SECRET_ACCESS_KEY를 사용하여 실행됨 + SECRET_JSON=$(aws secretsmanager get-secret-value --secret-id $SECRET_NAME --region $AWS_REGION --query SecretString --output text) + + # jq를 사용하여 JSON의 각 키-값을 'export KEY="VALUE"' 형식으로 변환하고 실행 + export $(echo $SECRET_JSON | jq -r 'to_entries|map("\(.key)=\(.value|tostring)")|.[]') + echo "> 환경변수 로딩 완료." - - name: Configure kubectl for EKS - run: | - aws eks update-kubeconfig --name ${{ env.EKS_CLUSTER_NAME }} --region ${{ env.AWS_REGION }} + # 기존 애플리케이션 종료 + CURRENT_PID=$(pgrep -f ".jar") + if [ -z "$CURRENT_PID" ]; then + echo "> 현재 실행 중인 애플리케이션이 없습니다." + else + echo "> 실행 중인 애플리케이션을 종료합니다. (PID: $CURRENT_PID)" + kill -15 $CURRENT_PID + sleep 5 + fi - - name: Deploy to Amazon EKS - run: | - # Update the Kubernetes deployment with the new image version - kubectl set image deployment/joycrew-backend-deployment backend-container=${{ steps.build-image.outputs.image }} \ No newline at end of file + # 새 애플리케이션 실행 + EC2_HOME="/home/${{ secrets.SSH_USER }}" # 👈 SSH_USER Secret 사용 + JAR_NAME=$(ls -tr $EC2_HOME/*.jar | tail -n 1) + echo "> 새 애플리케이션을 배포합니다: $JAR_NAME" + + # nohup을 사용하여 백그라운드에서 Spring Boot 애플리케이션 실행 + nohup java -jar -Dspring.profiles.active=prod $JAR_NAME > $EC2_HOME/application.log 2>&1 & \ No newline at end of file diff --git a/employees.csv b/employees.csv deleted file mode 100644 index 404b5e2..0000000 --- a/employees.csv +++ /dev/null @@ -1,2 +0,0 @@ -name,email,initialPassword,companyName,departmentName,position,role,birthday,address,hireDate,phoneNumber -김지훈,kim.jihoon@example.com,newpass456,회사1,부서1,사원,EMPLOYEE,1998-10-25,서울특별시 강서구,2025-08-15,010-9999-8888 diff --git a/iam-policy.json b/iam-policy.json deleted file mode 100644 index 761d0e7..0000000 --- a/iam-policy.json +++ /dev/null @@ -1,251 +0,0 @@ -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "iam:CreateServiceLinkedRole" - ], - "Resource": "*", - "Condition": { - "StringEquals": { - "iam:AWSServiceName": "elasticloadbalancing.amazonaws.com" - } - } - }, - { - "Effect": "Allow", - "Action": [ - "ec2:DescribeAccountAttributes", - "ec2:DescribeAddresses", - "ec2:DescribeAvailabilityZones", - "ec2:DescribeInternetGateways", - "ec2:DescribeVpcs", - "ec2:DescribeVpcPeeringConnections", - "ec2:DescribeSubnets", - "ec2:DescribeSecurityGroups", - "ec2:DescribeInstances", - "ec2:DescribeNetworkInterfaces", - "ec2:DescribeTags", - "ec2:GetCoipPoolUsage", - "ec2:DescribeCoipPools", - "ec2:GetSecurityGroupsForVpc", - "ec2:DescribeIpamPools", - "ec2:DescribeRouteTables", - "elasticloadbalancing:DescribeLoadBalancers", - "elasticloadbalancing:DescribeLoadBalancerAttributes", - "elasticloadbalancing:DescribeListeners", - "elasticloadbalancing:DescribeListenerCertificates", - "elasticloadbalancing:DescribeSSLPolicies", - "elasticloadbalancing:DescribeRules", - "elasticloadbalancing:DescribeTargetGroups", - "elasticloadbalancing:DescribeTargetGroupAttributes", - "elasticloadbalancing:DescribeTargetHealth", - "elasticloadbalancing:DescribeTags", - "elasticloadbalancing:DescribeTrustStores", - "elasticloadbalancing:DescribeListenerAttributes", - "elasticloadbalancing:DescribeCapacityReservation" - ], - "Resource": "*" - }, - { - "Effect": "Allow", - "Action": [ - "cognito-idp:DescribeUserPoolClient", - "acm:ListCertificates", - "acm:DescribeCertificate", - "iam:ListServerCertificates", - "iam:GetServerCertificate", - "waf-regional:GetWebACL", - "waf-regional:GetWebACLForResource", - "waf-regional:AssociateWebACL", - "waf-regional:DisassociateWebACL", - "wafv2:GetWebACL", - "wafv2:GetWebACLForResource", - "wafv2:AssociateWebACL", - "wafv2:DisassociateWebACL", - "shield:GetSubscriptionState", - "shield:DescribeProtection", - "shield:CreateProtection", - "shield:DeleteProtection" - ], - "Resource": "*" - }, - { - "Effect": "Allow", - "Action": [ - "ec2:AuthorizeSecurityGroupIngress", - "ec2:RevokeSecurityGroupIngress" - ], - "Resource": "*" - }, - { - "Effect": "Allow", - "Action": [ - "ec2:CreateSecurityGroup" - ], - "Resource": "*" - }, - { - "Effect": "Allow", - "Action": [ - "ec2:CreateTags" - ], - "Resource": "arn:aws:ec2:*:*:security-group/*", - "Condition": { - "StringEquals": { - "ec2:CreateAction": "CreateSecurityGroup" - }, - "Null": { - "aws:RequestTag/elbv2.k8s.aws/cluster": "false" - } - } - }, - { - "Effect": "Allow", - "Action": [ - "ec2:CreateTags", - "ec2:DeleteTags" - ], - "Resource": "arn:aws:ec2:*:*:security-group/*", - "Condition": { - "Null": { - "aws:RequestTag/elbv2.k8s.aws/cluster": "true", - "aws:ResourceTag/elbv2.k8s.aws/cluster": "false" - } - } - }, - { - "Effect": "Allow", - "Action": [ - "ec2:AuthorizeSecurityGroupIngress", - "ec2:RevokeSecurityGroupIngress", - "ec2:DeleteSecurityGroup" - ], - "Resource": "*", - "Condition": { - "Null": { - "aws:ResourceTag/elbv2.k8s.aws/cluster": "false" - } - } - }, - { - "Effect": "Allow", - "Action": [ - "elasticloadbalancing:CreateLoadBalancer", - "elasticloadbalancing:CreateTargetGroup" - ], - "Resource": "*", - "Condition": { - "Null": { - "aws:RequestTag/elbv2.k8s.aws/cluster": "false" - } - } - }, - { - "Effect": "Allow", - "Action": [ - "elasticloadbalancing:CreateListener", - "elasticloadbalancing:DeleteListener", - "elasticloadbalancing:CreateRule", - "elasticloadbalancing:DeleteRule" - ], - "Resource": "*" - }, - { - "Effect": "Allow", - "Action": [ - "elasticloadbalancing:AddTags", - "elasticloadbalancing:RemoveTags" - ], - "Resource": [ - "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*", - "arn:aws:elasticloadbalancing:*:*:loadbalancer/net/*/*", - "arn:aws:elasticloadbalancing:*:*:loadbalancer/app/*/*" - ], - "Condition": { - "Null": { - "aws:RequestTag/elbv2.k8s.aws/cluster": "true", - "aws:ResourceTag/elbv2.k8s.aws/cluster": "false" - } - } - }, - { - "Effect": "Allow", - "Action": [ - "elasticloadbalancing:AddTags", - "elasticloadbalancing:RemoveTags" - ], - "Resource": [ - "arn:aws:elasticloadbalancing:*:*:listener/net/*/*/*", - "arn:aws:elasticloadbalancing:*:*:listener/app/*/*/*", - "arn:aws:elasticloadbalancing:*:*:listener-rule/net/*/*/*", - "arn:aws:elasticloadbalancing:*:*:listener-rule/app/*/*/*" - ] - }, - { - "Effect": "Allow", - "Action": [ - "elasticloadbalancing:ModifyLoadBalancerAttributes", - "elasticloadbalancing:SetIpAddressType", - "elasticloadbalancing:SetSecurityGroups", - "elasticloadbalancing:SetSubnets", - "elasticloadbalancing:DeleteLoadBalancer", - "elasticloadbalancing:ModifyTargetGroup", - "elasticloadbalancing:ModifyTargetGroupAttributes", - "elasticloadbalancing:DeleteTargetGroup", - "elasticloadbalancing:ModifyListenerAttributes", - "elasticloadbalancing:ModifyCapacityReservation", - "elasticloadbalancing:ModifyIpPools" - ], - "Resource": "*", - "Condition": { - "Null": { - "aws:ResourceTag/elbv2.k8s.aws/cluster": "false" - } - } - }, - { - "Effect": "Allow", - "Action": [ - "elasticloadbalancing:AddTags" - ], - "Resource": [ - "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*", - "arn:aws:elasticloadbalancing:*:*:loadbalancer/net/*/*", - "arn:aws:elasticloadbalancing:*:*:loadbalancer/app/*/*" - ], - "Condition": { - "StringEquals": { - "elasticloadbalancing:CreateAction": [ - "CreateTargetGroup", - "CreateLoadBalancer" - ] - }, - "Null": { - "aws:RequestTag/elbv2.k8s.aws/cluster": "false" - } - } - }, - { - "Effect": "Allow", - "Action": [ - "elasticloadbalancing:RegisterTargets", - "elasticloadbalancing:DeregisterTargets" - ], - "Resource": "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*" - }, - { - "Effect": "Allow", - "Action": [ - "elasticloadbalancing:SetWebAcl", - "elasticloadbalancing:ModifyListener", - "elasticloadbalancing:AddListenerCertificates", - "elasticloadbalancing:RemoveListenerCertificates", - "elasticloadbalancing:ModifyRule", - "elasticloadbalancing:SetRulePriorities" - ], - "Resource": "*" - } - ] -} diff --git a/me.jpg b/me.jpg deleted file mode 100644 index 35ad773c26e47a2318d57f3d708aaa4935b04ce1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 44642 zcmdqI1yoht)<3)thmdZhK|oTZBt$|$T0lTbx}>{XP(o5bLPQWj5lQJz;n3Zk(s1ZH z9KMZD-TU149pin!d%tgt|9`>S`?u$wYpyxx>b2Lo8oycqI;B0Wo&$iA62J}szzqN$ zLIglT1Ofj52ql2_3j=^Agz8Uh0Ac-|2L%97i2vdbwE|Fo=Lco}3Ua?c|8~cC0{|Ex z1(lnZmnRnT8-}1jfscRCdAPYhLjIEY5#>f$Kzo5BM2oai@yO@H0d;)Ttn7NqRfw&Yjzt+^z*%biL9YH#y zhpXc?ehXp}XHY>9f4#;Qzv0?zZ1NlK|HboEO$y{$28#>F*x1wj6Te-?V1u;2@znZ&9tN+f^=_}1gXp(>k#05`*K|-ml*rOt z{&)G%yWW;hwLm%+NPpyRrKt>JJP<3p*r;FY1L^?P^R|?_rvKt`c2m2yg$blPo7>Au zf*7<5n)uvV=GqU}{NLkF@Tt&QGuDpq{89j;>FxeF*A_nqqCN@C3x5KBz^&V?YXc1keL+U^D?7 z0BgXicdKFxeERK;3}6g60~UZe!1+7nj}%(J-q?dzb-)|g2JAr|mp{`<{(5T;xP$b| zzfrGgR)5ldlg9}>|CPf1mlPY|Czz`ppa|H4&u$<#2h;qafhMpFQmp=b{zD^UP^Kwp zgDn`Z-~ZS2Kd66kN`O9i{CirrKXRnU|06xRH2NcSIdmCxHdIPfG1LdBkHPChF!G`b zq2B+)j=%WPexePaEuxK}&7(~iS_L`(&2LJ;JZRBxel-Vu{tx~^Wd>#Pqe`NRg3>|B zsAQ;=04M4_Fdqq2aWH3okSg`Nh1XvBqm=&9ravtJ{wks0`7pULQ7~^}KE!1GdrCZZ zJpMoI{pIU_^yF{4n*8bSKQ#E){Qr7m1(<<)D*jm-ziR*ugO)&H&?aaHv=&+c&_f%b zmC$Zz-8KF_f04pYQOZc0n6p@b%q|SF~8|~T|Zp7bGg^K5x+@sHTQG{>o_3g z;OOOSWnt+`F9D7i=JfLRrg!hq^K$d@1Hkq8bIk()2dclu6$n}PU$mEV03h0Mb#+zq z7fqo70KN-?T|(7gG*%%1AjAfM_9|01XZPRapA^{ zAs`JX04l&!KnE}Y>#HSL&z%7ez!wMvLVyV14Uhn&0O>#`@D<1hih&BC7H9(6fG(gP z7y+ih+Oq;|0&w6MxPU+)7!X_tF@yp_2e}R5fbc?uAYu?{$YY2aLGhuA?Q4G3wnl%j!KM5kID(wU^!F`R8v$ZRDaY+)D+aOsO6|XP=`<# zP~oUZG+Z=lG!8UjGD&RLi9%T0rVyGBMb}-3JeYmQ4AFfV+>b}5R7*ic^C~CgBU9qr#G-~(B0s@A#>x| z4f`8GHxh4ryHS5*@W$$m3rqsc+nB+1k40d1jYpZ1n&u|2*wDG2#E=K2vrE}2qOu<5q1!+5n&K96G;=95xpYH zBx)gAB8Cz(5=#-A62BzQB5oyKAwef$A(1DsCW#=)C+Q`DlM<2MBh@7JBz;d>M>uB|E=Kb5r7``OVii3vLeHJRzqde@Jdl9!_3JK0%sY>ZVnMT=4xkW`n^?=HhDx9jAYLXg-nw?sW+J`!mx|{llhL%Q_#*yY7 zO$*HyEg9`YT5H<3wDq*>bi{OGbXIh4=^E%Z=t=1%=xynf>3`6}Z&BZpyXAH(<5usj z3kDVj4Td0wLWUVeEJh*5=ZtR|n;GFubWBQ2zD&7HleaN%3*EN7opc*^`-GW=S&KQ8 zxq^9(g`7p6#hWFUWttV2Rh-p{^%Ltb8#QY#9Smx zWJ#1k)L1k_bY6^J%t-8`*u3~HabxjM;)@R%ADTV<@^DpxO~P6tPhv-sThdvwO!7oZ zSjtbT@e%Zq?4$5Uz0yR|8qz7!voZ`a7Baaqa9Ms?AK6AZG&x1NSh+EI8hKOs9C^5c zpn{*m4@DeBb;T6L#mDTAogY^#p(rUR#VbuK-&VF)u22C~zyPl_)*Ps6=zp{X{Afu41Fro-3ax2>S zZuEV$Sh2XdM5v^o^mb`#8F5*7Ii%dH9A05rF<+@uIZ&lg)m;6cx~%4IO?K_A+V^#& zbusl=^{?u$8oV2h8XX(Ank<@@nhl$$TC`h+f2jWGZB=Y-Zq4Lura8C)!1 z%Q9l+)lb}?iK}$0IcvOYwd*qL{TsR)E1Qm+7h56Qgxje*tUIN<4|lua+VGV<=e?`_ z$ODRl?8AG9%|}m;W{>TTFHXWwDNeth37x^twa!;BJP;U&_99)6#X9EDP4ge7MgYyU7-`0n}IDW0}e@EbQ>KFR${tx)KHRP{%0sv3| z&goURz}d|h02E~b05d2bUk?DD-~zzY`v3|b-=Fb!alfrQuOHCCb;U=uOA|Yj-(UUH z?YA{4NcuzXzu!S?`1tN!r}?M*)fDgwT;^W~6!0I4f`S4?g@OSU4fQ&pp`-l@;28cZ zfTJ}SZd?boP`dx2?pc0}H-QtlzCssAaU~nSg z4UEmW!6^B?iBxR>&ctWp9E6EQc9Wcf@-{OID;xVgegQ$D`@&L>q-A8~9E`Q)brZFWFynz7?00mX%jj zR#i8*{Ag{1wRdz54h@ftj*U-DE-WrBul!tHTi@8*KR7%(J~=%*2X|(EGY7o>R)+sz z4#%6qP!er!AgOi##4`7io z-CMZ5cWu%yi~eT@1^vIW=ud3I!wZ=I)^`O&sa^p%V;90#K>y2yJ_M=G6|nd1 zI1h`P4YJq(?+GX=yu2YseFZdWq+bD)nMjJlW49|H1EVGVl)dB%Sklg)zE6Oxc~nwUyZ=Un2DNW&zP2^ed&2FJan=lqbtB%XYZo?3izUN zsEp=<PLS&{?eaTeIw=wp-?m3~aYm*&6!@BdeB*ND;mM zBjRwK05YpI=HWZ$aRInin$k)IN!e&N5y|7iNevD5jD5J&WI2&&&y#be4EwYq8|FU; z5R_p@%sgm1AzDvV>*BCKFwp&Spz9>&Lwt*36)mRxCQ6-<`LR~|w1&`uZNMk#q-{F5 z))T*Se}+;cV7Yvt?=e|h0A9kHuwhiKc)o6a?|bJMDp&d(y3=)jq|r%3S$g!*I0{qJm{K{<>@mm>-Vpflq*RzMa^6&NI38bJuYf5Q*v&CI zt6}~bHSBixRCZ?WKLNc>d7^xhNwe!!*(y&rJ9CrB5^Nuyeha4YxtScB|pfvQU zFEk)Xx|U*E=IfaTM0^$%^))AN*@jSMRSssAKg8R7ZxhS-8fw8S-9_~A1)AR(+yjZn zaJsoLq1Ljf`Tq0i*V2bFOj6m3Ep)T;cWA8(R$v_~PSIsqywRSssbkB|Bgb!%te3E3 zqp0ev!ghzC0Ju)fgwsNoNT2ZBhf($}NR^}`=cztr5Htk+*QYIB}8E-zZ5H`ChC zdWGjVhrc%0Tz1XI zpy7Bk+Oqa39AN!#B1hVu1tNkn=F+?83ivcxO}SbN&OsR3GOqxG-+Q2;|7c4=aZ*&J z5Y6oq(b`jQL}Hax)s7zB(Avu6{Zw7TLTwMMxa?|S*((o7+QU>9#R;s$$Op4W%Q_yB zm2Y5Iod^LwaSx%R4X+zc*;NoESWAn$k!20op6VJ-pRI2l)+8$+k4Lc;L&Fudvo2oa z{)kOVGU8P%Lk;c1DmzaCr$)wLEqZrqvCwd=(vp5dS4AZ8>ty+tV!B(j{A%S40{%?l z^73{Ym12e%FAbaL!o}D&kql;Wx=A&a?#!|lv-eHrc$TV!+JM66!M9~75h`%z)Y~ln zq5aw0SxSb*$okj6i4cr`V zcQuZcOnb?xw0|tnU60piu=&yrBYynceL3U}4s>_u=&QY=eRk%J)?B%`izdFQ^it^u z#Bs7+Z&WVC5_+Wsa!7U^3K`3_PHZzph1uHh{5WccYNcH4#9W(;8J#5;q8HX~pp>P} z?jY$(I|yY_jf*6G+}01jxecB$*-$D^cV#TQSb5uZJ&Y=WzTM9>9L+Kyd2D#dskJdsksU^WsU-(lzfqe!W-qf!7 zjFvUJYM;40{O}9K$b217U&DW5)SOBzMr_jx#+bLJJnq5Sm`<>y^CdE^^z89dyx>BP z)6pVPJv--qh@;m^a7I=*VC!?%s&@R8wSD|aFkI_31z&<0&gZxDgsuLHH~8~W&bEyb z7%Cm}iPI}pR9~)7*4SYzV*8b4Rm+z7i>%d{x!KY!d=}Av6wjQ7_i7|7X2<223#?V= zzPzXL1yjEFF7vh+;mY}rj~s1y?&N-H-h)6)v!Ns|x z(hAT&a|Iu8wbIRfSvktq>TM{=-sXhAJkC8ewb*l*Pb{R(&zg>lib>RFR;IXRp`(j>s&qt-u92VD94JWG($QL@Oo=U2dh z-NS*a;r~oWBIijmW*3oi3O()sJrRZ=xQyNoiCvsP&Maa_xTnzwQ!mpmJktcWrl;92 zmpU4O*nD>0_oYu_D$5#(Ed42mvPNnpe9)ru9wsJwk=-Mw*!Y2$0JmrpKV?hWYJf*b z*_m7cKggVa(g|?!F5JSS3M)##w^cm<);ugIQ`ACDGa9UBl$Vf1m$tg= zS~xvuQKeR>jo1#fyF7!)1m^ES$Ek(!3*txCO0JO>D<$J-pruO}~m4C-#b{&XSwLYIR(6w8#2c4q$T)Imx4bmaV4QySx* z($W<9EuMmfeb_CkP|+LI7NjdIl6AF3c1Dpc6dDU57#NXm+w0+@Z*y%+WuN9OELIs6 z_-X6~EMCIeSh;Ey7ir83?04)kG^8~`n9McQNt_tALuREwsEpfK8rf{)`#Q=O_k25-a|QF4xc+IRgaXte|a2Xd>l2=RqQDKaj_O`1;e*} zUL?|27k2Lp&X8~68*vmzpD)gsOo<(d^YSjsWWtw6F^{nsYx~`-j#*Rh+jmh4%sz;O z4vp5>#E`UDu9L+1^^&*in4WWyGr2aBHd`T?N1rWqM(?j>nM;MqMFbA-An_&AJIm}w zl}ywHKDga4f5K~Ex0V@+SOwBQU1)sM>(RFqx9O}KD*isQTR&L5&Q*oA>M$c7&&5z^ zaO%6^9(kfU!4g@7l|6?sSWG`sntOwsV6_v;HwQob9MP6Wn{?9n8c%G~;&5B9EOm+F z(IMO20JrgdKbNK_lO4B@;|i_km(OGa*Y{mvgH==dCd+cSMs&BI&{BGa_E51dv3pS; zEs<__9;^NIv0M&zslmy3aUyx2cHT+bdoIvuk09wOdLtS)ttr3!RM3#bT zzq_+sm%~Nfhx%?#Zu?rf*;ySUc9d5#c!Z@e%2Bn0mbO1W7lQDeimZCUi=))tfxIK;xd*unqrRrI@FQ zekx5`@J(TnP0?5LyfJ4M4AI<;u5Me>J{F<1K3{CUGI~&m+(~YC4hT{mKNplTyl+(^ zjkXv4gRV&BK;BYi=7`nDMOFUG^r?!v&o-q@Vz2#8QJyuw#=`q;oHiB9-u9p6Ed5jb zI~&SYx3MpX5r(CwNf#m;@l4Kg=?(*_CF2|1Une>3O@I%t(r0?e4;bi8+1@%F(q4vK z0d{k*(km>Qd~j88#^dW&gOopNVWS^AOFC8j(QoZ7 zY+x2jS7RX+Dr;EXGBh<=Uw0WOZr2btR|MPUGu?A|7@~Ws>sFqdEz1+mDbu?5Q?lzr zl!(P@g*&+pFK-TG%KBT;JkbyOg9BH9zBpqCY{3+LkK8R=18HD!?#4Akvaw(Ivf-vL z@m|+LV(^~LLjH!e@XatzhT4)3Jl)QB9YNn+;6oq9`Gc!ipssTG|Fa@{T^7nwX|%foIoGCC|ZaX-}%RWnH}InYmBotXf@fxXlRQz7w{*>l?s zbDJ1cQh5^ZwV$ayfZiYe{S&sBd#A-dTuVxD2gvU#~y89HNFVRGNNi% zzcA8u#+6MsI1zor&^cqNN54p@ruZyCz>!U!$+XzU!BVMeeW$psL#95og`9Wqta zC8z7sVXB%HNYijP!tL5@d$_LC!L$|K$$xKn^A5xC-TZFN&6TN6FPG+2r~UYG*Tp*7 zmJ@IH*JiJ=AGAkQB|9n-?=O|3d67_N_5rmYI-YNF)wMPHA?K0jn0 z<%JpJ3!yW|hxzvl5+bK$^Jab3hj)G+*b(T2P)SI2K(o7DE;(Ez_PlDGtH0$W$jos^ zV$5ug`w)ijDT<0RTVPX=KSq;O=9?m4Kg`d+XE4$6%J2=MM#sk$mVvMua*rcf$i8({ z5TE~m9phE7Lrqzc9F1h!__^liNJ{hBtm=l)9b}IW70w-42iKf2<4WHcN^N%2*uE@I zwV60Kc1@?dNI}4hBbovDrpC~RQ^SSS=d2MJAEMsk;y`gymaiN(UE;8tCZGhFl zzAxIwqXYtjvoZ^B|jn)RtY>`%sn3C<}FOE zF^@F;i4c`oU!SalHgSEme)bvmYIo*EeVH*Pl8nk5{|lNaW!iS!_#~mqjCer6d~iBk z-RcT>?(n*0L9k8KZt2u*vBNi;kB8eTCA9!m%erB|^L_`WOT{woYdMP8+>4ot%4oTl z+3b!IG&dN_0;1;v5lYd5UE}zaZNG?2-0=qM8|MBYGZL|wc zS$N+fS;1sM+5CD+XYYmwu4r3ocofln7MHqj!jF2Vi?^8)J92v*3$jEjG|mDV5q0GkNp*Rv}<$KqR8E=;`wtotTen#GHgwyuE1!Wblf z*Mz5a(~jnh*yZpQ;5<9ZGb_KsMj1FgA_MMV`GRX7G_A`ze-eLiI*wjw%bznpax|4- zSaRxj7ukI>h*D6bFHc$fMrX-JuQZ`l> z0=K*Ki%@1T-*OYGjswFC$yPYMO5)2L_vfa^!KY(O>5~>qcXRf*7Eu>})Cf%*Uq&1iMl0 zlzo-roqE=gyxaI_N0>Gh8IWLMH4E&~mXzwwvcH_VOrP(xq=!FFGx=!g{`tx%72$Hs{2`xpdy>P|wdu zBS;#IHeu8I*}Ho!_CM_Lm{m8KZ(ISeq!%1-0&mAH^`$U$1}j1k1}{vbePR^05B&tr zy`p8>;#bvE@$Qo!Hj*RIyok~@-51t{k}HT723dK-0z74<-Z+Zi9f>>XcEBB`M|~WI z%;QPlG#fO@5mjC3KcZSj7DXd;7anRO`P`b zJH`0(Y^NM(jBr`J<)b&AbLcu;pPH=376_4*tjS-2VvRRn(s7k*%?c z)#Gn1eN#XDz+lwBO7xLgw3v}lk9SWmzb+hVmV za$tTm&yUz8*f3tOY+>O065jVuX~A zafrPYdEf4=I+hi2*_$p-^E9fZ+-7w~S7YSfF4+~3IUVs}d}{ekWOEaoM4@t0D!`{= z!65jfuS}ATSKd*U?t@BqSrIN|9`(`+W1d$N9^T4)jKX;&H6y#?JMFW{l9hx+m_#mu zx2iI>tPdBtGKYKTp%FR^eoS$g*QIZ&KT!5w-(;WS$+~kJO{h}whf0J~`1ZZRn+Dol zKefM`QtX=twsV%ccDXvOd5TB*pdmv)c`h}VkjuT8ZtkzT0@BRuHxmvqsAKXSwOX8? zTFbf#Z?_7i(;&3rf$-gkBTj{mx2-w)*sq*oD!Xdd-^kH6YRz2%aK*l<_%`ukaTqQi ztlGQljzAnVz?|w%fK9~{iHGiSGNTS}?C_>|JquseO6P4nbI-NQ-4yurb0(8a3w|<# zIjIm0qt4ldS*KqRO(_f;?cs+o;gJ3)@68miVcDtN(yH8!9MjZ)y`oo)tc`x$h4p2o zRQ7z=muv3|2(D*GhzkSYXbGWU_$g&%bd}GQ4w^=EnrYp)@ zKU`n$hhb~2Q8rv)E(4DJKgI!<|_QJErLj#c%;IP{?(p;um)67AqLbFA&>?T_QJ zsA!Wq_-dqH4`i8ASsssReO|Y9)vAe8rATY~#6{Qx&9t&S2@7jq zu8-Wb#&`B3`JBd>Qqz(#&LIZrW#Q4;Sf37+DAeWW!H=p6<=!+^!*@-E;&kx4YQPDk z(E7GsOql=ejS4Ii8OvFoqOInaF$*;3PI_8lM;zGXM4clO@u&}^nwYMD8}VPn>naNK zy;=|ZUz8t5u)O1JtNhjLv*tNlyf+?CU1KtnQEn=r(PdX}%2-nk6H3hO4 zZ@49gpFL)xUZ%-TTZ&1>>K>@&bUfi;;)%U=L|f~~vF?EC-tv`t?Y36*?r-dvAlMJCc%+!*HGTmX94%9^V#(0Y8Z(S zo+u{tY_-h)l`sEgq#Jfok%6|@*{M7!_I#Rx)3mqNq_Fsw;iA>y!RlnOm$XJNZ$@qL zOH@1wA=qQBm%8_&PQGvTcUZg1E|Bgv%}A;I*pm&sCw&~FqLQ&~Af4xlzBsI)q0%|t zhCdXJj5sPBuV@w47}k82{|aVn9r3n(QvS?yo~9+ndVlw(f7|5w8Qb0DDibrd8r+vB zo@;zlgO$iS>>ckI#&1jT^j^Ho`K~?jzSudI0t{-rk)TQ6^G7~AZK^J1oxs`R)^JlK2lndK5+DRXf&lh zE|AiIhva0Ug)phxhdMu+KvRaG=@wi8YW~TjGb$9AX=IG-Ik*ERO@|+tb-gm)_Smnw zk95e570t@t4hVxpdXEhW-`<*K=+EEY84tuXdyG3Sia!SIK!+G=sg#DuMEPmwe~zw! zHE@VcmVVwpK;eiklzCTWnCf9FqTpk*w{J%~ZL(WCBG`Q(WqS4L_Rjm8l1jXrY8B2o zcbMc`v6P85?#)K4BqH`1dyTTH-m&!R`Ge&ByglFsC-jyHdpMQ^7u2q9f27A8o z#*;bPa%>4d$El9gznA4T-sPfC3rx0EWf_9URyO9F-0a7DZ}bXvtT~run(Bs$;NXuWG~ap?e; zJtuMB5B=~U@XSHYNz%GrWi8GU#~~@@29Ml%?vE6|q}PWh)k75K2QTQC-Zc7tT&l1> z;6ky<&9+uM)zXl%y=Y-CTG4YXzO^1>sM_j63!Zdp5g(uFqHko})ZhJqdv{2$O!qi4 zNyu`CI7C15#rW|hN7tv%RINv%DMMX=CkP_J<5wyZHpkoW&?x%^F}&48_((EVVec0ZK z`8vThJqch&Y4XM|C!Qn)2WyIC1izltn%qgRz+x=0E>%NNrZ zSMMqee!5ZBVx_x_Y+zh}#`>u?2qm2LlVDW^VZXqs5r1|2!S zVDu!X0ApFfc%Klp8=v)=ickw`p{q3IxZ*YN;t|A7`szw;QEK>fZYP zCQkOY$C>RF&?h9!MSJvPW1q>2>5ZfV*X>4a^<~3Mx_Jrxo&4M>re-a>Sizlsqxo%` z%ZJ+r_p_%ryt|!k1hvE2J`-lC?!CAV8-wTZlxa0r)3{pI5lPc9whUNT|ELhmOG3XZ z?vxOwh~R0<{;EC_-cZbeF`-L@F3NRY3SVGGP|M1814~tdoLcZ}`^Z{^r!d zxz@Tvv&u`pxw<^wHo@=E>~is2gY^%%yN5MmPjoaTzr@ELoHWi&2<$$skIWJ?RcMlE zBf&T9Y;XpfYx+26VL6rOBc8x?{#eiLw1B<^Lj`2Fc!>xHTEXX>srl{8NiQG309|gg zSNPnua2y}-BPG@e>J-jh9XoXyS` z++zRum73O3bWj_C*3;ucfjtcHla*c$ z`mo%$8%<4M@9cRQzjxq(+%J-SYe%`!jw*goEeFu`RMQ%AOxrq>wt&@!fqr z_jxUtnBXZ!}Zgc6~i%$AGNh2FHHA+M*&7CMk#3lP>c#M5l%h@kq4)X|Wc4c6CXZ#k!H3}300 z%4!HL$UoAN>Eo=e$LPkGl{wrr%$OtaC)cxzQ{W7FY&+h^GDcO0p>U?vR!?^`vBQ3E zD^dHg^#l8vM%wvlzz6Rsktr{`F35*MDr3>=RPcPw z!aXIer?tcUhB$iSZ`>Jw@Ld6%^mil=wkW{C`Eb};F-dCi8*D2-#=BaZ#dwkYZI?qJ z8sf2IBRwmVp^SRuaUo@5=ZR!>oIdAqvJSTy~FUSYwfNoOe;2Xr$UMF_;Uy$`?VNHFyY ze4%U4HeEuqgiTaKo|@D3x{1|#i^YB{DnxDc(W7XcFmot32v zoi4&GjCe8M)`gQTrWo+DEpcJi$J@j|nrFwhsw0)Y_j8~zj`g2wLGbb|oHh7wo5g=Q z+8mm!*a8=<-I{??a#>53oe3h&b+QWvW>r5c*exXh7%#qgur|_goU-=U|ETEC z%}TRsV{9A`I3Kz61=nfi4a6OLeXU%tv_fg^KV)qj*ShV``gpYkj9-qCBiUEl25a>% z^!foR-i3WzlYkG&S^G#u(axfk&7 z9N#mz4=y?vcV5DY=aVAD_)kB4H(pR;-hKK$)!gor#ncsmLJu*38G=S#4~^l56r0cdqxbOZM2lS9TnK2RNKgeYm_{UQz79KNAaqqkXEQQ3ghxM-@^f16 z`zaiTRx=*!C+lN^LfbQFFhXTN&rkeEVzKA(m{b_J zsrBW7#}-xBMNiD*{HJ@{1H&xBFGFUi$JAUlJ_fIEaa5G=h+kmyviq#?~ z_k5&l*x2l<*2~1yuYj26)jhmz*c{@bu%o{F2YX-i@&eXGK{%y`Z;Vpi`bUC0zAUnXHGny2y0ou zYg%|bgMN~asKh!nFTF)Qg3d)+lzFO6>FS^M9;i256e*Ues-V43i1eohKd}@JaZRKz ztn_}yp|eq$Qyh3W`PFHC+d$baY+1H2c3y8|`aEeqv}v>Jj6S?=EQ!h^HVq@Y;%wcp ze6F}+>xkq4mNVNt*cKW}kLoNOi0q@OJ}#sF$KL|y^xmldZP z3*@Cr%p8&*g8^e1VOQFDt)I!M>bc^K$KKj<3s!7yqT-G9w?~VJ-jeguCIqq8& z6@-xuQn&C<&K9c7l)l#p@$6g)6nmv{f418*q*HsmTKCRS`}mnN;{z?R))%x#EFrsF z1$nDOg}T<$a_TIpu1?WBO{C{(_|NXb<~_$*M~Y>3Tk=&D2Sk)yjGg7mYGw#+9fS8P zr*;Eh>fJl(Fb{B9uyWis=cx5L7mog3;fN2=!cYfnz>$5z(6;u64THRcQ!pg9njG74y*&v$H-P{-Ql3DxG2ehXKEOkwmgxs2B5mm4;v$gIcmR^x&tb6-{S{(1g zUYldbcr*vYELF6vy$lmH7bn+S_}rexZ+-Kg-O8L!drqw#`wU-nx{CK;Ma>y%66> zXEM9Qu{tpg94j%-v}VQOa&x2`w3Oh?JJ^?R*t9YZ)z5^)E?LdDFWw4fS`({1;w@04 z%|2B7EMTNud>9L7iE}pZsb!X14#!G&^Z#nC@NXPZHtbACP=_wPGkkWc2(LAgC9*nkDEiA=%vq^-2{yA${u+FG@qZL@+mBlSLXJ(!QQn@MPU5Y1;u zCY|%;-JZnJ3x7-dka=%(83;JXGj#SW*VSFqeNtq;Utmgpek2|utfkDZ8E4TykiFA> zYSCCQ2TxbT?O&L2x&nM1#oiSrBT&YZ zbkA4fwf&t-saHjW>fzrnG~kR)CUaZ+sHl%p*^cM~Vhul~Q4BqDf|2V%`j!kid@0MK z?uD6~h8hV+`ixD^l7|@!6K*0&_qBx#)i+Kf2!>Wf`D9<+c?cdpnt51bN;}eP)gMS1 zB*Z_=?pvc-ADvPx&ex{A<=#{g)BJV1ZP^?{c_iy&5tXb$)F;!S7==*<*qz(YEt*jZ z*qF?wDjGg3uZ3lQ&-0qXj8y|q^YGE98U?QF9e<8Im@U8UPMBz_m-yX-n((Zcu*lKb zJrGvtu%b6&=AG;fv@PElsu0 zz5OF}R-`T8CqcTJG|_24JdQbpDSYEWgfr92KAmG&koK9S*zRMqIAPaT&-BPc>>lPT zK)L>dKuCG?j3oF0lxQO|020&OARl@8epw&CiG3ma_I8Zs$UVq6PvPZf6+P}Dk6AH@ zw!3hSF(?XC^0%3Xx3a$1ol!oOboN$AWkl-HHPy77XnXH}dsU}X=7NptM&+V%37K!!FZ?6Zwc`)Yl z$$D_IM0bv%n)j_`J6=KB1WYXLL+%6h=q`g&T;D3TS5fU0hPKD&S{woBnb{0^@4tjm z--+0N^8mfidp9FfQ)IGB$xT#KdrQ}PytzF$$oW#%DF&WPWa&YcWLwU&8`w_ozB*XD zDK)agwqTqdkpCn70p*b1;NwJ^6ww1=XYk}r|7u$3uA44?=PQ?`a^X!IQeF*BpgbTCLu|(H1rAL!XKrN9MzYIn|*UFiZDBf0$u;(b}R`J^YVNmmHbIv_9Vc zvctyTSS!Yt?RK}f5T*OV?+rytyUh_{3zlT#pNDkn+jCPl$YcTY&T5oPz8JPdB6 zj2x{|`l8EFutd*r9KSy{e=F^QdQrh(NAHRwR(5Lj)hZ?7&CPR9p-4m%x}2VS-xH4Q zzKPAHqp?@!qA zNemVQQsWr>=A{#+I%i3aOb_-KKz19_)!tm}5$^3%YKYc>VAn9YGJb68Gk5U%Y^n2_OV|{hSWwkPM$>V=JQG1G7k`vv;}T>w@MQH_&Uf76me096rmFX7_O5pb>lF zuJ2b!i(TgS=5o+UC#n8Skwes<_FWhtsNvnBukXUH$uI0nEY+qXa$eR@7QGX8I4p>QTsD5e1xI)fLAXHcyU^N$Z z9@=bmmw()q=Dz(0)pr)I!}jK~oiB=8E4rO3VtA5YU^*mtB7N&cPP1DZ2##z5D14PG zgQ%k9X?^@?2c>>CzS;`T<}o3seX7nCPl@^e(Dt5DO@&*#XaEJIiqcy|P=rWTdI^ej z0jZJR1?eEY1*uX)2dUCK5$U}XdhfkT4H5_tswaEgyU*C)H|`z#p1aTavBp~Yv(`wm z<~!#*pE9W>%AYS^zwKBz(T2pH=|oFbJWA%GDPA+8X?^JX>njW6YHx79w zscA3U-tDp^+#&w8W6iYIk7}29aU_#D*nK&wqkquW_($AIp*YSWmPzs_aG~IPzIEGU z0dJghW$wPE9QnC#o)wktY-XqA0$T*Lvjxas^9o9J?MprjeMQZuGOs>eKc;A4GDgI}+z0 zU#d8_B=3vj?dekoy4j&Y9!tMfg;<5}y@YbP3qn3g$b=fD`{$X+qlGtV;j*6*cCjZ+ z@pGGgHI~$7KZ@;L{bPOwaprn?R1v4M`GjL+RNs5X?kN3K zaY7Npo)zQ5oTZ5VInDCC0VYNflHjsyx#y)xBQ!zm8rj=o!5(rGdP#L4Nk3VAKr5Bd z#DBIpx%;531$R)T4*K%GV^%jZn7IR5?oE}Hb{9SyU&dROvheUGTh8kY^g_mPN0L50 zZ^|nwl{)_YwP`VhU57^hlk*GkK$|&uZ52vdTaKj&ksl+sYg+NYn?S|d^zo=iG8$*M zpG9iVx}D62nC83+0Q~8dx^g*(@BXjVXl|sUa6;uKsONPOA$A7C+{11c(`tV2}t~|_Srxz@+S^8lr zLaR1uWCQ$1hM&^ul77FqEIWi$cwl6g3kxv_!WKbmz;AxIeiMc-F}|BkO5VPDQd8!2 zHP>uU7bff^w}s}18Kq)kJ$)5Pm?}PX)uof7`7ofP-sMu)f)( zJ(@>*si}+9oV1(K{`6?=Dde+BE%mnZyOm@JrV-jBarqxFVef9?5?b2c|f76}CX%0;{%bYYKPW zQtfLe6kH#f>O3>;fx*i)Fy3nhl3hyv)tp16vv}FS*@@PGF_%Ga2ua`(`pU}})Su5W zeENQWsAN1?NH%p2eJ#5n3(|Skw=h@XwW9xZp?1SmiX9ENW&mc*<{jzM5sy-l!22J} zM6K7^jZ)!0aq2_l+tHpqeOUSUhq1EdF!S`9cEp2+iMujV0}Ma5CKFUrs*;;SS)hb(oLzA~pA)L&a8WRXdU5 zyIyS(XQZ4n%eHFGVFCC_`$q;{gO={JyVFex?m>mTIQ;d*WHMp#NFrzkBu3n((cdY>;63swoPx#;1|C~gy=rr z3lr%!dztG*b@>d%diUVcJ^#y(kke0=(d)(rwOggI*EikUG%SE!$LZfDI~ruQ;CF?g z*(nPg`CEPxKI#*%N68F!Gs$LIo9mCa^HdF$VYv;NudkKysocz(tJ`o0 z7q=*R`UdK0eV&wb^e6ElvGWGoDZ!9b|A?>mLlBnnWSNC|4o7m36;sDNB@WN_1EoE#!a0Q7>a2hmW@y9OVual*ahF}?Mr zMu(7oowaYhc@@7UbuJB-`#pi%{ia`b@$W;0fi-O%8PG2JoH>u2p+#%$UqFF}_Bxul zIr1sK+ZK4BE{?TGf2%RbwP_##N__*bHkH!zOA_WQZ7-OE^l*avfIUUsHRPG{oY z5E)MssEwdo`a|Kf%|L^h;#{Sjh-(>bx`R4@XjA5m3zJRm50n-2}!*tZY1vnM&$G2ZGx>aN1D}^0){i+v#Zk5`RAVkMy{{H_q4e-T*9d4*T-W!1m=SmYeHZKCU35~ESz z^Y}KcndNv63cXynulvpS)<$t)_*{oH8^6 zazk~o{;6S2b>R=<%CN<{c2Dq!L;1Ae;Wbw$L1+*=fx`zs+na9E&PG=4amcRMciko@ zxedYZ{LlIq6N5^h7I^josB|bSNhLauNO_X8Y;T$&t|sd(7o2h6K_@5%_?=1WU0Pq z5wKHb0v$c6NFs%*d}8?|J3mVz=1MS0g+U1+NbM}>(-y@Y@8Phyu+Vuo7DCX>h6K5; zp!R)eZ7@6}FLmJ~PYrR3Ja=DZc_W87=Q{)URuQu?oM@ZH+Q*c{fW05H2tNhQen1rNt9Co&B16Tu~tHzX!Uoun5E=?~aZ12;bfrGq19>asG z*_}g#Q(kCA5lZ2bx74+gIW4AkG+}Sx|D%B-Y}%<>uD>5KFdV>F#nfwa0^Rt7#Nu^R zkoO_uvqMWnG$cQY0j1P~+`a%f085Rx0!O&>L#5h}vvk=N$ zT;eju@xSW6Q&tv-uTc#oh5f|eW?WZ+5)EU-P~kwTr8Ehh*iV3`w1e-P9>cgMWZu3R zk$k)8(T;b|m#%1!clqrs^_W3~=c}X;{&w3t0rPu;9j8Uz--1AJ2O)(*HX1D22GI7rr@@nzqo~B&! z(i*=>UW0aFi%3Hc>=K2b=bRh8hj1ND8JBCcrGCtBXQk5?e7A1@VC2o#)wVo{sfK?@ zLTO~?1WW|B;K4kTLq0}n*){8J1WVFP&~E!p4==?g?*BAqw2T^(72|;K?yLb11&*=R zvcvJE1Ig#}Qg=^WId2!*feTK8$smP z_vF%&*1HNH@fjRm$;-b2JV@VmN(6@jQz~?8*ume9^CJpnPA0X%!C4p%nvU;l>53_TB+f-sI^+UH zbjTnl1%Pkq-4Q-MR{YZqrWaTZt4sQ^*UB)N`%V9~qO_rbXtPz`jt!fl5vK?Vug%q= zigwFX`MvL$^DnxuN{`S$8A^3=|No^-_MeGRa4BNL?_{v>%!b3){(K*mKHx!sJ)Baa>0! z`yDo-M3TERZL#BWyDjU5d>t=Et%b8(H)F zzppi;80Ys#GxKApmsbtMfagf7+=bR08+w6&`xnZm(8$hBpPFlIicH++3Zc zg1hC?i*rj(b*h$~jrM4Emw>!l&-%7XT;b#Hak}hj9~G>y?K_?meLxR2N@~a;Un7|r zcDPH4WFf6&;b4@y=zq~nCb`SDb{|1?asE9|r-LJj(%gRY)9R2hna!BXGUv)#;8qbr z{UP}lG|_D=-;R{ik{hJyXMX^#dR|kkQ!`}Cow@Tn@#Z2n0_UWPl?2Qlyo==<@pOZK zL85X!xsl})PIT*}d3wLZ9y|`*=NI1fg8W&Mq?(-JrNTYh!Gq0GsqA#sK8pL*&cJ9M zi89c7(Qg&aDCv`P4o)9boezafJr}%+s_3y7>}z6>{1Ht1JWNg2ELnIo$iuS$l zS;O?v-aFxym#e{E9*hLTw2qYSsNyQ0Gb0%&+!6ckTL-0)ASYmCwmB*nM5Zol);fZ^ z)_zh?R7@`pd&#D``qN1Kv{4k!+{s9tbOj1TZx?=c#JSEW+1IyK?)rHHN~;~X*yVKp z-g>|c8auyYzE{i@`+b9)`;4B^lFA!b_y)GL!~Jlwn^aQKj^9bNQT^}(cx=(A42JS;{3#r$=d08`wOiX0ne=B)SdZ;34 z2m!iRj^agw_CKQg%%xHY98^SZN|e%gDLJ0t?B4fE*t;%FGZIZml@w^fXysay0re(L zJt-g3%ZY_vv})PoNL~(WP2N%NoeN<_Wi`>OLx^Msr0aK$NW>1~k#w}yNnv5Wuj5N~ zu3@0s+_Vpku!u1FF93L4VR^rD^5uknBb_AGFK-YbGVbw1!@zA$zcdAaw-dUeOiw6r zJ3g!HgtB)0r4LRtNQM=nuF9P7g0-Vt^S2LTo92d!lzR2t5=9gcd$;*V$k_|$$jPW@ zC+LQq={y%CO&8m_@ft7r6T7w9?I9N8zJ86xa~tGewtb3H+1-+jO@ej@sdjxKx^oAc zj0QZq(ZwLwV1R{?XaghdI!SYOp&M4^V$6z^jUx)N|5g8CjZeu`)Lwu_H_#Z)!8v^No`xxH`WtJ ze(M!E#fyE#!yCKWGt+SyD%}4mr}&4o;{U9qLLv52eU@hzE$!z!Pokm zQp~6L&!8$r&GfC@N2K1p%nZj}mio=nBqx;VwGxSMgl|Cjqdje8lC09;&_j6HnLv`b zvbACrrNf_hOPj~=!6=RMU5s-6&D*x)nKnE3T82Q9)bOuQUln&T<^EnMM=&}esQwtT z9K(WIpXlmfB2;5ukyb4j>Q%{cGCKA@eRzPOM{!u9e`9$wC2|MfLxbv@NrlBb`3px{ zW@fTx&&)2dUL657e%&)!RYC})^F_Nh^wV+l&seOpinXMYLp3g2okst{uUy{I3)CWP z+yV2HG+^;=@G8?Y{`!+Lf=THO5F3Wr75F{@hJhxs?GuBB7=3 z1s?AjFKv5!uan_@?5eNA3H{V!&FcX({4RHU4ZDJH^H7&+c7%e4j>Mz zouZdRUU+EE1Q7z3WDG+Vz1B;Im9n+y!ow?Doiah62DJmG-6LaqqKj{BQEBRnH$ zhckFBMLT-8joRh<+vY&^%(4D3;S;>Np=kc5pKlgtsZd_mmiSe0s6M{p1u9qzY`yRXWC{BWW*G1^7y|7ha?#?|2n>lSTA;cY=NKL zM%0+uYibVte(sOU2d^7&4%dqN7@l4?hhdzg;p9)Z()hi-dPjSAAW4eddZ_&c7`H9Z z82kkUX{@dGWRV^;R)9Bq#j2xsn84FYHT8Rwfjz##62s-c*$nD?Jw#}cM&0a(7g$r| zC8L_;4hM3ZJN(f1Y04Ap$1B(bZrhkn{j<`0mXJTMBvbGAJs9F*w&P9`C3MJvx*J{N zi8|9cgNEF(rAX(hy1k#=d-Q&h1vV`5sg@^*CG6ycXx)Pun=^U??Gr9@U}hoDG^nhd zL%H+}SB#Dk2i3KGj&0JT)^RV0S~cSjIpw)N*}jT23&fI?^6P&3W`2ppyOZ(R=&D*R zHcHeT^jxhOO(2E3oywxWWx2?3FWCJhTc^83wdXE4W+S5Wm9nTmGXuxB zXT@c{;Aecc3f{@Id_ZE|q9kT)$J`9rCcygI@U-1L{HF%{_gdu$o)x^P}6`qdUgYSA|_!MDGvom-#%~?iPuvr~o$$ zJ$j;>-k!k8uy94KU!= zj=zpgOZ>PcnMS))M|C6NBZT7VG5WSjcE@-(nC#_4qn$}Lvy2w1Bi(6UNGp@n2s}Yb zt#XTUnjeF%?rz~=3vkFPg(O2y?!Ku0s}%<5FZRAdPb~mm?6``GHxJd(Zygn8%I%l0 z(YnlJ@AtLfvLR2Y6Rx;J_qAG!EufU{+LLVkoB>RP78ZwZnB)pTx+~OVY@@XB?o}*t z4~8VY7U^vy@IRxTY32a(?ztcO44UsexL5e+X>LyA}03C2pVESy$=C zsecOSpk_oWO*1Sh3u_+1xOrGxm$9WosZJzBDAMYva>APgy__^7&86$7>yHUZ+k3nS zdZsP6pf}ViRDtJj+F0;>bMAUavlKot@g0O?+gt&3egjvZqSnP$7ogpaSxAncRKd8& z$X2aJq)phYxKO6s{U^qpuD=_}F)t4X2@YYv@BI!BKhH5Y>7H%f0EcG$8;%YCNw ztlpjBW%Vv@Xad~GT;`~^?OiJ=W&->TOz!fB`W;dE(i?w?P!kLpXsl~9S6x3tl{l=P zfx^EN!5Ht7cd40LvvBB@NU3HJ+7V2tCUyUx$A4qLLZtr-W;8_#{7-}$Mv{ILS5ZgF z$505?Wa(EP_kf&fQMcbaqARjy%gyK{0cfaul1fudB*i|R;^tX}zVmJO#G&^MNd4hZ zkxIdU+Rf-z>ALGwtH3m}JV%;U30raWv(4n0bUL0>OZHb?Z|>=& z6>U1Eco8pc|5k8wlk`OiW))y&JL*KWi}5k=>^S@owXezQ=@)-`Mcv>-su(tC&}s*i zxY>Q3T`c)MDhioH&7jhk8_ChJli0!hV|EvekM=_q}l zTXnGaOXDNv!!(%}Fk{5lcU@{5nc403GSi;-&`%A!f z!)eb&fNO>Xn*r_u!#4xH3)Q*IdW70t|3(`C+t7hySeeUF#0W?x2&2Sy$57Mi7H1&n zA=6DEkL~>Cic(p;-#RqLl7h$?A?QiL#4mKr8WiX}J2D zLJnuw!1p|^{%jBgG24I+4m|9h!N9w}#at*5wE{SrCI0+BEL&~XM;9jV9)ztKEx^0W zHzVd`GvIyMpQc`4;tF&Yb%vS5U)T=t;+-L=uGzm7u<~uPo|N@WymLt0<;+I19F<2G zMrl1mme{bKh@HQ|{@j`##OG$Sxu`?8_=CIWwIo~)4%!l~aUKPmz?0c;IL@Zz(!oF-b_7!-^Ef&@P{Ps=qYj~JbWsirxr5-4?c zZf)kLTv^Rm4GWVC1GoZ1w3wI7X!pVo(Y{YtI{6sx|!CcLc z*GPG~+%|!hU!D1)<@-0$@L_D13u~5sVaScibt>9dx{mNp)?5;k{RYyqP4<$4>5Hn22m2K=W9ey^Rr2l|0$b?z8)=AGFNQMN)jo z`dFj^w5JVbmwP}X>ifNW{BfOxj9-k8^l>$D>7qvkRoB-orLVJ&omrE>L_glUsg;LN zLdCeMNjrUY&8`i>92eRh&Hk-k>ad7Byp!PxsKp>jqIFu5fe za_WgkpJJnF&+rPj4?*ZZ=iCDp>WY`;FXlgw!%mLGg_&lO(L)rmM6VbNhwD-}^Ez@sXOY&qtP^E;yW zqxzUXI^7%M3wvafl4|PjIs)R?RG@U~F-p1nBf%N6h5$r!=x!F2L8$pa4(unz+zi&F z0qN2^c943<$0j9eN@m5@XDn9j2A9K5S82a($pRYtGvZ!S^f-D&b(SF*V|zg`$_WYE zdjg`wPj&f5$F#1iQm$smvYs11`U}X)w;DkVj8+g_Qhi8nq+Zz%(wM++}KD|fA6#gh4d+xCr{^L)hbXLAj(9Vxh>)#2gvU#t;*k;38 zjwJq6vt5PHtE8EyD66Xs8tNuCg?5o?tv3)~)Q%9P4f7^Wyk+qZivyn^&eSb0nG{dRYwuYm|*t71K?qXtRcO zl1wzHbY@DsH+h4!|1Y&-JHwWRd z>Kff_adC^}h5X!T;j~6{_FvbE%2-&{M!QW)KNK!P4rG#hrQ1n|NF20^X1;5gpd+>g zU4D6gazXLa4PDHp+@3A2G05Nf2>jlcQ+h1=!&B81Z|(OdoF_E`+r8tb6t|!ean+3o zCg%CP#AB&7ZQ<||iYAHoZ3Lom+u@?5N@^doJ0xrZu$DdP!s7Jnui=!+a0M(Bq_X;> zqLw!Zp5oX{2316vX-&J!&B<|I)ip_sj4C-SN0Je!q02pKZQ0JLR0dZ2g1neRDlS=Q zh+bhJr9^H~Fx=|R(d>~?$I4oL-cH1oA1+hpy4WsF5{=d zWegFbDbe^3w!Fwz*CA9=*Gr!&*VFNW+Y#W2n$3k>!kf|x_E2puBE@JB_3nM+n@2X* zQcYev$7KwKI4O@OZ#dphl9ux3&&SqH6x5hRd#g8=@`#(}M{~8mYlg5KjMyZc{0Yk> zp1E!}J=owYxHv7Rhga)UiwEXXM-r ztUm@Vpff~C#Z<#59s=G(mc)(5q?*6D*BAKA4Q^O(j%60xtdZ6Bekl+H|x}3pVekjBShA zHFLyQ3n4-+xe0hJ+3LgBY}j)}v)MMY$MpsxE=o4~9<12lix_>vDW{B`##ZCvkoWA_ zqv~NlXdlObIn7>CuHACt2msu~7r)gn9t z+aH&*7mC6tG^}!wz|cn0m9OhcA8YVh_hQ7hcJ9F%B{M(^uWf8}rkQy#cw$s!ayN(k?D!W zTX#SgUgpYba*f9g^Q)S;CydDXD;x+-4|O-STpw0Bxer$to;&hET(#+dn5IwOt>yV- zVVh+$6`V(dBVoB3{oq9Yu5R9Ck--<2q3dVmQK~}wiX1x=pP@VSflaaE?S!mEl^*@;p@;6As(%5x4KGHM^a~9IBP|&+ zI~<&|CtiR8}!A^}n4FH$&-8@1e%O6O?ZILX}@UDH^L0(QFM!ZXHs`02T zPSLBeNkP8o8l4YkIbU{V=ouS-5+`;|V_4b#AbT{4l#Jbw2KZSPPM+*@NX^&53XE&U z#(r9_e=PoDr_p&U^eHRY^t2{1`b|t)9xxQ|a`q?td$MVShgsLjXPlj8mYw(jjiX>R za0_DIOmzODD@jn2{B^f%z8=D4N=?Sfv*Ta$)&HlM`VZ)!|9qBOY_zh1ybQqS0`(Me z*vfXT9?kt!`{n}@JW-E$gZBX%&cHw{e~b47 zKKzEeXO0qIGVw>;UjWVxh?mbW(V5qf_s!l+{l=)XRb60YD%-+0XHSHpwbIQ?zOV9& zAMH(|h0EX1L~aTA3SF!7urxxxVM5orGA=!-@a3fL`@Uq6wd;04zR#Rz_ziG@gQ-HI zl*f-OE^lo?x098ZP}Kh5#!A-Ob@{8eG}hTfW#?SAj(~Qog6I}>`e$7`7scwT$r3-luXQRO=3q{;+z=$H z)jN}4H!WQ0DJ7+Gt=+4P#yj)GX3fcY!B+N2@6BNHvn@wSQDQkM$=VBX9KUV>NTS zU5-;$hPj^+=~T?qd#%J(Vg-#RlN7Mu(0tEB!0X^p>vJHfo^QMY%pGz$dPAJG9sZ|7 zPA}_K#w=E^oXx~xmDBQS^B;t0-2Z<3TX&NG4d%euh``sbDJ&??S1knNnuE|1ZLCJT z;qK0D`Q1Cn4ygYGv-_Blyw7m&O<9GIm3b`HVT+ z)^a^gr-uD|Yw?o@`oM2K(q@JDpPv!oAQ&?xN;iPvMm4vMeKX9=-|y)>iJd(~8VRoY zjR`?ckv8cSVwt|Ji96Fn#X?qD)sCY9o*5pTXSo)0w`{Gb>4?98qK3sqpz2)>mecSc zDjm43TmRN`*;jTn!)mZZz%i^w`g`ZCtABj2{#B{$o9BoH6*je*C6*IhcJ?QA= zds{IXtEKL07CTv^t4`Qg9xq<}JQS#LBf-%nGOvt)Dn2zH4c=3JD$2y2Y zg=`72CldRIm$NaxoK%~s2jY-isF^jFRq3}8L!JwAEFGy`6`1HO4U0LWI`t^gFS`Zv zRFtTz%jO_sadbU|_4(Gcb!!Qj2#cf5-=EWNPEsL;T!nW%$PvTr-FMH9Esmnr6`CWy zn_9>k5^nzJ@w?{{x=w=(Em&2^_6Y5hCdL^w~LBVM5g~Hs<8((in_(W|8^ep?qdyu}twd8_%_4N3RmJ z*>Fa_Of)sY=Zy?o>THl=dX4o`X}B4sNq;hOY0u#e;%|;iKaO!Xj|2N;arVI@oW2a| z$qKHu_1|)ypf_0?D#}_DLC@Hr%82eE-GY*}=UGXi+bg-(K#%j&51-9vYZ%^H5Waga z#a`g27A>ZZkv@Nhnu#?I)JGN6){zl@3TD-6?^C-_5T*s|-g?)fUh^VCq~r|c2u=#% z;db&T)0C&Hj6~;mIfZ!yg z#U>9l=$vod@0E{aWpZa0^-=Im14&hd;^S4iEpx0?8R7w2X9@|9D6J;tV7!JD%Vq2I zz6k?>_P!c&iP!Wh+GJxw88Hzdhz7MNfs&qQ-Aa+_B0tjYJ#qHRUO#$0{e@bYqKXXxl3$>P7zy#7h~`nSa8|IEelpMi!W z@1;IL(F5>w?`{M2RfqWxzVi1aTe{O)b9kX3Zx%UQeWUNA`w<3#-r_gYADMw1_T}ny zkE=K%vSMzDLph*T#SGLZo)M>Ha@I}6_azZU6~!LZMx3t! zAflsjI_I3~FDnTwCP)?a2F=@L70ExcG!MeemLwCm8!x(=Kn46#Usa2!&1`6Fq3(f|sHFVk6{IL%`wo?nD#n}jVOjc4aewgcDoJhYZi#_GjG#~Ss0y#wxB8g8u81F}ZE~At7md|y}T8FcjxmJ!t1HM!>`!wQvW&J`nWjs&|wR$BN)FgMYSfQe2R(tO*mA;Z71 zBYu?DXKi1rsF`=CB}bl@FDk`XhYvOd7A*N%m&lLt?tm3fGIT_h9El1SIL)}6neXR;9p_Z87m9JlaIQl2OrC>9coX=^?DX&Zi@ z*7v@UHT-Fr>J!rK7jzdTX{~lOBkOamK|Hj}2fBhzho(D)CdvVe>_hP<<%A}$1jl>q z2vbztm#V1Qo?!P6$DDQ(S+Gmn)jmi%u!kLWx5hj9+P)rrOAoE526FvVwIxX5PZ5I(Y$XTLqL3^^ zahM8&M5Fd{)2I*;Z(&2hwln8ix^lsHl}C~vXM$U5dF3xhw>Uuz!jr$5RLkvkd%v@b zlt!uw<|r8eS(!+WcKTWJnr|7YA8hE#&L;U;kac+ppqEL1z=beT-{ z(0lB46ZKwxdjRmvMx;krq*m;(0^+g=T;{+f4Y;!Tm?Xd}EEj^PMJC}l+%(a|e8 zgkCX?Tr)jPIyovJaPf0IDiX0cT2;J_$996JJ~t*+UIRQjS32K@v;F*xLLA+n@-x1o z+GI{V{fAq?`fYMRo8|DMl?mR0El*fogJcbgQ8~)G%&PmY@VJm8J8*q}J^>oW;SA>@ zpNYbG#KgZqIQ1m@nU)qU(GOz)fz*8$s;@ubSG-(PIC_5piYu^AKeMn2WeVD<^fk@r zuj+AgX>UqaB=uu^o>LYwk=mFgzIv|YsA~3+CJD)j#QSRSRZD)PW9FAVuDoM9QU7FX zs2g(~N2fvOK&A%p1T7qqsHVo-#+epHns&%~DfeG1zI_Uf@=(KkA!f4WAFA2DfxDFPB<8CYq=X z*7-`PAOdGcMJE>=%uMbs3~_w1aSRbLjg@9Cb>s99I1m~VjHTOX9@R!Hrub>?Y#RLV z5|OsHR+5DcfWtyJ*$*HBg`uH9)I?VR1#?3HFGhEc|+SP z0lK`!n4sEvus&%jsl3+F4dQSo;Dl5BQR3G-_ zgmXOlUri(bf7{j1Wwev_Y5h=pb01y;Wp?6y1PEV(6_P2}s983}!H6M`dF_D=1$gNh3(1{w3P2JmFQtGr0Znl^JZA93M@{>=0z+6Kdd z!cL=O^(=xV%s^8`4%g#<0Y7H@umit;l_T2QNRc3}I?Gm){LU+pIiek0 zg*88#?L=_{Z%fi|U`rTc5u^Qc)(6m#&o`-cyM7^7^C#^UVR&|zPT22LCL#9V;YMAy zc;Z<_xG(Hmk65Jv{&GRgTS1?e`yVgEqvwi~7M;!0AqpMCW_-yG_*E4qbOYF3Ox=Ty zEw{3rpum$c2bJOhRr@lx*gAm-!OSl;S47B_WrfSH?M1r%j7+(}b&VI#y(sy9eMLk# zBu~H>7ZX`0sz=fX-r!x`C3=Z{tSydtq-vkY`MpKoVmfBf3c`@$2$IoC-*U+|(PT)m zE0w4HNd%xR-^3d&hWzY_ydC}H1Tp*iHdf{1w8#Dzh?y39>bMMRw5u2ef<7`9p_u0E zpLZgo^D!6p+w8;RtQ2F*iSL8n-yxj5AJ6wvPct+=Pw|UF{ox9Ui5;fkzCC@3?Sj4j zr_D>Y4h+mQdIB`X!92?yOr@7tCV4=n2mh=F=4S?%#BOiInWC7>(1%+*#t ztN=Z2Gb=>x#`d<^=fOHhjIu?JqHF1RBs|gLRJQPd0|r#Q9P%U2ilbPr_FG@)c__`( zGt;ZO9xPIKVFg=iBb~2-g&x|D;=f>3?UyP~`amwg&pm~k>wtB`JBXd&0QPHvz6|%B zmNP4uP5(jDwQfBE$t@CXvI3=3(PCWM-VRHH*FA@-D-|;6+pFc?99R_#>n0OK{H`Xy z(N$QjnY^Q|vw{~ZTj5XV6eyH#2-L1l_|`wS9O#r)Xok^FVF{7(9Mb4`A$W+@lMzSy z8I)`+E$MvS647FU4c%v#uH4k>sHt2Y*PpI!ChQs5T=oRsS}%p{uedA|hcHhE2FkJh znAuL{z#+)zQegQIw%ajGquUk@9)Nw?7D$={tD7i?M4tm zgB&@}N7`M`g->_Nb>(a+&5m>KhYR;U#8E9}AoHXQ5of;7Yg>`afesi71rp|6s9rsDzKFQx^7O}Pj#7?@8Nw% z)rMr)1mAml>6SVb>k-fgeL#aZ(4h4f;HJ6cbc-@7YJyC(s@~={(~d7$wb7ulqs`Xq zT$+fd(bthL*Pyffk5n-a;2@z6Z$*d!dCA(+xEgl)B<553>8|}poGr30JYb@YUiS6I zuj=7=y}WPjr8w2mL2eZxv>0N<3H(v&*coVGfh?FrJxy3+RdEp)I?6r6K2 zKfURZe5YRYr#OC_=fjV{JvDK43k9UE_rrDFlrh;*x_#juA`XTxIWZB}JHo{uXr!Y7 z?dW{L_0~^jlleMQRIMiI=Tz?ove~Hr0>A{(HNN;u(LsJ%T8y57k)~yyB}-&qw7#U~ zr7=A%<@UR<=M0*AQ0b)&Ba#&^;eV*v+jWxDq5K_Q-_5X?Z{CbkFCk<9txuNrAW{p; zV)SElqJi`671MFCU9|nlqD9jn52K@B;`(akY#{4wv->6wtS+qH+8sFW^@Vw+);tt( zJxRSc^#ZPlDJ{09_G1tg{1&3Dp=DzEsdPa>coqF7$-YCzm;89Kz0sk|2yd_T!FcGE zWR`tQ;Oz#%5Mnu6q5Rk3qf8dnJm{-2#11)e;#58`ygd1~u?89yyX@9ivhDBe&80fB zBBRj)(EDjvegZ>;UsauQN<@meKh|SNq&=l=m8iON(&fMx*}X4Hs1bDd!|71C^a)@` zBKR}G*j}lI9!m(})_ZxJVQsgqxKmpO`Qf-Q789nm*?Sh;&S*we$1+dCe=YOf-XbB>QB;%o z!QIPSYpo9=)``4JT5K+c3-2PnMutn3rW4g{Kj)Qs{jN1(GK|xSg^)nHhXGQA)JTb^ z#m)U6OX^(x6jL4tYtKksW?Ki10sT2Q+D)3@6dDczM)?mD&~fGRd|yELvNMGLKZ zKgmL9x1zaC*7ZAMK@yiqdJv2$HcFk&{*emYhJ!0X(~IE1URAED{etYY`=F{dV)gxj z*d;?nZ3@Zd$nN0FgYobHhQ>7UHyqV^>p(v7485PHnl2qR>L_^KrU=w@u6n|$&+DC^ zkYE#7cj!K|%}D6gnbM(QRNX-j>wB2rjRM4YxKVi+(oO}_aUeJMV3HY9>$lf3`VH8p zf86w>oH;MUtDiSDt* zkkcGD;4m9IZM%`BKxO(`UF z0(LD3{&>p#F2f{C;NmDQ|Mjvr!8Kli8QL%>5q}v-H!xQyciZaE(PBHeAWU0I68fyR zKy@)BpbCFmC7L!di>-Dh*WC1aE68`zf=)zK>cqzSL-4}Q&xAVxv*J5zUQY#dd-y$b z*j==RSiQcsalF zTkq@D)_*Tov`BF(a3i@X`dvTW{DN^-i;JmT5>%m;h>@@MJ*|#>6#Glz`Ry%C)0jxX zEnTlWs#BSwRz$6Z^@ApYX<=yzeDWr7?{<2B>lt`7+$q`sdJIEmzoV zUKZff^Mx+TNUVMUT0H|hXD2l zwAe6}8$4}#3@Srt0mnap2ioVI;@PX%mJ*_JpO`j2irp8bCU@EyIMfYa=WBU<4YfU! z&B-;B&i6#06H~D%_V|9E`+Xq+B0(Wgq3m_hnCXFKowoL5odPVdjlG}$M4hP?YKR+x zdP6togSiu_C+=Z<#UZlnTcl<&?+Jp9_pQk^NjlG}hJ>I<(J0d{csiYKWQu-iFC3St zCX*$cGk5L1pw^c2>`*dyQ198-hc&hk=X`RyYWs*~Yw+14X zRa>+D!W6oHyFA{%i0Op=fDtbByYtsjtwlpr@zR%dqs*BqW9>n^cB)+5(%YOjKM;c- zvsq;Y!#40n6(r1Ng-u;DO*>zbcSmzT7nOc(HNkInY1yCefQ3?I_e?iyNcIOSCHR8) z!iQC+-Oe@<++mKfkzJ!QdI!_o^zH(F@|MbzP^u`sVsMq|PR3s!SmX2%{twGMT{3PFXwh@T;CBMvMR31wE@conv z$l|KP>79Beg&WO`6#A<~^f3E!zU9eo*wu?CRMQ5N;p5F>bIy|=YZ6{X-2ymcox`(M zX6w`$h`m>W2kYv+;^COme#tV&7>$CBmK;!(l8qimdLRu@EdRp7G_izm=kiy9qaCkF z>>!s7`g28$#f|~*9vLEJ&MO&zFfZh~XnwyjSubeqEI8~@3CYhhz>fHo`_;>z3-Wto zM_q`IRN6GRdU0V-5k|B5r#1EI!BK@5Nug4W&x^QRyc?K+J$~!9`O1TeA>}L6If#yZ zYq!^+tqI|T4?PPN@D-p?#D-u?mgYlfzLp(gJOls0_CYE5>G;qafJR+%*NN}OhShjT zX@fwKmc37e!Nzc_esZ?Cc5>@Vm%nh(4k!lL^^OZ2qn@j<`XhzzaP+id4 zN?!QlQ1}u1AW3nDBXS0)6lY(_U);WN4BAARxf-K^236#6^f}h0h_wp7CjR`S!<49& z^hI)u>rp(zw-NBP$^W@t@X%;ZELb0Vm*KR=)yZth;6cInF|+ceS=?>CinLfowjp8!P#nB?+ zt>>j0@R1AmtzTp{eI+-byP-c<)fE(qSNil{O5RhY?3>#^66)J1OLz5P+bUg=S9&0> zE6$=4{nDpPnvI0iQ8(JDqJ#AJ2!}R9@zx=;K51fxdUp#HLqG!DKIAvJ(xA4cHYjyfhg);HLYo))z8o5?6*1_|4;I~Itg|;5> z;IC|J?V9Nvo%}l=7?%m3vQ(#t6!0YuETJQ`;c5q8`NU{l6OB1ewHsu3(d%ZJ<=cKf z;3bG}yW7AaV8-hZ&xBr~3#cmiVBq`-Ba6c}wAtxR^TJ*(1Um zN3%=ILAS8np5r`4$?v0WtISyBOSus=qp5aqMKd3|8*}kwP{@El6ocDH<^--HhmtTO&`-nJsuO}RxRJRK&VLZXG7w5Ep?%1gOn+q!<6g!^Tr}=B z3NaBNsHwOWLPC%|3%PCyNBtxc?c;^J1lTor(b<&x{GxGd!!)A3OWSN6N?x?eP#hFm zH-%D0b5#kjS!uPhyO&{qnl`;B#jjsyqTxP0iQA?$C1O={Nl7)*4(Sk9cWot0%s?`e zVs6^FS6CC%J@}|G0j|Aqon&nxGRjU1@4Bo>`xs5Gcq4AiYx%T`rWfI`+>z~or@QI& zc7Qw7$5K$#MMf%^?zuRcz;tpxGW9OZL=tXg^|{mMlT37#9?ym^IioESTg*w(vC0)8 z9ts6Vg)af#8=5_qann{=4D)A9V{`@;$Mvo4Y zoEl_Zf=h97;`(_Vd$P0E3GYf@Mi)0)@iG-2k7UsMbXm>hG6Po|pDSzk$u89R$WP>| z=1@s;Ixnkb+eDXGN(c&0ySD_53<{NiJj}C}=vG8I@%8mglx}d7*oG>fzuUVf_JSgT zS@fEYWFu!n-#tz&D9Z4jmx8@WD8ZGcVY|y$%AQFS1_>n&)CM@0J^e72cm&I;8IRIY zyvw*<=6JD`pxb8nrquD~sc_gQ5nox!bBahKuFzLYkh@CAf`o2n7IEz*)pYJ{m$78G zvD(Mp1Zytpg+b$mD|n+Vc(SOEBiCMEKZn)eBI77K#y-~f5!_IPDZWzmysCw6NujgM zSetb9LEWAXSAk?cD#XGwS^jKJE|wps{OO@zX<97JxLbMQ>RAi{^tI_Z$7`}Z-o3h& zg`EY~*1g11ciPHY@Di6y+>8S_>OSZn2)?zP2Mw`eYJulTD|xM(fBMg7317z`j>%MV zEBXW;$eC8e!}p_qqLW_Dijw6=0DrfSLGfC<=DGN_XbS!}0;M~@&%9;T7!vvl$%Sgr zCyUWmL@BNBqYi~wu$9P~QQ-vrxtWR{nzU;5pO%^ovmCZUzxer}*D%}sJOx+4N!AJN zkgn_yTH5M?Q%ugX-E{^XU96)Cw?tHlOM6H?yi}sY66Xqu)H{!$^bhFI*B_q z5TiyHVa63BU$P7`31nwq_!>8_U+V$Bl#y$bP&G`r&f{^RyTCXv`<3Wl>>&3yCJz>4 znI$_R!N?&n?X#GybBcoYvzX@~XQIAov)(CRTngvwFMYukBebLxq?ZQn_UXgWV289* z5L_UA1B5BvPj`-2=MrvLd}D?{fnvd!f3&qJw4^t*a80&b2Mbce_t1PnnCcC@fppK8hdm zy_zZzDxM7Upy{jEkJnWvs+%2F^+#%YXo&=fT>GBT#D=^snl`dcFpJ}wm@MBvt;C_L zzOhp_4MVyv>dlFx8B!daAmw`Q~Y%J|a+cUzu5IG1_l1qjZe9uge*Q0H|M6FeWZ_W`q zg&dwYV8ggRX`5&(dD1bJVu8DCL6LsPzE#A;StWLHbJ?hk4;q)5C>mkfW9gm0Nnqie zmnKp4=uCv)VoIQ&^i@sN`M?Cd26mdEKpyKq$nz6+^trhj+hWJ!4yNV+ zT%cb(P54-k2dIq*ht)Xz$<1#goYPMd3ukgJoSa@Oj!%N5Z}(6>IGus@Is)Ci0A>zD z{scUj*=J^%4PdR5Z9Tl#nx6FV+lE$?06>jl!+&%J`=fK!(!eQySTUX&r_8Dan!el~u5WnPHGmb$u3Dw6SNcDosK5Iv|>UYB}`1VICrgi%MArGIjZ9(`{ z_wNx=aqGmXNy$lxFNig?8_@h)eD5P75Lup`P|!!|q;k5n7b^$W0|yfA2_?XAMF2X7 z*FyL={bPtLfMv26LT&!lndPs7U`$H*;Gi!~zfhNoVVkvVuf^|H_!%E^jRNBKriHri z$|M3GupY#WhOhTheGmHkf)%=8i3^*+e)PBS;6wAb|E%`P;pNuIAfR_wy%zE4@o(|M z6)#0lD@{LZl&_-qCH^SSH)*%x)xYB#@7jigl>+NE`Tv%%?(j#zA%Y;Ac(1t-Q2^k^ z4(tn*cK8FA+L6C?ZETld$3Q&%^zV$}igxc1Og>Z|1>)yEO3fi4EzsQCKL)WP^jjOS ztLxQMKjbb*koJIr=YL>i-m)dr+L7p^UD2FGc@^rmE8YA*jUb0-?b`DK!aM$7M`4sA z_J;NWmrc7X-F=ZFf$Lr|K>MLTv!2BSHy(LMuIREt}H#2aSJ&TlhY^f00IMAEirGHk8LgVcjb_>45Q%5s>SR zQ}6o#H48w(KmOxA*%NLRJ37;bHQVvjgy`jZ~x%X2Ot~}-|N4H&w;lrTVpZX)tdWoz}O>ee!qa~a(|#i zE|S-nssNC5Kj~k)Lyh`h9LW(NnaNF*d?qe)A8{omgfk5Awfq;%FxFjLJlGez?i>R= zvijG4=(?-5h1ulnF-t^Y(k|da4Eo2Qo{4`kZNVASA`=Qx Date: Tue, 30 Sep 2025 12:12:46 +0900 Subject: [PATCH 102/135] Update ci-cd.yml --- .github/workflows/ci-cd.yml | 38 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 447e32b..2cf9874 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -29,44 +29,39 @@ jobs: - name: Build with Gradle run: ./gradlew build -x test - # 5. 빌드된 JAR 파일을 EC2로 전송 (Secret 이름을 SSH_HOST/USER/KEY로 변경) + # 5. 빌드된 JAR 파일을 EC2로 전송 - name: Transfer JAR to EC2 uses: appleboy/scp-action@v0.1.4 with: - host: ${{ secrets.SSH_HOST }} # SSH_HOST 사용 - username: ${{ secrets.SSH_USER }} # SSH_USER 사용 - key: ${{ secrets.SSH_KEY }} # SSH_KEY 사용 + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_KEY }} source: "build/libs/*.jar" - target: "/home/${{ secrets.SSH_USER }}" # 대상 경로 수정 + target: "/home/${{ secrets.SSH_USER }}" - # 6. EC2에 접속하여 배포 명령 실행 (Secret 이름 및 AWS 인증 주입) + # 6. EC2에 접속하여 배포 명령 실행 (들여쓰기 수정 완료) - name: Deploy on EC2 uses: appleboy/ssh-action@v0.1.10 with: - host: ${{ secrets.SSH_HOST }} # SSH_HOST 사용 - username: ${{ secrets.SSH_USER }} # SSH_USER 사용 - key: ${{ secrets.SSH_KEY }} # SSH_KEY 사용 - - # AWS CLI 사용을 위해 인증 정보를 환경 변수로 주입합니다. - env: - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_KEY }} script: | # -------------------------------------------------- # AWS Secrets Manager 설정 # -------------------------------------------------- - SECRET_NAME="security" # 👈 Secrets Manager 이름 "security"로 확정 - AWS_REGION="ap-northeast-2" # 👈 ARN에 따른 리전 확정 + SECRET_NAME="security" # Secrets Manager 이름 + AWS_REGION="ap-northeast-2" # AWS 리전 # -------------------------------------------------- # 배포 로직 시작 # -------------------------------------------------- echo "> Secrets Manager에서 환경변수를 가져옵니다." - # env 블록을 통해 주입된 AWS_ACCESS_KEY_ID와 AWS_SECRET_ACCESS_KEY를 사용하여 실행됨 + # AWS 인증 정보는 ssh-action의 env 블록을 통해 환경 변수로 전달됨 SECRET_JSON=$(aws secretsmanager get-secret-value --secret-id $SECRET_NAME --region $AWS_REGION --query SecretString --output text) # jq를 사용하여 JSON의 각 키-값을 'export KEY="VALUE"' 형식으로 변환하고 실행 + # 이 키-값들은 Spring Boot 애플리케이션에 환경 변수로 전달됨 export $(echo $SECRET_JSON | jq -r 'to_entries|map("\(.key)=\(.value|tostring)")|.[]') echo "> 환경변수 로딩 완료." @@ -81,9 +76,12 @@ jobs: fi # 새 애플리케이션 실행 - EC2_HOME="/home/${{ secrets.SSH_USER }}" # 👈 SSH_USER Secret 사용 + EC2_HOME="/home/${{ secrets.SSH_USER }}" JAR_NAME=$(ls -tr $EC2_HOME/*.jar | tail -n 1) echo "> 새 애플리케이션을 배포합니다: $JAR_NAME" # nohup을 사용하여 백그라운드에서 Spring Boot 애플리케이션 실행 - nohup java -jar -Dspring.profiles.active=prod $JAR_NAME > $EC2_HOME/application.log 2>&1 & \ No newline at end of file + nohup java -jar -Dspring.profiles.active=prod $JAR_NAME > $EC2_HOME/application.log 2>&1 & + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} From a78126485275bc7368facc05939bc8b606086829 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Tue, 30 Sep 2025 12:20:27 +0900 Subject: [PATCH 103/135] Update ci-cd.yml --- .github/workflows/ci-cd.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 2cf9874..a51d476 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -60,9 +60,9 @@ jobs: # AWS 인증 정보는 ssh-action의 env 블록을 통해 환경 변수로 전달됨 SECRET_JSON=$(aws secretsmanager get-secret-value --secret-id $SECRET_NAME --region $AWS_REGION --query SecretString --output text) - # jq를 사용하여 JSON의 각 키-값을 'export KEY="VALUE"' 형식으로 변환하고 실행 - # 이 키-값들은 Spring Boot 애플리케이션에 환경 변수로 전달됨 - export $(echo $SECRET_JSON | jq -r 'to_entries|map("\(.key)=\(.value|tostring)")|.[]') + # --- [수정] Bash에서 유효한 환경 변수 이름으로 변환 --- + # Spring Boot가 인식하도록 키의 '.'을 '_'로 바꾸고 대문자로 변환하여 export 합니다. + export $(echo $SECRET_JSON | jq -r 'to_entries | map({key: .key | ascii_upcase | gsub("\\."; "_"), value: .value}) | map(.key + "=\"" + (.value | tostring) + "\"") | .[]') echo "> 환경변수 로딩 완료." # 기존 애플리케이션 종료 From 21c52c5c052f087216ce56c26b2caa1d8c55ed6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Tue, 30 Sep 2025 12:26:01 +0900 Subject: [PATCH 104/135] Update ci-cd.yml --- .github/workflows/ci-cd.yml | 55 +++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index a51d476..bcdb9a7 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -1,34 +1,31 @@ name: Deploy to EC2 (Self-contained) - on: push: branches: - main - jobs: build-and-deploy: runs-on: ubuntu-latest - steps: # 1. 소스 코드 체크아웃 - name: Checkout source code uses: actions/checkout@v3 - + # 2. JDK 17 설정 - name: Set up JDK 17 uses: actions/setup-java@v3 with: java-version: '17' distribution: 'temurin' - + # 3. gradlew 실행 권한 추가 - name: Grant execute permission for gradlew run: chmod +x ./gradlew - + # 4. Gradle로 프로젝트 빌드 - name: Build with Gradle run: ./gradlew build -x test - + # 5. 빌드된 JAR 파일을 EC2로 전송 - name: Transfer JAR to EC2 uses: appleboy/scp-action@v0.1.4 @@ -37,36 +34,38 @@ jobs: username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_KEY }} source: "build/libs/*.jar" - target: "/home/${{ secrets.SSH_USER }}" - - # 6. EC2에 접속하여 배포 명령 실행 (들여쓰기 수정 완료) + target: "/home/${{ secrets.SSH_USER }}/app" + strip_components: 2 + + # 6. EC2에 접속하여 배포 명령 실행 - name: Deploy on EC2 uses: appleboy/ssh-action@v0.1.10 with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_KEY }} + envs: AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY script: | # -------------------------------------------------- # AWS Secrets Manager 설정 # -------------------------------------------------- - SECRET_NAME="security" # Secrets Manager 이름 - AWS_REGION="ap-northeast-2" # AWS 리전 - + SECRET_NAME="security" + AWS_REGION="ap-northeast-2" + # -------------------------------------------------- # 배포 로직 시작 # -------------------------------------------------- echo "> Secrets Manager에서 환경변수를 가져옵니다." - # AWS 인증 정보는 ssh-action의 env 블록을 통해 환경 변수로 전달됨 SECRET_JSON=$(aws secretsmanager get-secret-value --secret-id $SECRET_NAME --region $AWS_REGION --query SecretString --output text) - # --- [수정] Bash에서 유효한 환경 변수 이름으로 변환 --- - # Spring Boot가 인식하도록 키의 '.'을 '_'로 바꾸고 대문자로 변환하여 export 합니다. - export $(echo $SECRET_JSON | jq -r 'to_entries | map({key: .key | ascii_upcase | gsub("\\."; "_"), value: .value}) | map(.key + "=\"" + (.value | tostring) + "\"") | .[]') + # 환경변수를 파일로 저장 후 source + echo "$SECRET_JSON" | jq -r 'to_entries | map({key: .key | ascii_upcase | gsub("\\."; "_"), value: .value}) | map(.key + "=\"" + (.value | tostring) + "\"") | .[]' > /tmp/env_vars.sh + source /tmp/env_vars.sh + rm /tmp/env_vars.sh echo "> 환경변수 로딩 완료." - + # 기존 애플리케이션 종료 - CURRENT_PID=$(pgrep -f ".jar") + CURRENT_PID=$(pgrep -f "\.jar") if [ -z "$CURRENT_PID" ]; then echo "> 현재 실행 중인 애플리케이션이 없습니다." else @@ -74,14 +73,22 @@ jobs: kill -15 $CURRENT_PID sleep 5 fi - + # 새 애플리케이션 실행 - EC2_HOME="/home/${{ secrets.SSH_USER }}" - JAR_NAME=$(ls -tr $EC2_HOME/*.jar | tail -n 1) + APP_DIR="/home/${{ secrets.SSH_USER }}/app" + JAR_NAME=$(ls -tr $APP_DIR/*.jar 2>/dev/null | tail -n 1) + + if [ -z "$JAR_NAME" ]; then + echo "> ERROR: JAR 파일을 찾을 수 없습니다." + exit 1 + fi + echo "> 새 애플리케이션을 배포합니다: $JAR_NAME" + nohup java -jar -Dspring.profiles.active=prod $JAR_NAME > $APP_DIR/application.log 2>&1 & - # nohup을 사용하여 백그라운드에서 Spring Boot 애플리케이션 실행 - nohup java -jar -Dspring.profiles.active=prod $JAR_NAME > $EC2_HOME/application.log 2>&1 & + echo "> 백그라운드 실행 후 SSH 연결 종료." + sleep 2 + exit 0 env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} From c80497dee0498b698296cc1d463269b793519e43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Tue, 30 Sep 2025 12:29:38 +0900 Subject: [PATCH 105/135] Update ci-cd.yml --- .github/workflows/ci-cd.yml | 135 ++++++++++++++++++++++-------------- 1 file changed, 82 insertions(+), 53 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index bcdb9a7..31b881a 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -39,56 +39,85 @@ jobs: # 6. EC2에 접속하여 배포 명령 실행 - name: Deploy on EC2 - uses: appleboy/ssh-action@v0.1.10 - with: - host: ${{ secrets.SSH_HOST }} - username: ${{ secrets.SSH_USER }} - key: ${{ secrets.SSH_KEY }} - envs: AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY - script: | - # -------------------------------------------------- - # AWS Secrets Manager 설정 - # -------------------------------------------------- - SECRET_NAME="security" - AWS_REGION="ap-northeast-2" - - # -------------------------------------------------- - # 배포 로직 시작 - # -------------------------------------------------- - echo "> Secrets Manager에서 환경변수를 가져옵니다." - SECRET_JSON=$(aws secretsmanager get-secret-value --secret-id $SECRET_NAME --region $AWS_REGION --query SecretString --output text) - - # 환경변수를 파일로 저장 후 source - echo "$SECRET_JSON" | jq -r 'to_entries | map({key: .key | ascii_upcase | gsub("\\."; "_"), value: .value}) | map(.key + "=\"" + (.value | tostring) + "\"") | .[]' > /tmp/env_vars.sh - source /tmp/env_vars.sh - rm /tmp/env_vars.sh - echo "> 환경변수 로딩 완료." - - # 기존 애플리케이션 종료 - CURRENT_PID=$(pgrep -f "\.jar") - if [ -z "$CURRENT_PID" ]; then - echo "> 현재 실행 중인 애플리케이션이 없습니다." - else - echo "> 실행 중인 애플리케이션을 종료합니다. (PID: $CURRENT_PID)" - kill -15 $CURRENT_PID - sleep 5 - fi - - # 새 애플리케이션 실행 - APP_DIR="/home/${{ secrets.SSH_USER }}/app" - JAR_NAME=$(ls -tr $APP_DIR/*.jar 2>/dev/null | tail -n 1) - - if [ -z "$JAR_NAME" ]; then - echo "> ERROR: JAR 파일을 찾을 수 없습니다." - exit 1 - fi - - echo "> 새 애플리케이션을 배포합니다: $JAR_NAME" - nohup java -jar -Dspring.profiles.active=prod $JAR_NAME > $APP_DIR/application.log 2>&1 & - - echo "> 백그라운드 실행 후 SSH 연결 종료." - sleep 2 - exit 0 - env: - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + uses: appleboy/ssh-action@v0.1.10 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_KEY }} + envs: AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY + command_timeout: 15m + script: | + # -------------------------------------------------- + # AWS Secrets Manager 설정 + # -------------------------------------------------- + SECRET_NAME="security" + AWS_REGION="ap-northeast-2" + + echo "> Secrets Manager에서 환경변수를 가져옵니다." + SECRET_JSON=$(aws secretsmanager get-secret-value --secret-id $SECRET_NAME --region $AWS_REGION --query SecretString --output text) + + # 환경변수를 파일로 저장 후 source + echo "$SECRET_JSON" | jq -r 'to_entries | map({key: .key | ascii_upcase | gsub("\\."; "_"), value: .value}) | map(.key + "=\"" + (.value | tostring) + "\"") | .[]' > /tmp/env_vars.sh + source /tmp/env_vars.sh + rm /tmp/env_vars.sh + echo "> 환경변수 로딩 완료." + + # 애플리케이션 디렉토리 설정 + APP_DIR="/home/${{ secrets.SSH_USER }}/app" + + # 기존 애플리케이션 종료 (개선된 방식) + echo "> 기존 애플리케이션 확인 중..." + CURRENT_PID=$(pgrep -f "java.*\.jar") + + if [ -n "$CURRENT_PID" ]; then + echo "> 실행 중인 애플리케이션을 종료합니다. (PID: $CURRENT_PID)" + kill -15 $CURRENT_PID + + # 프로세스가 종료될 때까지 대기 (최대 30초) + for i in {1..30}; do + if ! kill -0 $CURRENT_PID 2>/dev/null; then + echo "> 애플리케이션이 정상 종료되었습니다." + break + fi + echo "> 종료 대기 중... ($i/30)" + sleep 1 + done + + # 여전히 실행 중이면 강제 종료 + if kill -0 $CURRENT_PID 2>/dev/null; then + echo "> 강제 종료합니다." + kill -9 $CURRENT_PID + sleep 2 + fi + else + echo "> 현재 실행 중인 애플리케이션이 없습니다." + fi + + # 새 애플리케이션 실행 + JAR_NAME=$(ls -tr $APP_DIR/*.jar 2>/dev/null | tail -n 1) + + if [ -z "$JAR_NAME" ]; then + echo "> ERROR: JAR 파일을 찾을 수 없습니다." + exit 1 + fi + + echo "> 새 애플리케이션을 배포합니다: $JAR_NAME" + + # nohup으로 백그라운드 실행 (SSH 세션과 독립) + nohup java -jar -Dspring.profiles.active=prod "$JAR_NAME" > "$APP_DIR/application.log" 2>&1 < /dev/null & + NEW_PID=$! + + # 프로세스 시작 확인 + sleep 3 + if kill -0 $NEW_PID 2>/dev/null; then + echo "> 애플리케이션이 성공적으로 시작되었습니다. (PID: $NEW_PID)" + else + echo "> ERROR: 애플리케이션 시작에 실패했습니다." + echo "> 로그 확인: tail -n 50 $APP_DIR/application.log" + exit 1 + fi + + echo "> 배포 완료!" + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} From c9b9799183105f620bfb9da00ff260a90cf7ee1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Tue, 30 Sep 2025 12:30:43 +0900 Subject: [PATCH 106/135] Update ci-cd.yml --- .github/workflows/ci-cd.yml | 163 ++++++++++++++++++------------------ 1 file changed, 81 insertions(+), 82 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 31b881a..2ac3397 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -39,85 +39,84 @@ jobs: # 6. EC2에 접속하여 배포 명령 실행 - name: Deploy on EC2 - uses: appleboy/ssh-action@v0.1.10 - with: - host: ${{ secrets.SSH_HOST }} - username: ${{ secrets.SSH_USER }} - key: ${{ secrets.SSH_KEY }} - envs: AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY - command_timeout: 15m - script: | - # -------------------------------------------------- - # AWS Secrets Manager 설정 - # -------------------------------------------------- - SECRET_NAME="security" - AWS_REGION="ap-northeast-2" - - echo "> Secrets Manager에서 환경변수를 가져옵니다." - SECRET_JSON=$(aws secretsmanager get-secret-value --secret-id $SECRET_NAME --region $AWS_REGION --query SecretString --output text) - - # 환경변수를 파일로 저장 후 source - echo "$SECRET_JSON" | jq -r 'to_entries | map({key: .key | ascii_upcase | gsub("\\."; "_"), value: .value}) | map(.key + "=\"" + (.value | tostring) + "\"") | .[]' > /tmp/env_vars.sh - source /tmp/env_vars.sh - rm /tmp/env_vars.sh - echo "> 환경변수 로딩 완료." - - # 애플리케이션 디렉토리 설정 - APP_DIR="/home/${{ secrets.SSH_USER }}/app" - - # 기존 애플리케이션 종료 (개선된 방식) - echo "> 기존 애플리케이션 확인 중..." - CURRENT_PID=$(pgrep -f "java.*\.jar") - - if [ -n "$CURRENT_PID" ]; then - echo "> 실행 중인 애플리케이션을 종료합니다. (PID: $CURRENT_PID)" - kill -15 $CURRENT_PID - - # 프로세스가 종료될 때까지 대기 (최대 30초) - for i in {1..30}; do - if ! kill -0 $CURRENT_PID 2>/dev/null; then - echo "> 애플리케이션이 정상 종료되었습니다." - break - fi - echo "> 종료 대기 중... ($i/30)" - sleep 1 - done - - # 여전히 실행 중이면 강제 종료 - if kill -0 $CURRENT_PID 2>/dev/null; then - echo "> 강제 종료합니다." - kill -9 $CURRENT_PID - sleep 2 - fi - else - echo "> 현재 실행 중인 애플리케이션이 없습니다." - fi - - # 새 애플리케이션 실행 - JAR_NAME=$(ls -tr $APP_DIR/*.jar 2>/dev/null | tail -n 1) - - if [ -z "$JAR_NAME" ]; then - echo "> ERROR: JAR 파일을 찾을 수 없습니다." - exit 1 - fi - - echo "> 새 애플리케이션을 배포합니다: $JAR_NAME" - - # nohup으로 백그라운드 실행 (SSH 세션과 독립) - nohup java -jar -Dspring.profiles.active=prod "$JAR_NAME" > "$APP_DIR/application.log" 2>&1 < /dev/null & - NEW_PID=$! - - # 프로세스 시작 확인 - sleep 3 - if kill -0 $NEW_PID 2>/dev/null; then - echo "> 애플리케이션이 성공적으로 시작되었습니다. (PID: $NEW_PID)" - else - echo "> ERROR: 애플리케이션 시작에 실패했습니다." - echo "> 로그 확인: tail -n 50 $APP_DIR/application.log" - exit 1 - fi - - echo "> 배포 완료!" - env: - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + uses: appleboy/ssh-action@v0.1.10 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_KEY }} + envs: AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY + command_timeout: 15m + script: | + # -------------------------------------------------- + # AWS Secrets Manager 설정 + # -------------------------------------------------- + SECRET_NAME="security" + AWS_REGION="ap-northeast-2" + + echo "> Secrets Manager에서 환경변수를 가져옵니다." + SECRET_JSON=$(aws secretsmanager get-secret-value --secret-id $SECRET_NAME --region $AWS_REGION --query SecretString --output text) + + # 환경변수를 파일로 저장 후 source + echo "$SECRET_JSON" | jq -r 'to_entries | map({key: .key | ascii_upcase | gsub("\\."; "_"), value: .value}) | map(.key + "=\"" + (.value | tostring) + "\"") | .[]' > /tmp/env_vars.sh + source /tmp/env_vars.sh + rm /tmp/env_vars.sh + echo "> 환경변수 로딩 완료." + + # 애플리케이션 디렉토리 설정 + APP_DIR="/home/${{ secrets.SSH_USER }}/app" + + # 기존 애플리케이션 종료 + echo "> 기존 애플리케이션 확인 중..." + CURRENT_PID=$(pgrep -f "java.*\.jar") + + if [ -n "$CURRENT_PID" ]; then + echo "> 실행 중인 애플리케이션을 종료합니다. (PID: $CURRENT_PID)" + kill -15 $CURRENT_PID + + # 프로세스가 종료될 때까지 대기 (최대 30초) + for i in {1..30}; do + if ! kill -0 $CURRENT_PID 2>/dev/null; then + echo "> 애플리케이션이 정상 종료되었습니다." + break + fi + echo "> 종료 대기 중... ($i/30)" + sleep 1 + done + + # 여전히 실행 중이면 강제 종료 + if kill -0 $CURRENT_PID 2>/dev/null; then + echo "> 강제 종료합니다." + kill -9 $CURRENT_PID + sleep 2 + fi + else + echo "> 현재 실행 중인 애플리케이션이 없습니다." + fi + + # 새 애플리케이션 실행 + JAR_NAME=$(ls -tr $APP_DIR/*.jar 2>/dev/null | tail -n 1) + + if [ -z "$JAR_NAME" ]; then + echo "> ERROR: JAR 파일을 찾을 수 없습니다." + exit 1 + fi + + echo "> 새 애플리케이션을 배포합니다: $JAR_NAME" + + # nohup으로 백그라운드 실행 + nohup java -jar -Dspring.profiles.active=prod "$JAR_NAME" > "$APP_DIR/application.log" 2>&1 < /dev/null & + NEW_PID=$! + + # 프로세스 시작 확인 + sleep 3 + if kill -0 $NEW_PID 2>/dev/null; then + echo "> 애플리케이션이 성공적으로 시작되었습니다. (PID: $NEW_PID)" + else + echo "> ERROR: 애플리케이션 시작에 실패했습니다." + exit 1 + fi + + echo "> 배포 완료!" + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} From 29ea2b28c7f75b5b2123a1915ed38eb1e021502b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Tue, 30 Sep 2025 12:33:52 +0900 Subject: [PATCH 107/135] Update ci-cd.yml --- .github/workflows/ci-cd.yml | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 2ac3397..27bf5b7 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -37,7 +37,7 @@ jobs: target: "/home/${{ secrets.SSH_USER }}/app" strip_components: 2 - # 6. EC2에 접속하여 배포 명령 실행 + # 6. 배포 스크립트 생성 및 실행 - name: Deploy on EC2 uses: appleboy/ssh-action@v0.1.10 with: @@ -47,6 +47,15 @@ jobs: envs: AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY command_timeout: 15m script: | + # 배포 스크립트를 파일로 생성 + cat > /tmp/deploy.sh << 'DEPLOY_SCRIPT_EOF' + #!/bin/bash + + # AWS 환경변수 설정 (부모 프로세스에서 전달받음) + export AWS_ACCESS_KEY_ID="$1" + export AWS_SECRET_ACCESS_KEY="$2" + export SSH_USER="$3" + # -------------------------------------------------- # AWS Secrets Manager 설정 # -------------------------------------------------- @@ -63,7 +72,7 @@ jobs: echo "> 환경변수 로딩 완료." # 애플리케이션 디렉토리 설정 - APP_DIR="/home/${{ secrets.SSH_USER }}/app" + APP_DIR="/home/${SSH_USER}/app" # 기존 애플리케이션 종료 echo "> 기존 애플리케이션 확인 중..." @@ -75,7 +84,7 @@ jobs: # 프로세스가 종료될 때까지 대기 (최대 30초) for i in {1..30}; do - if ! kill -0 $CURRENT_PID 2>/dev/null; then + if ! ps -p $CURRENT_PID > /dev/null 2>&1; then echo "> 애플리케이션이 정상 종료되었습니다." break fi @@ -84,7 +93,7 @@ jobs: done # 여전히 실행 중이면 강제 종료 - if kill -0 $CURRENT_PID 2>/dev/null; then + if ps -p $CURRENT_PID > /dev/null 2>&1; then echo "> 강제 종료합니다." kill -9 $CURRENT_PID sleep 2 @@ -109,14 +118,30 @@ jobs: # 프로세스 시작 확인 sleep 3 - if kill -0 $NEW_PID 2>/dev/null; then + if ps -p $NEW_PID > /dev/null 2>&1; then echo "> 애플리케이션이 성공적으로 시작되었습니다. (PID: $NEW_PID)" + echo "$NEW_PID" > "$APP_DIR/app.pid" else echo "> ERROR: 애플리케이션 시작에 실패했습니다." + echo "> 로그 확인: tail -50 $APP_DIR/application.log" exit 1 fi echo "> 배포 완료!" + DEPLOY_SCRIPT_EOF + + # 스크립트 실행 권한 부여 + chmod +x /tmp/deploy.sh + + # 백그라운드에서 배포 스크립트 실행 (SSH 세션과 분리) + echo "> 배포 스크립트를 백그라운드에서 실행합니다..." + nohup /tmp/deploy.sh "$AWS_ACCESS_KEY_ID" "$AWS_SECRET_ACCESS_KEY" "${{ secrets.SSH_USER }}" > /tmp/deploy.log 2>&1 & + + echo "> 배포 스크립트가 시작되었습니다." + echo "> 배포 로그 확인: tail -f /tmp/deploy.log" + + # 배포 스크립트가 시작될 시간을 준 후 SSH 종료 + sleep 2 env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} From c757cd9b6b28085acc72e21b87743559a6d6e070 Mon Sep 17 00:00:00 2001 From: yeoEun Date: Tue, 30 Sep 2025 16:04:46 +0900 Subject: [PATCH 108/135] feat/solapi-addLastloginat --- .../backend/controller/KycController.java | 34 +++++++++- .../backend/dto/kyc/PhoneVerifyResponse.java | 6 +- .../service/PhoneVerificationService.java | 64 ++++++++++++++++--- 3 files changed, 91 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/joycrew/backend/controller/KycController.java b/src/main/java/com/joycrew/backend/controller/KycController.java index b13d730..9cbdc75 100644 --- a/src/main/java/com/joycrew/backend/controller/KycController.java +++ b/src/main/java/com/joycrew/backend/controller/KycController.java @@ -5,15 +5,24 @@ import com.joycrew.backend.dto.kyc.PhoneVerifyRequest; import com.joycrew.backend.dto.kyc.PhoneVerifyResponse; import com.joycrew.backend.service.PhoneVerificationService; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.util.EmailMasker; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; + @RestController @RequestMapping("/kyc/phone") @RequiredArgsConstructor public class KycController { private final PhoneVerificationService svc; + private final EmployeeRepository employeeRepo; @PostMapping("/start") public PhoneStartResponse start(@RequestBody @Valid PhoneStartRequest req) { @@ -23,7 +32,28 @@ public PhoneStartResponse start(@RequestBody @Valid PhoneStartRequest req) { @PostMapping("/verify") public PhoneVerifyResponse verify(@RequestBody @Valid PhoneVerifyRequest req) { + // 1) 코드 검증 + KYC 토큰 생성 + phone 획득 var r = svc.verify(req.requestId(), req.code()); - return new PhoneVerifyResponse(r.verified(), r.kycToken()); + + // 2) 해당 phone으로 직원들 조회 → 이메일/최근로그인 추출 + var employees = employeeRepo.findByPhoneNumber(r.phone()); + + List emails = employees.stream() + .flatMap(e -> Stream.of(e.getEmail(), e.getPersonalEmail())) + .filter(Objects::nonNull) + .map(EmailMasker::mask) + .distinct() + .toList(); + + LocalDateTime recent = employees.stream() + .map(e -> e.getLastLoginAt()) // LocalDateTime + .filter(Objects::nonNull) + .max(Comparator.naturalOrder()) + .orElse(null); + + String recentStr = (recent != null) ? recent.toString() : null; // ISO-8601 문자열 + + // 3) 응답 + return new PhoneVerifyResponse(r.verified(), r.kycToken(), emails, recentStr); } -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/dto/kyc/PhoneVerifyResponse.java b/src/main/java/com/joycrew/backend/dto/kyc/PhoneVerifyResponse.java index 9726e2f..bdd54ea 100644 --- a/src/main/java/com/joycrew/backend/dto/kyc/PhoneVerifyResponse.java +++ b/src/main/java/com/joycrew/backend/dto/kyc/PhoneVerifyResponse.java @@ -1,6 +1,10 @@ package com.joycrew.backend.dto.kyc; +import java.util.List; + public record PhoneVerifyResponse( boolean verified, - String kycToken + String kycToken, + List emails, + String recentLoginAt ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/PhoneVerificationService.java b/src/main/java/com/joycrew/backend/service/PhoneVerificationService.java index 6339904..1213bfe 100644 --- a/src/main/java/com/joycrew/backend/service/PhoneVerificationService.java +++ b/src/main/java/com/joycrew/backend/service/PhoneVerificationService.java @@ -16,71 +16,115 @@ import static org.springframework.http.HttpStatus.*; +/** + * 휴대폰 OTP 발송/검증 서비스. + * - start(phone): 6자리 코드 생성 후 bcrypt로 저장, SMS 발송, requestId 반환 + * - verify(requestId, code): 코드 검증, 상태 갱신, 시도 제한/만료 처리, KYC 토큰 발급 + * + * 주의: + * - resend 쿨다운은 현재 "반환"만 하고 실제 차단 로직은 넣지 않음(필요 시 조회/검증 추가). + */ @Service @RequiredArgsConstructor public class PhoneVerificationService { private final PhoneVerificationRepository repo; + + // SmsConfig에서 @Primary로 선택된 Sender 주입 (console 또는 solapi) private final @Qualifier("smsSender") SmsSender sms; + private final KycTokenService kycTokenService; - @Value("${otp.ttl-minutes:5}") private int ttlMin; - @Value("${otp.max-attempts:5}") private int maxAttempts; - @Value("${otp.resend-cooldown-seconds:30}") private int cooldownSec; + @Value("${otp.ttl-minutes:5}") + private int ttlMin; + + @Value("${otp.max-attempts:5}") + private int maxAttempts; + + @Value("${otp.resend-cooldown-seconds:30}") + private int cooldownSec; private final Random random = new Random(); + /** + * 인증 시작: 6자리 코드 생성 → 저장 → SMS 발송 → requestId 반환 + */ public StartResult start(String phone) { + // 6자리 랜덤 코드 생성 String code = String.format("%06d", random.nextInt(1_000_000)); + + // 코드 해시(bcrypt) String hash = BCrypt.hashpw(code, BCrypt.gensalt()); + var now = LocalDateTime.now(); + var pv = PhoneVerification.builder() - .phone(phone).codeHash(hash) + .phone(phone) + .codeHash(hash) .expiresAt(now.plusMinutes(ttlMin)) - .attempts(0).maxAttempts(maxAttempts) - .createdAt(now).lastSentAt(now) + .attempts(0) + .maxAttempts(maxAttempts) + .createdAt(now) + .lastSentAt(now) .requestId(UUID.randomUUID().toString()) .status(PhoneVerification.Status.PENDING) .build(); + repo.save(pv); + // 실제 전송: console 모드면 로그로, solapi면 문자 발송 sms.send(phone, "[JoyCrew] 인증번호: " + code + " (유효 " + ttlMin + "분)"); + + // 현재 구현은 쿨다운을 "반환"만 함. (차단하고 싶으면 최근 전송 이력 조회/검증 추가) return new StartResult(pv.getRequestId(), cooldownSec); } + /** + * 코드 검증: 상태/만료/시도수 체크 → 성공 시 VERIFIED로 갱신하고 KYC 토큰 발급 + */ public VerifyResult verify(String requestId, String code) { var pv = repo.findByRequestId(requestId) .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "request not found")); - if (pv.getStatus() != PhoneVerification.Status.PENDING) + if (pv.getStatus() != PhoneVerification.Status.PENDING) { throw new ResponseStatusException(BAD_REQUEST, "already used"); + } var now = LocalDateTime.now(); + if (now.isAfter(pv.getExpiresAt())) { pv.setStatus(PhoneVerification.Status.EXPIRED); repo.save(pv); throw new ResponseStatusException(BAD_REQUEST, "expired"); } + if (pv.getAttempts() >= pv.getMaxAttempts()) { pv.setStatus(PhoneVerification.Status.BLOCKED); repo.save(pv); throw new ResponseStatusException(TOO_MANY_REQUESTS, "too many attempts"); } + // 시도 1 증가 pv.setAttempts(pv.getAttempts() + 1); + boolean ok = BCrypt.checkpw(code, pv.getCodeHash()); if (!ok) { repo.save(pv); throw new ResponseStatusException(UNAUTHORIZED, "invalid code"); } + // 성공: VERIFIED 처리 pv.setStatus(PhoneVerification.Status.VERIFIED); repo.save(pv); + // KYC 토큰 발급 (payload: phone | issuedAt | exp | sig) String token = kycTokenService.create(pv.getPhone()); - return new VerifyResult(true, token); + + // 컨트롤러에서 이메일/최근로그인 조회가 필요하므로 phone도 함께 반환 + return new VerifyResult(true, token, pv.getPhone()); } + // 응답 DTO (internal) public record StartResult(String requestId, int resendAvailableInSec) {} - public record VerifyResult(boolean verified, String kycToken) {} -} \ No newline at end of file + public record VerifyResult(boolean verified, String kycToken, String phone) {} +} From 5f539c01208b13387578d7519ae3eaca9102e16b Mon Sep 17 00:00:00 2001 From: Jiwon Kim Date: Wed, 1 Oct 2025 16:48:50 +0900 Subject: [PATCH 109/135] =?UTF-8?q?fix=20:=20transaction=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/joycrew/backend/entity/Wallet.java | 153 ++++++++++-------- .../backend/repository/WalletRepository.java | 13 +- .../service/TransactionHistoryService.java | 80 +++++---- .../backend/service/WalletService.java | 27 ++-- src/main/resources/application.yml | 2 +- 5 files changed, 159 insertions(+), 116 deletions(-) diff --git a/src/main/java/com/joycrew/backend/entity/Wallet.java b/src/main/java/com/joycrew/backend/entity/Wallet.java index 5a7aaad..e4e626f 100644 --- a/src/main/java/com/joycrew/backend/entity/Wallet.java +++ b/src/main/java/com/joycrew/backend/entity/Wallet.java @@ -2,7 +2,10 @@ import com.joycrew.backend.exception.InsufficientPointsException; import jakarta.persistence.*; -import lombok.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + import java.time.LocalDateTime; @Entity @@ -10,90 +13,102 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Wallet { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long walletId; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long walletId; - @OneToOne - @JoinColumn(name = "employee_id", nullable = false, unique = true) - private Employee employee; + // ⭐️ 동시성 제어를 위한 낙관적 락 필드 추가 + @Version + private Long version; - @Column(nullable = false) - private Integer balance; + @OneToOne + @JoinColumn(name = "employee_id", nullable = false, unique = true) + private Employee employee; - @Column(nullable = false) - private Integer giftablePoint; + @Column(nullable = false) + private Integer balance; - @Column(nullable = false) - private LocalDateTime createdAt; + @Column(nullable = false) + private Integer giftablePoint; - @Column(nullable = false) - private LocalDateTime updatedAt; + @Column(nullable = false) + private LocalDateTime createdAt; - public Wallet(Employee employee) { - this.employee = employee; - this.balance = 0; - this.giftablePoint = 0; - } + @Column(nullable = false) + private LocalDateTime updatedAt; - public void addPoints(int amount) { - if (amount < 0) { - throw new IllegalArgumentException("Points to add cannot be negative."); + public Wallet(Employee employee) { + this.employee = employee; + this.balance = 0; + this.giftablePoint = 0; } - this.balance += amount; - this.giftablePoint += amount; - } - public void spendGiftablePoints(int amount) { - if (amount < 0) { - throw new IllegalArgumentException("Points to spend cannot be negative."); + public void addPoints(int amount) { + if (amount < 0) { + throw new IllegalArgumentException("Points to add cannot be negative."); + } + this.balance += amount; + this.giftablePoint += amount; } - if (this.giftablePoint < amount) { - throw new InsufficientPointsException("Insufficient giftable points."); + + public void spendGiftablePoints(int amount) { + if (amount < 0) { + throw new IllegalArgumentException("Points to spend cannot be negative."); + } + // ⭐️ 잔액 확인 강화 (비관적 락과 함께 동시성 문제 해결) + if (this.giftablePoint < amount) { + throw new InsufficientPointsException("Insufficient giftable points."); + } + this.balance -= amount; + this.giftablePoint -= amount; } - this.balance -= amount; - this.giftablePoint -= amount; - } - public void purchaseWithPoints(int amount) { - if (amount < 0) { - throw new IllegalArgumentException("Purchase amount cannot be negative."); + public void purchaseWithPoints(int amount) { + if (amount < 0) { + throw new IllegalArgumentException("Purchase amount cannot be negative."); + } + if (this.balance < amount) { + throw new InsufficientPointsException("Insufficient points for purchase."); + } + this.balance -= amount; + + // ⭐️ giftablePoint 불일치 문제 해소: + // 최소 0 보장 (기존 Math.max 로직을 유지하면서 안전성 확보) + this.giftablePoint -= amount; + if (this.giftablePoint < 0) { + this.giftablePoint = 0; + } } - if (this.balance < amount) { - throw new InsufficientPointsException("Insufficient points for purchase."); + + public void revokePoints(int amount) { + if (amount < 0) { + throw new IllegalArgumentException("Amount to revoke cannot be negative."); + } + if (this.balance < amount) { + throw new InsufficientPointsException("Insufficient balance to revoke points."); + } + this.balance -= amount; + // 기존 로직 유지 + this.giftablePoint = Math.max(0, this.giftablePoint - amount); } - this.balance -= amount; - this.giftablePoint = Math.max(0, this.giftablePoint - amount); - } - public void revokePoints(int amount) { - if (amount < 0) { - throw new IllegalArgumentException("Amount to revoke cannot be negative."); + public void refundPoints(int amount) { + if (amount < 0) { + throw new IllegalArgumentException("Refund amount cannot be negative."); + } + this.balance += amount; + this.giftablePoint += amount; } - if (this.balance < amount) { - throw new InsufficientPointsException("Insufficient balance to revoke points."); + + @PrePersist + protected void onCreate() { + this.createdAt = this.updatedAt = LocalDateTime.now(); + if (this.balance == null) this.balance = 0; + if (this.giftablePoint == null) this.giftablePoint = 0; } - this.balance -= amount; - this.giftablePoint = Math.max(0, this.giftablePoint - amount); - } - public void refundPoints(int amount) { - if (amount < 0) { - throw new IllegalArgumentException("Refund amount cannot be negative."); + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); } - this.balance += amount; - this.giftablePoint += amount; - } - - @PrePersist - protected void onCreate() { - this.createdAt = this.updatedAt = LocalDateTime.now(); - if (this.balance == null) this.balance = 0; - if (this.giftablePoint == null) this.giftablePoint = 0; - } - - @PreUpdate - protected void onUpdate() { - this.updatedAt = LocalDateTime.now(); - } -} +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/repository/WalletRepository.java b/src/main/java/com/joycrew/backend/repository/WalletRepository.java index 851d09b..71d2c82 100644 --- a/src/main/java/com/joycrew/backend/repository/WalletRepository.java +++ b/src/main/java/com/joycrew/backend/repository/WalletRepository.java @@ -1,10 +1,19 @@ package com.joycrew.backend.repository; import com.joycrew.backend.entity.Wallet; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; import java.util.Optional; public interface WalletRepository extends JpaRepository { - Optional findByEmployee_EmployeeId(Long employeeId); -} + // ⭐️ 비관적 락 적용 (동시성 문제 해결) + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select w from Wallet w where w.employee.employeeId = :employeeId") + Optional findByEmployee_EmployeeIdForUpdate(Long employeeId); + + // 일반 조회는 기존 메서드 유지 + Optional findByEmployee_EmployeeId(Long employeeId); +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java b/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java index bffd6a6..d6a06d8 100644 --- a/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java +++ b/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java @@ -18,37 +18,51 @@ @Transactional(readOnly = true) public class TransactionHistoryService { - private final RewardPointTransactionRepository transactionRepository; - private final EmployeeRepository employeeRepository; - - public List getTransactionHistory(String userEmail) { - Employee user = employeeRepository.findByEmail(userEmail) - .orElseThrow(() -> new UserNotFoundException("User not found with email: " + userEmail)); - - List personalTransactionTypes = List.of( - TransactionType.AWARD_P2P - ); - - return transactionRepository.findBySenderOrReceiverOrderByTransactionDateDesc(user, user) - .stream() - .filter(tx -> personalTransactionTypes.contains(tx.getType())) - .map(tx -> { - boolean isSender = user.equals(tx.getSender()); - int amount = isSender ? -tx.getPointAmount() : tx.getPointAmount(); - - String counterparty = isSender - ? tx.getReceiver().getEmployeeName() - : tx.getSender().getEmployeeName(); - - return TransactionHistoryResponse.builder() - .transactionId(tx.getTransactionId()) - .type(tx.getType()) - .amount(amount) - .counterparty(counterparty) - .message(tx.getMessage()) - .transactionDate(tx.getTransactionDate()) - .build(); - }) - .collect(Collectors.toList()); - } + private final RewardPointTransactionRepository transactionRepository; + private final EmployeeRepository employeeRepository; + + public List getTransactionHistory(String userEmail) { + Employee user = employeeRepository.findByEmail(userEmail) + .orElseThrow(() -> new UserNotFoundException("User not found with email: " + userEmail)); + + List personalTransactionTypes = List.of( + TransactionType.AWARD_P2P + ); + + return transactionRepository.findBySenderOrReceiverOrderByTransactionDateDesc(user, user) + .stream() + .filter(tx -> personalTransactionTypes.contains(tx.getType())) + .map(tx -> { + boolean isSender = user.equals(tx.getSender()); + int amount = isSender ? -tx.getPointAmount() : tx.getPointAmount(); + + String counterparty = "System/Admin"; // 기본값 설정 + Employee counterpartyEmployee = null; + + if (isSender) { + counterpartyEmployee = tx.getReceiver(); + } else { + counterpartyEmployee = tx.getSender(); + } + + if (counterpartyEmployee != null) { + counterparty = counterpartyEmployee.getEmployeeName(); + // ⭐️ 여기에서 counterpartyProfileImageUrl, counterpartyDepartmentName도 매핑해야 함 + } else if (tx.getType() == TransactionType.AWARD_MANAGER_SPOT) { + counterparty = "Admin"; + } else if (tx.getType() == TransactionType.REDEEM_ITEM || tx.getType() == TransactionType.EXPIRE_POINTS) { + counterparty = "System"; + } + + return TransactionHistoryResponse.builder() + .transactionId(tx.getTransactionId()) + .type(tx.getType()) + .amount(amount) + .counterparty(counterparty) + .message(tx.getMessage()) + .transactionDate(tx.getTransactionDate()) + .build(); + }) + .collect(Collectors.toList()); + } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/WalletService.java b/src/main/java/com/joycrew/backend/service/WalletService.java index 0cc8381..719451a 100644 --- a/src/main/java/com/joycrew/backend/service/WalletService.java +++ b/src/main/java/com/joycrew/backend/service/WalletService.java @@ -15,17 +15,22 @@ @RequiredArgsConstructor @Transactional(readOnly = true) public class WalletService { - private final WalletRepository walletRepository; - private final EmployeeRepository employeeRepository; - private final EmployeeMapper employeeMapper; + private final WalletRepository walletRepository; + private final EmployeeRepository employeeRepository; + private final EmployeeMapper employeeMapper; - public PointBalanceResponse getPointBalance(String userEmail) { - Employee employee = employeeRepository.findByEmail(userEmail) - .orElseThrow(() -> new UserNotFoundException("Authenticated user not found.")); + @Transactional // ⭐️ Wallet 생성 및 저장 필요하므로 @Transactional 재정의 + public PointBalanceResponse getPointBalance(String userEmail) { + Employee employee = employeeRepository.findByEmail(userEmail) + .orElseThrow(() -> new UserNotFoundException("Authenticated user not found.")); - Wallet wallet = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) - .orElse(new Wallet(employee)); + // ⭐️ orElseGet을 사용하여 Wallet이 없으면 생성 후 DB에 저장 (영속성 문제 해결) + Wallet wallet = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) + .orElseGet(() -> { + Wallet newWallet = new Wallet(employee); + return walletRepository.save(newWallet); + }); - return employeeMapper.toPointBalanceResponse(wallet); - } -} + return employeeMapper.toPointBalanceResponse(wallet); + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7ab6cc5..83d2f30 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -82,4 +82,4 @@ aws: jobs: recent-view-cleanup: enabled: true - cron: "0 0 3 * * *" + cron: "0 0 3 * * *" \ No newline at end of file From efba8d5aa238be2f588f13308ef7604f3245a278 Mon Sep 17 00:00:00 2001 From: yeoEun Date: Wed, 1 Oct 2025 21:38:04 +0900 Subject: [PATCH 110/135] feat/kakao-biz --- .../config/KakaoGiftBizClientConfig.java | 30 +++ .../backend/config/SecurityConfig.java | 3 +- .../backend/controller/CatalogController.java | 37 ++++ .../backend/controller/OrderController.java | 165 +++------------- .../backend/controller/ProductController.java | 88 --------- .../RecentProductViewController.java | 48 ----- .../backend/dto/CreateOrderRequest.java | 11 +- .../joycrew/backend/dto/OrderResponse.java | 73 +++---- .../backend/dto/PagedProductResponse.java | 41 ---- .../joycrew/backend/dto/ProductResponse.java | 43 ---- .../dto/RecentViewedProductResponse.java | 18 -- .../dto/kakao/CreateGiftOrderRequest.java | 8 + .../dto/kakao/CreateGiftOrderResponse.java | 7 + .../kakao/ExternalProductDetailResponse.java | 9 + .../kakao/ExternalProductOptionPointDto.java | 8 + .../dto/kakao/ExternalProductResponse.java | 9 + .../dto/kakao/KakaoGiftDetailV3Request.java | 9 + .../dto/kakao/KakaoGiftDetailV3Response.java | 7 + .../dto/kakao/KakaoTemplateOrderRequest.java | 17 ++ .../dto/kakao/KakaoTemplateOrderResponse.java | 8 + .../com/joycrew/backend/entity/Order.java | 46 ++--- .../com/joycrew/backend/entity/Product.java | 56 ------ .../backend/entity/RecentProductView.java | 43 ---- .../com/joycrew/backend/entity/Wallet.java | 8 +- .../backend/entity/enums/Category.java | 16 -- .../backend/entity/enums/GiftCategory.java | 21 ++ .../backend/entity/enums/OrderStatus.java | 7 +- .../backend/entity/enums/SortOption.java | 5 + .../backend/entity/kakao/KakaoTemplate.java | 46 +++++ .../backend/kakao/KakaoGiftBizClient.java | 75 +++++++ .../repository/KakaoTemplateRepository.java | 10 + .../backend/repository/OrderRepository.java | 2 +- .../backend/repository/ProductRepository.java | 37 ---- .../RecentProductViewRepository.java | 20 -- .../scheduler/RecentViewCleanupJob.java | 26 --- .../service/ExternalCatalogService.java | 55 ++++++ .../backend/service/GiftPurchaseService.java | 186 ++++++++++++++++++ .../joycrew/backend/service/OrderService.java | 126 ++---------- .../backend/service/ProductQueryService.java | 69 ------- .../service/RecentProductViewService.java | 74 ------- src/main/resources/application.yml | 14 ++ .../AdminEmployeeControllerTest.java | 137 ------------- .../EmployeeQueryControllerTest.java | 57 ------ .../controller/StatisticsControllerTest.java | 62 ------ .../controller/UserControllerTest.java | 100 ---------- 45 files changed, 671 insertions(+), 1266 deletions(-) create mode 100644 src/main/java/com/joycrew/backend/config/KakaoGiftBizClientConfig.java create mode 100644 src/main/java/com/joycrew/backend/controller/CatalogController.java delete mode 100644 src/main/java/com/joycrew/backend/controller/ProductController.java delete mode 100644 src/main/java/com/joycrew/backend/controller/RecentProductViewController.java delete mode 100644 src/main/java/com/joycrew/backend/dto/PagedProductResponse.java delete mode 100644 src/main/java/com/joycrew/backend/dto/ProductResponse.java delete mode 100644 src/main/java/com/joycrew/backend/dto/RecentViewedProductResponse.java create mode 100644 src/main/java/com/joycrew/backend/dto/kakao/CreateGiftOrderRequest.java create mode 100644 src/main/java/com/joycrew/backend/dto/kakao/CreateGiftOrderResponse.java create mode 100644 src/main/java/com/joycrew/backend/dto/kakao/ExternalProductDetailResponse.java create mode 100644 src/main/java/com/joycrew/backend/dto/kakao/ExternalProductOptionPointDto.java create mode 100644 src/main/java/com/joycrew/backend/dto/kakao/ExternalProductResponse.java create mode 100644 src/main/java/com/joycrew/backend/dto/kakao/KakaoGiftDetailV3Request.java create mode 100644 src/main/java/com/joycrew/backend/dto/kakao/KakaoGiftDetailV3Response.java create mode 100644 src/main/java/com/joycrew/backend/dto/kakao/KakaoTemplateOrderRequest.java create mode 100644 src/main/java/com/joycrew/backend/dto/kakao/KakaoTemplateOrderResponse.java delete mode 100644 src/main/java/com/joycrew/backend/entity/Product.java delete mode 100644 src/main/java/com/joycrew/backend/entity/RecentProductView.java delete mode 100644 src/main/java/com/joycrew/backend/entity/enums/Category.java create mode 100644 src/main/java/com/joycrew/backend/entity/enums/GiftCategory.java create mode 100644 src/main/java/com/joycrew/backend/entity/enums/SortOption.java create mode 100644 src/main/java/com/joycrew/backend/entity/kakao/KakaoTemplate.java create mode 100644 src/main/java/com/joycrew/backend/kakao/KakaoGiftBizClient.java create mode 100644 src/main/java/com/joycrew/backend/repository/KakaoTemplateRepository.java delete mode 100644 src/main/java/com/joycrew/backend/repository/ProductRepository.java delete mode 100644 src/main/java/com/joycrew/backend/repository/RecentProductViewRepository.java delete mode 100644 src/main/java/com/joycrew/backend/scheduler/RecentViewCleanupJob.java create mode 100644 src/main/java/com/joycrew/backend/service/ExternalCatalogService.java create mode 100644 src/main/java/com/joycrew/backend/service/GiftPurchaseService.java delete mode 100644 src/main/java/com/joycrew/backend/service/ProductQueryService.java delete mode 100644 src/main/java/com/joycrew/backend/service/RecentProductViewService.java delete mode 100644 src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java delete mode 100644 src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java delete mode 100644 src/test/java/com/joycrew/backend/controller/StatisticsControllerTest.java delete mode 100644 src/test/java/com/joycrew/backend/controller/UserControllerTest.java diff --git a/src/main/java/com/joycrew/backend/config/KakaoGiftBizClientConfig.java b/src/main/java/com/joycrew/backend/config/KakaoGiftBizClientConfig.java new file mode 100644 index 0000000..25e3d4e --- /dev/null +++ b/src/main/java/com/joycrew/backend/config/KakaoGiftBizClientConfig.java @@ -0,0 +1,30 @@ +package com.joycrew.backend.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.BufferingClientHttpRequestFactory; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +import java.time.Duration; + +@Configuration +public class KakaoGiftBizClientConfig { + + @Bean + public RestTemplate kakaoGiftBizRestTemplate( + @Value("${kakao.giftbiz.timeout-ms:5000}") long timeoutMs) { + + var factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout((int) timeoutMs); + factory.setReadTimeout((int) timeoutMs); + + return new RestTemplateBuilder() + .requestFactory(() -> new BufferingClientHttpRequestFactory(factory)) + .setConnectTimeout(Duration.ofMillis(timeoutMs)) + .setReadTimeout(Duration.ofMillis(timeoutMs)) + .build(); + } +} diff --git a/src/main/java/com/joycrew/backend/config/SecurityConfig.java b/src/main/java/com/joycrew/backend/config/SecurityConfig.java index 7c56e56..8bfd205 100644 --- a/src/main/java/com/joycrew/backend/config/SecurityConfig.java +++ b/src/main/java/com/joycrew/backend/config/SecurityConfig.java @@ -57,8 +57,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/kyc/phone/**", "/accounts/emails/by-phone" ).permitAll() - .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll() - .requestMatchers(HttpMethod.GET, "/api/crawl/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/catalog/**").permitAll() .requestMatchers("/api/admin/employees").permitAll() .requestMatchers("/api/admin/**").hasAuthority(AdminLevel.SUPER_ADMIN.name()) .anyRequest().authenticated() diff --git a/src/main/java/com/joycrew/backend/controller/CatalogController.java b/src/main/java/com/joycrew/backend/controller/CatalogController.java new file mode 100644 index 0000000..4d37ba2 --- /dev/null +++ b/src/main/java/com/joycrew/backend/controller/CatalogController.java @@ -0,0 +1,37 @@ +package com.joycrew.backend.controller; + +import com.joycrew.backend.dto.kakao.ExternalProductDetailResponse; +import com.joycrew.backend.dto.kakao.ExternalProductResponse; +import com.joycrew.backend.entity.enums.GiftCategory; +import com.joycrew.backend.entity.enums.SortOption; +import com.joycrew.backend.service.ExternalCatalogService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/catalog") +public class CatalogController { + + private final ExternalCatalogService catalog; + + @GetMapping(value = "/kakao/{category}", produces = "application/json; charset=UTF-8") + public ResponseEntity> listKakaoByCategory( + @PathVariable String category, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "POPULAR") SortOption sort + ) { + GiftCategory gc = GiftCategory.valueOf(category.toUpperCase()); + return ResponseEntity.ok(catalog.listByCategory(gc, page, size, sort)); + } + + @GetMapping(value = "/kakao/product/{templateId}", produces = "application/json; charset=UTF-8") + public ResponseEntity detailPoints(@PathVariable String templateId) { + var resp = catalog.getDetailWithPoints(templateId); + return resp == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(resp); + } +} diff --git a/src/main/java/com/joycrew/backend/controller/OrderController.java b/src/main/java/com/joycrew/backend/controller/OrderController.java index 688584b..cb0e27f 100644 --- a/src/main/java/com/joycrew/backend/controller/OrderController.java +++ b/src/main/java/com/joycrew/backend/controller/OrderController.java @@ -1,156 +1,53 @@ package com.joycrew.backend.controller; import com.joycrew.backend.dto.CreateOrderRequest; -import com.joycrew.backend.dto.ErrorResponse; import com.joycrew.backend.dto.OrderResponse; import com.joycrew.backend.dto.PagedOrderResponse; import com.joycrew.backend.security.UserPrincipal; -import com.joycrew.backend.service.OrderService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.ExampleObject; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; +import com.joycrew.backend.service.GiftPurchaseService; +import com.joycrew.backend.service.OrderService; // 조회용 import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "Orders", description = "APIs for purchasing products and tracking orders") +@Tag(name = "Orders", description = "Purchase Kakao gifts with points (no cancellation)") @RestController @RequestMapping("/api/orders") @RequiredArgsConstructor public class OrderController { - private final OrderService orderService; + private final GiftPurchaseService giftPurchaseService; + private final OrderService orderService; - @Operation( - summary = "Create an order", - description = "Creates an order for the current user and deducts points from the linked wallet.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "Order created successfully.", - content = @Content( - schema = @Schema(implementation = OrderResponse.class), - examples = @ExampleObject( - value = """ - { - "orderId": 1001, - "employeeId": 1, - "productId": 101, - "productName": "Smartphone", - "productItemId": "12345", - "productUnitPrice": 499, - "quantity": 2, - "totalPrice": 998, - "status": "PLACED", - "orderedAt": "2025-08-11T10:00:00", - "shippedAt": null, - "deliveredAt": null - } - """ - ) - ) - ) - } - ) - @PostMapping - public ResponseEntity createOrder( - @AuthenticationPrincipal UserPrincipal principal, - @RequestBody CreateOrderRequest request - ) { - Long employeeId = principal.getEmployee().getEmployeeId(); - return ResponseEntity.ok(orderService.createOrder(employeeId, request)); - } + @PostMapping + public ResponseEntity createOrder( + @AuthenticationPrincipal UserPrincipal principal, + @RequestBody CreateOrderRequest request + ) { + Long employeeId = principal.getEmployee().getEmployeeId(); + return ResponseEntity.ok(giftPurchaseService.purchaseWithPoints(employeeId, request)); + } - @Operation( - summary = "Get my orders (paged)", - description = "Retrieves the current user's own orders only.", - parameters = { - @Parameter(name = "page", description = "Page number (0-based)", example = "0"), - @Parameter(name = "size", description = "Items per page", example = "20") - } - ) - @GetMapping - public ResponseEntity getMyOrders( - @AuthenticationPrincipal UserPrincipal principal, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size - ) { - Long employeeId = principal.getEmployee().getEmployeeId(); - return ResponseEntity.ok(orderService.getMyOrders(employeeId, page, size)); - } + @GetMapping + public ResponseEntity getMyOrders( + @AuthenticationPrincipal UserPrincipal principal, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Long employeeId = principal.getEmployee().getEmployeeId(); + return ResponseEntity.ok(orderService.getMyOrders(employeeId, page, size)); + } - @Operation( - summary = "Get my order detail", - description = "Retrieves a specific order of the current user." - ) - @GetMapping("/{orderId}") - public ResponseEntity getMyOrderDetail( - @AuthenticationPrincipal UserPrincipal principal, - @PathVariable Long orderId - ) { - Long employeeId = principal.getEmployee().getEmployeeId(); - return ResponseEntity.ok(orderService.getMyOrderDetail(employeeId, orderId)); - } + @GetMapping("/{orderId}") + public ResponseEntity getMyOrderDetail( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long orderId + ) { + Long employeeId = principal.getEmployee().getEmployeeId(); + return ResponseEntity.ok(orderService.getMyOrderDetail(employeeId, orderId)); + } - @Operation( - summary = "Cancel my order (only if not shipped)", - description = "Cancels the current user's order and refunds points if the order has not been shipped yet.", - responses = { - @ApiResponse( - responseCode = "200", - description = "Order canceled successfully.", - content = @Content( - schema = @Schema(implementation = OrderResponse.class), - examples = @ExampleObject( - value = """ - { - "orderId": 1001, - "employeeId": 1, - "productId": 101, - "productName": "Smartphone", - "productItemId": "12345", - "productUnitPrice": 499, - "quantity": 2, - "totalPrice": 998, - "status": "CANCELED", - "orderedAt": "2025-08-11T10:00:00", - "shippedAt": null, - "deliveredAt": null - } - """ - ) - ) - ), - @ApiResponse( - responseCode = "400", - description = "Order cannot be canceled after it has been shipped.", - content = @Content( - schema = @Schema(implementation = ErrorResponse.class), - examples = @ExampleObject( - value = """ - { - "code": "ORDER_CANNOT_CANCEL", - "message": "Order cannot be canceled after it has been shipped.", - "timestamp": "2025-08-11T11:00:00", - "path": "/api/orders/1001/cancel" - } - """ - ) - ) - ) - } - ) - @PatchMapping("/{orderId}/cancel") - public ResponseEntity cancelMyOrder( - @AuthenticationPrincipal UserPrincipal principal, - @PathVariable Long orderId - ) { - Long employeeId = principal.getEmployee().getEmployeeId(); - return ResponseEntity.ok(orderService.cancelMyOrder(employeeId, orderId)); - } + // ❌ 취소 엔드포인트 제거 (정책: 취소 불가) } diff --git a/src/main/java/com/joycrew/backend/controller/ProductController.java b/src/main/java/com/joycrew/backend/controller/ProductController.java deleted file mode 100644 index f0fb1fc..0000000 --- a/src/main/java/com/joycrew/backend/controller/ProductController.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.joycrew.backend.controller; - -import com.joycrew.backend.dto.PagedProductResponse; -import com.joycrew.backend.dto.ProductResponse; -import com.joycrew.backend.entity.enums.Category; -import com.joycrew.backend.service.ProductQueryService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -@Tag(name = "Products", description = "APIs for querying products") -@RestController -@RequestMapping("/api/products") -@RequiredArgsConstructor -public class ProductController { - - private final ProductQueryService productQueryService; - - @Operation( - summary = "Search products with filters (paged)", - description = "Search products by a query term (name or item ID). You can also filter by category. If the query is empty, it lists products from the specified category or all products if no category is given.", - parameters = { - @Parameter(name = "q", description = "Search query (optional)", example = "Keyboard"), - @Parameter(name = "category", description = "Product category to filter by (optional)", example = "APPLIANCES"), - @Parameter(name = "page", description = "Page number (0-based)", example = "0"), - @Parameter(name = "size", description = "Items per page", example = "20") - } - ) - @GetMapping("/search") - public ResponseEntity searchProducts( - @RequestParam(name = "q", required = false) String q, - @RequestParam(name = "category", required = false) Category category, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size - ) { - return ResponseEntity.ok(productQueryService.searchProducts(q, category, page, size)); - } - - @Operation( - summary = "Get all products (paged)", - description = "Fetches all products with pagination.", - parameters = { - @Parameter(name = "page", description = "Page number (0-based)", example = "0"), - @Parameter(name = "size", description = "Items per page", example = "20") - } - ) - @GetMapping - public ResponseEntity getAllProducts( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size - ) { - return ResponseEntity.ok(productQueryService.getAllProducts(page, size)); - } - - @Operation( - summary = "Get product by ID", - description = "Fetches a single product by its ID." - ) - @GetMapping("/{id}") - public ResponseEntity getProductById( - @Parameter(description = "Product ID", example = "1") - @PathVariable Long id - ) { - ProductResponse product = productQueryService.getProductById(id); - return (product != null) ? ResponseEntity.ok(product) : ResponseEntity.notFound().build(); - } - - @Operation( - summary = "Get products by category (paged)", - description = "Fetches products by category (keyword) with pagination.", - parameters = { - @Parameter(name = "category", description = "Product category (keyword)", example = "BEAUTY"), - @Parameter(name = "page", description = "Page number (0-based)", example = "0"), - @Parameter(name = "size", description = "Items per page", example = "20") - } - ) - @GetMapping("/category/{category}") - public ResponseEntity getProductsByCategory( - @PathVariable Category category, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size - ) { - return ResponseEntity.ok(productQueryService.getProductsByCategory(category, page, size)); - } -} diff --git a/src/main/java/com/joycrew/backend/controller/RecentProductViewController.java b/src/main/java/com/joycrew/backend/controller/RecentProductViewController.java deleted file mode 100644 index 68ded13..0000000 --- a/src/main/java/com/joycrew/backend/controller/RecentProductViewController.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.joycrew.backend.controller; - -import com.joycrew.backend.dto.RecentViewedProductResponse; -import com.joycrew.backend.security.UserPrincipal; -import com.joycrew.backend.service.RecentProductViewService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -@Tag(name = "Recent Views", description = "APIs for recording and retrieving recently viewed products") -@RestController -@RequestMapping("/api/recent-views") -@RequiredArgsConstructor -public class RecentProductViewController { - - private final RecentProductViewService recentProductViewService; - - @Operation(summary = "Record a recent view", description = "Records a product as recently viewed by the current user.") - @PostMapping("/{productId}") - public ResponseEntity recordView( - @AuthenticationPrincipal UserPrincipal principal, - @PathVariable Long productId - ) { - Long employeeId = principal.getEmployee().getEmployeeId(); - recentProductViewService.recordView(employeeId, productId); - return ResponseEntity.ok().build(); - } - - @Operation( - summary = "Get recent views", - description = "Returns the current user's recently viewed products within the last 3 months.", - parameters = @Parameter(name = "limit", description = "Max items to return (default 20, max 100)", example = "20") - ) - @GetMapping - public ResponseEntity> getRecentViews( - @AuthenticationPrincipal UserPrincipal principal, - @RequestParam(required = false) Integer limit - ) { - Long employeeId = principal.getEmployee().getEmployeeId(); - return ResponseEntity.ok(recentProductViewService.getRecentViews(employeeId, limit)); - } -} diff --git a/src/main/java/com/joycrew/backend/dto/CreateOrderRequest.java b/src/main/java/com/joycrew/backend/dto/CreateOrderRequest.java index 0232615..6e3926c 100644 --- a/src/main/java/com/joycrew/backend/dto/CreateOrderRequest.java +++ b/src/main/java/com/joycrew/backend/dto/CreateOrderRequest.java @@ -1,11 +1,6 @@ package com.joycrew.backend.dto; -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "Create order request") public record CreateOrderRequest( - @Schema(description = "Product ID", example = "101") - Long productId, - @Schema(description = "Quantity", example = "2") - Integer quantity -) { } + String externalProductId, // KakaoTemplate.templateId + Integer quantity // null 또는 <=0 이면 1 +) {} diff --git a/src/main/java/com/joycrew/backend/dto/OrderResponse.java b/src/main/java/com/joycrew/backend/dto/OrderResponse.java index 7b24644..1ad5930 100644 --- a/src/main/java/com/joycrew/backend/dto/OrderResponse.java +++ b/src/main/java/com/joycrew/backend/dto/OrderResponse.java @@ -1,56 +1,37 @@ +// src/main/java/com/joycrew/backend/dto/OrderResponse.java package com.joycrew.backend.dto; import com.joycrew.backend.entity.Order; import com.joycrew.backend.entity.enums.OrderStatus; import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; import java.time.LocalDateTime; -import java.util.List; -@Schema(description = "Order response") +@Schema(description = "주문 응답") +@Builder public record OrderResponse( - @Schema(description = "Order ID", example = "1001") - Long orderId, - @Schema(description = "Employee ID", example = "1") - Long employeeId, - @Schema(description = "Product ID", example = "101") - Long productId, - @Schema(description = "Product name", example = "Smartphone") - String productName, - @Schema(description = "상품 썸네일 URL") - String thumbnailUrl, - @Schema(description = "Product item ID", example = "12345") - String productItemId, - @Schema(description = "Unit price", example = "499") - Integer productUnitPrice, - @Schema(description = "Quantity", example = "2") - Integer quantity, - @Schema(description = "Total price", example = "998") - Integer totalPrice, - @Schema(description = "Status", example = "PLACED") - OrderStatus status, - @Schema(description = "Ordered at", example = "2025-08-11T10:00:00") - LocalDateTime orderedAt, - @Schema(description = "Shipped at", example = "2025-08-12T09:00:00") - LocalDateTime shippedAt, - @Schema(description = "Delivered at", example = "2025-08-13T18:30:00") - LocalDateTime deliveredAt + Long orderId, + String productName, + Integer quantity, + Integer unitPoint, + Integer totalPoint, + OrderStatus status, + LocalDateTime orderedAt, + String externalOrderId, + String thumbnailUrl ) { - public static OrderResponse from(Order o, String thumbnailUrl) { - return new OrderResponse( - o.getOrderId(), - o.getEmployee().getEmployeeId(), - o.getProductId(), - o.getProductName(), - thumbnailUrl, - o.getProductItemId(), - o.getProductUnitPrice(), - o.getQuantity(), - o.getTotalPrice(), - o.getStatus(), - o.getOrderedAt(), - o.getShippedAt(), - o.getDeliveredAt() - ); - } -} \ No newline at end of file + public static OrderResponse from(Order o, String thumbnailUrl) { + return OrderResponse.builder() + .orderId(o.getId()) // ✅ getOrderId -> getId + .productName(o.getProductName()) + .quantity(o.getQuantity()) + .unitPoint(o.getProductUnitPrice()) + .totalPoint(o.getTotalPrice()) + .status(o.getStatus()) + .orderedAt(o.getOrderedAt()) + .externalOrderId(o.getExternalOrderId()) // 추가했으면 매핑, 없으면 제거 + .thumbnailUrl(thumbnailUrl) + .build(); + } +} diff --git a/src/main/java/com/joycrew/backend/dto/PagedProductResponse.java b/src/main/java/com/joycrew/backend/dto/PagedProductResponse.java deleted file mode 100644 index 874feb0..0000000 --- a/src/main/java/com/joycrew/backend/dto/PagedProductResponse.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.joycrew.backend.dto; - -import com.joycrew.backend.entity.Product; -import io.swagger.v3.oas.annotations.media.ArraySchema; -import io.swagger.v3.oas.annotations.media.Schema; - -import java.util.List; - -@Schema(description = "Paged product response") -public record PagedProductResponse( - @ArraySchema(arraySchema = @Schema(description = "Content list"), - schema = @Schema(implementation = ProductResponse.class)) - List content, - @Schema(description = "Current page (0-based)", example = "0") - int page, - @Schema(description = "Page size", example = "20") - int size, - @Schema(description = "Total elements", example = "123") - long totalElements, - @Schema(description = "Total pages", example = "7") - int totalPages, - @Schema(description = "Has next page", example = "true") - boolean hasNext, - @Schema(description = "Has previous page", example = "false") - boolean hasPrevious -) { - public static PagedProductResponse from(org.springframework.data.domain.Page pageData) { - List mapped = pageData.getContent().stream() - .map(ProductResponse::from) - .toList(); - return new PagedProductResponse( - mapped, - pageData.getNumber(), - pageData.getSize(), - pageData.getTotalElements(), - pageData.getTotalPages(), - pageData.hasNext(), - pageData.hasPrevious() - ); - } -} diff --git a/src/main/java/com/joycrew/backend/dto/ProductResponse.java b/src/main/java/com/joycrew/backend/dto/ProductResponse.java deleted file mode 100644 index 590934c..0000000 --- a/src/main/java/com/joycrew/backend/dto/ProductResponse.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.joycrew.backend.dto; - -import com.joycrew.backend.entity.Product; -import com.joycrew.backend.entity.enums.Category; -import io.swagger.v3.oas.annotations.media.Schema; - -import java.time.LocalDateTime; - -@Schema(description = "Product summary DTO") -public record ProductResponse( - @Schema(description = "Product ID", example = "1") - Long id, - @Schema(description = "Category (keyword)", example = "BEAUTY") - Category keyword, - @Schema(description = "Rank order", example = "1") - Integer rankOrder, - @Schema(description = "Product name", example = "Smartphone") - String name, - @Schema(description = "Thumbnail URL", example = "https://example.com/image.jpg") - String thumbnailUrl, - @Schema(description = "Price", example = "499") - Integer price, - @Schema(description = "Detail URL", example = "https://example.com/product/1") - String detailUrl, - @Schema(description = "Item ID", example = "12345") - String itemId, - @Schema(description = "Registered time", example = "2025-08-11T10:00:00") - LocalDateTime registeredAt -) { - public static ProductResponse from(Product p) { - return new ProductResponse( - p.getId(), - p.getKeyword(), - p.getRankOrder(), - p.getName(), - p.getThumbnailUrl(), - p.getPrice(), - p.getDetailUrl(), - p.getItemId(), - p.getRegisteredAt() - ); - } -} diff --git a/src/main/java/com/joycrew/backend/dto/RecentViewedProductResponse.java b/src/main/java/com/joycrew/backend/dto/RecentViewedProductResponse.java deleted file mode 100644 index b25dfd1..0000000 --- a/src/main/java/com/joycrew/backend/dto/RecentViewedProductResponse.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.joycrew.backend.dto; - -import com.joycrew.backend.entity.Product; -import io.swagger.v3.oas.annotations.media.Schema; - -import java.time.LocalDateTime; - -@Schema(description = "Recently viewed product item") -public record RecentViewedProductResponse( - @Schema(description = "Product") - ProductResponse product, - @Schema(description = "Viewed at", example = "2025-08-11T12:34:56") - LocalDateTime viewedAt -) { - public static RecentViewedProductResponse of(Product product, LocalDateTime viewedAt) { - return new RecentViewedProductResponse(ProductResponse.from(product), viewedAt); - } -} diff --git a/src/main/java/com/joycrew/backend/dto/kakao/CreateGiftOrderRequest.java b/src/main/java/com/joycrew/backend/dto/kakao/CreateGiftOrderRequest.java new file mode 100644 index 0000000..9b6b31f --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/kakao/CreateGiftOrderRequest.java @@ -0,0 +1,8 @@ +package com.joycrew.backend.dto.kakao; + +public record CreateGiftOrderRequest( + String externalProductId, // = templateId + String itemId, + Integer quantity, + String receiverPhone +) {} diff --git a/src/main/java/com/joycrew/backend/dto/kakao/CreateGiftOrderResponse.java b/src/main/java/com/joycrew/backend/dto/kakao/CreateGiftOrderResponse.java new file mode 100644 index 0000000..ce6ec77 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/kakao/CreateGiftOrderResponse.java @@ -0,0 +1,7 @@ +package com.joycrew.backend.dto.kakao; + +public record CreateGiftOrderResponse( + String orderId, + String status, + String redeemUrl +) {} diff --git a/src/main/java/com/joycrew/backend/dto/kakao/ExternalProductDetailResponse.java b/src/main/java/com/joycrew/backend/dto/kakao/ExternalProductDetailResponse.java new file mode 100644 index 0000000..eb6a860 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/kakao/ExternalProductDetailResponse.java @@ -0,0 +1,9 @@ +package com.joycrew.backend.dto.kakao; + +public record ExternalProductDetailResponse( + String templateId, + String name, + int pointPrice, // 환산 포인트 (ceil(basePriceKrw / krwPerPoint)) + int priceKrw, + String thumbnailUrl +) {} diff --git a/src/main/java/com/joycrew/backend/dto/kakao/ExternalProductOptionPointDto.java b/src/main/java/com/joycrew/backend/dto/kakao/ExternalProductOptionPointDto.java new file mode 100644 index 0000000..7acc248 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/kakao/ExternalProductOptionPointDto.java @@ -0,0 +1,8 @@ +package com.joycrew.backend.dto.kakao; + +public record ExternalProductOptionPointDto( + String itemId, + String name, + Integer pricePoint, + Integer priceKrw +) {} diff --git a/src/main/java/com/joycrew/backend/dto/kakao/ExternalProductResponse.java b/src/main/java/com/joycrew/backend/dto/kakao/ExternalProductResponse.java new file mode 100644 index 0000000..bd531b7 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/kakao/ExternalProductResponse.java @@ -0,0 +1,9 @@ +package com.joycrew.backend.dto.kakao; + +public record ExternalProductResponse( + String templateId, + String name, + int pointPrice, + int priceKrw, + String thumbnailUrl +) {} diff --git a/src/main/java/com/joycrew/backend/dto/kakao/KakaoGiftDetailV3Request.java b/src/main/java/com/joycrew/backend/dto/kakao/KakaoGiftDetailV3Request.java new file mode 100644 index 0000000..ed3d533 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/kakao/KakaoGiftDetailV3Request.java @@ -0,0 +1,9 @@ +package com.joycrew.backend.dto.kakao; + +import java.util.List; +import java.util.Map; + +/** https://gateway-giftbiz.kakao.com/openapi/giftbiz/v3/template/order/gift 바디 포맷 */ +public record KakaoGiftDetailV3Request( + List> params // 주문 완료 후 수신자 단위 식별 파라미터 +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/kakao/KakaoGiftDetailV3Response.java b/src/main/java/com/joycrew/backend/dto/kakao/KakaoGiftDetailV3Response.java new file mode 100644 index 0000000..e4cbc84 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/kakao/KakaoGiftDetailV3Response.java @@ -0,0 +1,7 @@ +package com.joycrew.backend.dto.kakao; + +import java.util.Map; + +public record KakaoGiftDetailV3Response( + Map data +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/kakao/KakaoTemplateOrderRequest.java b/src/main/java/com/joycrew/backend/dto/kakao/KakaoTemplateOrderRequest.java new file mode 100644 index 0000000..17ba37d --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/kakao/KakaoTemplateOrderRequest.java @@ -0,0 +1,17 @@ +package com.joycrew.backend.dto.kakao; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; +import java.util.Map; + +public record KakaoTemplateOrderRequest( + @JsonProperty("template_token") String templateToken, + @JsonProperty("receiver_type") String receiverType, // "PHONE" + @JsonProperty("receivers") List> receivers, // [{ "receiver_id": "010..." }] + @JsonProperty("success_callback_url") String successCallbackUrl, + @JsonProperty("fail_callback_url") String failCallbackUrl, + @JsonProperty("gift_callback_url") String giftCallbackUrl, // 명세에 맞춰 이름 확인 + @JsonProperty("template_order_name") String templateOrderName, + @JsonProperty("external_order_id") String externalOrderId +) {} diff --git a/src/main/java/com/joycrew/backend/dto/kakao/KakaoTemplateOrderResponse.java b/src/main/java/com/joycrew/backend/dto/kakao/KakaoTemplateOrderResponse.java new file mode 100644 index 0000000..dccaaf3 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/kakao/KakaoTemplateOrderResponse.java @@ -0,0 +1,8 @@ +package com.joycrew.backend.dto.kakao; + +import java.util.Map; + +/** 응답 스키마는 문서 예시에 맞추어 맵으로 유연 처리(필요시 필드 확정) */ +public record KakaoTemplateOrderResponse( + Map data +) {} diff --git a/src/main/java/com/joycrew/backend/entity/Order.java b/src/main/java/com/joycrew/backend/entity/Order.java index e56a1ae..9c76db7 100644 --- a/src/main/java/com/joycrew/backend/entity/Order.java +++ b/src/main/java/com/joycrew/backend/entity/Order.java @@ -7,10 +7,7 @@ import java.time.LocalDateTime; @Entity -@Table(name = "orders", indexes = { - @Index(name = "idx_orders_employee", columnList = "employee_id"), - @Index(name = "idx_orders_status", columnList = "status") -}) +@Table(name = "orders") @Getter @Setter @NoArgsConstructor @@ -20,44 +17,43 @@ public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long orderId; + private Long id; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "employee_id") + /** 주문자 */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "employee_id", nullable = false) private Employee employee; - // Product snapshot at the time of order - @Column(nullable = false) + /** 상품 ID (카카오 템플릿 ID를 해시 변환해서 저장) */ private Long productId; - @Column(nullable = false, length = 1000) + /** 상품 이름 */ private String productName; - @Column(nullable = false, length = 64) - private String productItemId; + /** 옵션 상품 ID (옵션 없는 경우 null) */ + private Long productItemId; - @Column(nullable = false) + /** 단가(포인트 단위) */ private Integer productUnitPrice; - @Column(nullable = false) + /** 수량 */ private Integer quantity; - @Column(nullable = false) + /** 총 금액(포인트 단위) */ private Integer totalPrice; + /** 주문 상태 */ @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 32) private OrderStatus status; - @Column(nullable = false) + /** 주문 일시 */ private LocalDateTime orderedAt; - private LocalDateTime shippedAt; - private LocalDateTime deliveredAt; - - @PrePersist - protected void onCreate() { - if (this.orderedAt == null) this.orderedAt = LocalDateTime.now(); - if (this.status == null) this.status = OrderStatus.PLACED; - } + /** + * 외부 주문번호 (카카오 GiftBiz external_order_id) + * - 카카오 스펙: length ≤ 70 + * - 성공 건은 중복 불가 + */ + @Column(name = "external_order_id", length = 70, unique = true, nullable = false) + private String externalOrderId; } diff --git a/src/main/java/com/joycrew/backend/entity/Product.java b/src/main/java/com/joycrew/backend/entity/Product.java deleted file mode 100644 index e13239f..0000000 --- a/src/main/java/com/joycrew/backend/entity/Product.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.joycrew.backend.entity; - -import com.joycrew.backend.entity.enums.Category; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.persistence.*; -import lombok.*; - -import java.time.LocalDateTime; - -@Entity -@Table(name = "product") -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class Product { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Schema(description = "상품의 고유 ID", example = "1") - private Long id; - - @Column(nullable = false, length = 50) - @Enumerated(EnumType.STRING) - @Schema(description = "상품의 카테고리", example = "BEAUTY") - private Category keyword; - - @Column(nullable = false) - @Schema(description = "상품 순위", example = "1") - private Integer rankOrder; - - @Column(nullable = false, length = 1000) - @Schema(description = "상품명", example = "Smartphone") - private String name; - - @Column(nullable = true, length = 2000) - @Schema(description = "상품 썸네일 URL", example = "https://example.com/image.jpg") - private String thumbnailUrl; - - @Column(nullable = false) - @Schema(description = "상품 가격", example = "499") - private Integer price; - - @Column(nullable = false, length = 2000) - @Schema(description = "상품 상세 URL", example = "https://example.com/product/1") - private String detailUrl; - - @Column(nullable = false, length = 64) - @Schema(description = "상품 고유 아이템 ID", example = "12345") - private String itemId; - - @Column(nullable = false) - @Schema(description = "상품 등록 시간", example = "2025-08-11T10:00:00") - private LocalDateTime registeredAt; -} diff --git a/src/main/java/com/joycrew/backend/entity/RecentProductView.java b/src/main/java/com/joycrew/backend/entity/RecentProductView.java deleted file mode 100644 index f339f82..0000000 --- a/src/main/java/com/joycrew/backend/entity/RecentProductView.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.joycrew.backend.entity; - -import jakarta.persistence.*; -import lombok.*; - -import java.time.LocalDateTime; - -@Entity -@Table(name = "recent_product_view", - uniqueConstraints = @UniqueConstraint(name = "uq_employee_product", columnNames = {"employee_id", "product_id"}), - indexes = { - @Index(name = "idx_rpv_employee_viewed", columnList = "employee_id, viewedAt"), - @Index(name = "idx_rpv_viewed", columnList = "viewedAt") - }) -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class RecentProductView { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - // who viewed - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "employee_id") - private Employee employee; - - // which product - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "product_id") - private Product product; - - @Column(nullable = false) - private LocalDateTime viewedAt; - - @PrePersist - public void onCreate() { - if (this.viewedAt == null) this.viewedAt = LocalDateTime.now(); - } -} diff --git a/src/main/java/com/joycrew/backend/entity/Wallet.java b/src/main/java/com/joycrew/backend/entity/Wallet.java index 5a7aaad..fc5bad9 100644 --- a/src/main/java/com/joycrew/backend/entity/Wallet.java +++ b/src/main/java/com/joycrew/backend/entity/Wallet.java @@ -55,6 +55,10 @@ public void spendGiftablePoints(int amount) { this.giftablePoint -= amount; } + /** + * 개인 구매용 결제: balance 에서만 차감한다. + * giftablePoint 는 선물 가능 한도로 유지한다. + */ public void purchaseWithPoints(int amount) { if (amount < 0) { throw new IllegalArgumentException("Purchase amount cannot be negative."); @@ -62,8 +66,8 @@ public void purchaseWithPoints(int amount) { if (this.balance < amount) { throw new InsufficientPointsException("Insufficient points for purchase."); } - this.balance -= amount; - this.giftablePoint = Math.max(0, this.giftablePoint - amount); + this.balance -= amount; // ✅ 구매는 balance만 차감 + // ✅ giftablePoint는 변경하지 않음 } public void revokePoints(int amount) { diff --git a/src/main/java/com/joycrew/backend/entity/enums/Category.java b/src/main/java/com/joycrew/backend/entity/enums/Category.java deleted file mode 100644 index a196084..0000000 --- a/src/main/java/com/joycrew/backend/entity/enums/Category.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.joycrew.backend.entity.enums; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@Getter -@RequiredArgsConstructor -public enum Category { - BEAUTY("뷰티"), - APPLIANCES("가전"), - FURNITURE("가구"), - CLOTHING("옷"), - FOOD("음식"); - - private final String kr; -} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/enums/GiftCategory.java b/src/main/java/com/joycrew/backend/entity/enums/GiftCategory.java new file mode 100644 index 0000000..0dc99e6 --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/enums/GiftCategory.java @@ -0,0 +1,21 @@ +// src/main/java/com/joycrew/backend/entity/enums/GiftCategory.java +package com.joycrew.backend.entity.enums; + +import lombok.Getter; + +@Getter +public enum GiftCategory { + // 모바일 교환권 + CAFE("카페"), + VOUCHER("상품권"), + CHICKEN_PIZZA_BURGER("치킨/피자/버거"), + BAKERY_DONUT("베이커리/도넛"), + ICE_CREAM("아이스크림"), + CONVENIENCE_STORE("편의점"), + DINING_MEALKIT("외식/간편식"), + LIFESTYLE_MISC("생활편의/기타"), + HOTEL_MEAL("호텔 식사권"); + + private final String koName; + GiftCategory(String koName) { this.koName = koName; } +} diff --git a/src/main/java/com/joycrew/backend/entity/enums/OrderStatus.java b/src/main/java/com/joycrew/backend/entity/enums/OrderStatus.java index 772a1bb..a9f203c 100644 --- a/src/main/java/com/joycrew/backend/entity/enums/OrderStatus.java +++ b/src/main/java/com/joycrew/backend/entity/enums/OrderStatus.java @@ -1,8 +1,7 @@ package com.joycrew.backend.entity.enums; public enum OrderStatus { - PLACED, // 주문 생성(결제 완료) - SHIPPED, // 배송 중 - DELIVERED, // 배송 완료 - CANCELED // 취소(선택) + PENDING, + PLACED, + FAILED } diff --git a/src/main/java/com/joycrew/backend/entity/enums/SortOption.java b/src/main/java/com/joycrew/backend/entity/enums/SortOption.java new file mode 100644 index 0000000..4f440ea --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/enums/SortOption.java @@ -0,0 +1,5 @@ +package com.joycrew.backend.entity.enums; + +public enum SortOption { + POPULAR, PRICE_ASC, PRICE_DESC, NEW +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/kakao/KakaoTemplate.java b/src/main/java/com/joycrew/backend/entity/kakao/KakaoTemplate.java new file mode 100644 index 0000000..d46082e --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/kakao/KakaoTemplate.java @@ -0,0 +1,46 @@ +package com.joycrew.backend.entity.kakao; + +import com.joycrew.backend.entity.enums.GiftCategory; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter @Setter +@NoArgsConstructor @AllArgsConstructor @Builder +public class KakaoTemplate { + + @Id + @Column(length = 64) + private String templateId; // 내부 식별자(프론트 노출 ID) + + @Column(nullable = false, length = 128, unique = true) + private String templateToken; // 카카오 발송용 template_token + + @Column(nullable = false, length = 255) + private String name; + + @Column(nullable = false) + private Integer basePriceKrw; + + @Column(length = 512) + private String thumbnailUrl; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 64) + private GiftCategory joyCategory; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + public void prePersist() { + if (updatedAt == null) updatedAt = LocalDateTime.now(); + } + + @PreUpdate + public void preUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/joycrew/backend/kakao/KakaoGiftBizClient.java b/src/main/java/com/joycrew/backend/kakao/KakaoGiftBizClient.java new file mode 100644 index 0000000..dc18a64 --- /dev/null +++ b/src/main/java/com/joycrew/backend/kakao/KakaoGiftBizClient.java @@ -0,0 +1,75 @@ +// src/main/java/com/joycrew/backend/kakao/KakaoGiftBizClient.java +package com.joycrew.backend.kakao; + +import com.joycrew.backend.dto.kakao.KakaoTemplateOrderRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.web.client.*; + +import java.net.URI; + +import org.springframework.web.client.RestTemplate; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.util.UriComponentsBuilder; + +@Slf4j +@Component +@RequiredArgsConstructor +public class KakaoGiftBizClient { + + private final RestTemplate rt; + + @Value("${kakao.giftbiz.base-url}") + private String baseUrl; + + @Value("${kakao.giftbiz.api-key}") + private String apiKey; + + private HttpHeaders authHeaders() { + HttpHeaders h = new HttpHeaders(); + h.setContentType(MediaType.APPLICATION_JSON); + h.setAccept(java.util.List.of(MediaType.APPLICATION_JSON)); + // Kakao Developers 인증: "KakaoAK <키>" + h.set("Authorization", "KakaoAK " + apiKey); + h.set("User-Agent", "JoyCrewBackend/1.0"); + return h; + } + + /** + * 선물 발송 요청 (v1/template/order) + * 성공 시 바디 파싱을 하지 않고 String으로만 로깅한다. (응답 스펙 변화/빈 바디로 인한 파싱 실패 방지) + * 4xx는 BAD_REQUEST로, I/O는 BAD_GATEWAY로 매핑하여 상위로 던진다. + */ + public String sendTemplateOrder(KakaoTemplateOrderRequest req) { + URI uri = UriComponentsBuilder.fromHttpUrl(baseUrl) + .path("/v1/template/order") + .build(true) + .toUri(); + + try { + log.debug("[KAKAO] ORDER -> {} body={}", uri, req); + ResponseEntity res = rt.exchange( + uri, HttpMethod.POST, new HttpEntity<>(req, authHeaders()), String.class); + + log.info("[KAKAO] ORDER <- status={} hasBody={}", res.getStatusCodeValue(), res.hasBody()); + if (res.hasBody()) { + log.debug("[KAKAO] ORDER BODY: {}", res.getBody()); + } + return res.getBody(); + + } catch (HttpClientErrorException ex) { + // Kakao 4xx 그대로 400으로 매핑 + 본문 노출 + String body = ex.getResponseBodyAsString(); + log.warn("[KAKAO] 4xx order failed: status={} body={}", ex.getStatusCode().value(), body); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "KAKAO_ERROR: " + body, ex); + + } catch (ResourceAccessException ex) { + // 네트워크/DNS/타임아웃 등 I/O 오류 → 502로 매핑 + log.error("[KAKAO] I/O error: {}", ex.getMessage(), ex); + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "KAKAO_IO_ERROR: " + ex.getMessage(), ex); + } + } +} diff --git a/src/main/java/com/joycrew/backend/repository/KakaoTemplateRepository.java b/src/main/java/com/joycrew/backend/repository/KakaoTemplateRepository.java new file mode 100644 index 0000000..9540cac --- /dev/null +++ b/src/main/java/com/joycrew/backend/repository/KakaoTemplateRepository.java @@ -0,0 +1,10 @@ +package com.joycrew.backend.repository; + +import com.joycrew.backend.entity.enums.GiftCategory; +import com.joycrew.backend.entity.kakao.KakaoTemplate; +import org.springframework.data.domain.*; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface KakaoTemplateRepository extends JpaRepository { + Page findByJoyCategory(GiftCategory category, Pageable pageable); +} diff --git a/src/main/java/com/joycrew/backend/repository/OrderRepository.java b/src/main/java/com/joycrew/backend/repository/OrderRepository.java index 18f09c9..e12a340 100644 --- a/src/main/java/com/joycrew/backend/repository/OrderRepository.java +++ b/src/main/java/com/joycrew/backend/repository/OrderRepository.java @@ -11,5 +11,5 @@ public interface OrderRepository extends JpaRepository { Page findByEmployee_EmployeeId(Long employeeId, Pageable pageable); - Optional findByOrderIdAndEmployee_EmployeeId(Long orderId, Long employeeId); + Optional findByIdAndEmployee_EmployeeId(Long orderId, Long employeeId); } diff --git a/src/main/java/com/joycrew/backend/repository/ProductRepository.java b/src/main/java/com/joycrew/backend/repository/ProductRepository.java deleted file mode 100644 index cb060a9..0000000 --- a/src/main/java/com/joycrew/backend/repository/ProductRepository.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.joycrew.backend.repository; - -import com.joycrew.backend.entity.Product; -import com.joycrew.backend.entity.enums.Category; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -public interface ProductRepository extends JpaRepository { - - Page findByKeyword(Category keyword, Pageable pageable); - - @Query(""" - SELECT p - FROM Product p - WHERE (:q IS NULL OR :q = '') - OR LOWER(p.name) LIKE LOWER(CONCAT('%', :q, '%')) - OR p.itemId LIKE CONCAT('%', :q, '%') - """) - Page searchByQuery(@Param("q") String q, Pageable pageable); - - @Query(""" - SELECT p - FROM Product p - WHERE p.keyword = :category - AND ( - :q IS NULL OR :q = '' - OR LOWER(p.name) LIKE LOWER(CONCAT('%', :q, '%')) - OR p.itemId LIKE CONCAT('%', :q, '%') - ) - """) - Page searchByCategoryAndQuery(@Param("category") Category category, - @Param("q") String q, - Pageable pageable); -} diff --git a/src/main/java/com/joycrew/backend/repository/RecentProductViewRepository.java b/src/main/java/com/joycrew/backend/repository/RecentProductViewRepository.java deleted file mode 100644 index c6098e8..0000000 --- a/src/main/java/com/joycrew/backend/repository/RecentProductViewRepository.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.joycrew.backend.repository; - -import com.joycrew.backend.entity.RecentProductView; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -public interface RecentProductViewRepository extends JpaRepository { - - Optional findByEmployee_EmployeeIdAndProduct_Id(Long employeeId, Long productId); - - List findByEmployee_EmployeeIdAndViewedAtAfterOrderByViewedAtDesc( - Long employeeId, LocalDateTime threshold, Pageable pageable - ); - - long deleteByViewedAtBefore(LocalDateTime threshold); -} diff --git a/src/main/java/com/joycrew/backend/scheduler/RecentViewCleanupJob.java b/src/main/java/com/joycrew/backend/scheduler/RecentViewCleanupJob.java deleted file mode 100644 index 53c2a91..0000000 --- a/src/main/java/com/joycrew/backend/scheduler/RecentViewCleanupJob.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.joycrew.backend.scheduler; - -import com.joycrew.backend.service.RecentProductViewService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import org.springframework.scheduling.annotation.Scheduled; - -@Slf4j -@Component -@RequiredArgsConstructor -public class RecentViewCleanupJob { - - private final RecentProductViewService recentProductViewService; - - // 매일 새벽 03:00 (서울 시간대) - @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul") - public void cleanupOldRecentViews() { - long deleted = recentProductViewService.cleanupOldViews(); - if (deleted > 0) { - log.info("Cleaned up {} recent product views older than 3 months.", deleted); - } else { - log.debug("No recent product views to clean."); - } - } -} diff --git a/src/main/java/com/joycrew/backend/service/ExternalCatalogService.java b/src/main/java/com/joycrew/backend/service/ExternalCatalogService.java new file mode 100644 index 0000000..e90c998 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/ExternalCatalogService.java @@ -0,0 +1,55 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.kakao.ExternalProductDetailResponse; +import com.joycrew.backend.dto.kakao.ExternalProductResponse; +import com.joycrew.backend.entity.enums.GiftCategory; +import com.joycrew.backend.entity.enums.SortOption; +import com.joycrew.backend.entity.kakao.KakaoTemplate; +import com.joycrew.backend.repository.KakaoTemplateRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.*; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ExternalCatalogService { + + private final KakaoTemplateRepository templateRepo; + + @Value("${joycrew.points.krw_per_point:40}") + private int krwPerPoint; + + public List listByCategory(GiftCategory category, int page, int size, SortOption sort) { + Sort s = switch (sort) { + case PRICE_ASC -> Sort.by(Sort.Direction.ASC, "basePriceKrw"); + case PRICE_DESC -> Sort.by(Sort.Direction.DESC, "basePriceKrw"); + case POPULAR, NEW -> Sort.by(Sort.Direction.DESC, "updatedAt"); + }; + Page p = templateRepo.findByJoyCategory(category, PageRequest.of(page, size, s)); + + return p.getContent().stream().map(t -> { + int point = (int) Math.ceil(t.getBasePriceKrw() / (double) krwPerPoint); + return new ExternalProductResponse( + t.getTemplateId(), t.getName(), point, t.getBasePriceKrw(), t.getThumbnailUrl() + ); + }).toList(); + } + + public ExternalProductDetailResponse getDetailWithPoints(String templateId) { + var t = templateRepo.findById(templateId).orElse(null); + if (t == null) return null; + + int basePoint = (int) Math.ceil(t.getBasePriceKrw() / (double) krwPerPoint); + + return new ExternalProductDetailResponse( + t.getTemplateId(), + t.getName(), + basePoint, + t.getBasePriceKrw(), + t.getThumbnailUrl() + ); + } +} diff --git a/src/main/java/com/joycrew/backend/service/GiftPurchaseService.java b/src/main/java/com/joycrew/backend/service/GiftPurchaseService.java new file mode 100644 index 0000000..33dbebf --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/GiftPurchaseService.java @@ -0,0 +1,186 @@ +// src/main/java/com/joycrew/backend/service/GiftPurchaseService.java +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.CreateOrderRequest; +import com.joycrew.backend.dto.OrderResponse; +import com.joycrew.backend.dto.kakao.KakaoTemplateOrderRequest; +import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.entity.Order; +import com.joycrew.backend.entity.RewardPointTransaction; +import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.OrderStatus; +import com.joycrew.backend.entity.enums.TransactionType; +import com.joycrew.backend.entity.kakao.KakaoTemplate; +import com.joycrew.backend.exception.UserNotFoundException; +import com.joycrew.backend.kakao.KakaoGiftBizClient; +import com.joycrew.backend.repository.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GiftPurchaseService { + + private final KakaoGiftBizClient kakao; + private final WalletRepository walletRepository; + private final EmployeeRepository employeeRepository; + private final OrderRepository orderRepository; + private final RewardPointTransactionRepository transactionRepository; + private final KakaoTemplateRepository templateRepo; + + @Value("${joycrew.points.krw_per_point:40}") + private int krwPerPoint; + + @Value("${kakao.callback.success-url:}") + private String successCallbackUrl; + @Value("${kakao.callback.fail-url:}") + private String failCallbackUrl; + @Value("${kakao.callback.gift-cancel-url:}") + private String giftCancelCallbackUrl; + + @Value("${joycrew.kakao.dry-run:false}") + private boolean dryRun; + + /** + * 주문은 PENDING으로 먼저 저장 -> Kakao 호출 성공 시 PLACED, 실패 시 FAILED + * 포인트는 선차감하고, 실패 시 환불하여 일관성 유지 + */ + @Transactional + public OrderResponse purchaseWithPoints(Long employeeId, CreateOrderRequest req) { + // 1) 사용자/지갑/템플릿 + Employee employee = employeeRepository.findById(employeeId) + .orElseThrow(() -> new UserNotFoundException("Employee not found")); + Wallet wallet = walletRepository.findByEmployee_EmployeeId(employeeId) + .orElseThrow(() -> new IllegalStateException("Wallet not found")); + + KakaoTemplate template = templateRepo.findById(req.externalProductId()) + .orElseThrow(() -> new IllegalArgumentException("Template not found: " + req.externalProductId())); + + // 2) 금액/포인트 계산 (옵션 제거 버전) + int qty = (req.quantity() == null || req.quantity() <= 0) ? 1 : req.quantity(); + int unitKrw = template.getBasePriceKrw(); + long totalKrw = (long) unitKrw * qty; + int totalPoint = (int) Math.ceil(totalKrw / (double) krwPerPoint); + + // 3) 주문 레코드 PENDING 으로 선저장 (external_order_id 생성) + String externalOrderId = buildExternalOrderId(employeeId, template.getTemplateId()); + Order order = Order.builder() + .employee(employee) + .productId(stableHashToLong(template.getTemplateId())) + .productName(template.getName()) + .productItemId(null) // 옵션 없음 + .productUnitPrice((int) Math.ceil(unitKrw / (double) krwPerPoint)) + .quantity(qty) + .totalPrice(totalPoint) + .status(OrderStatus.PENDING) + .orderedAt(LocalDateTime.now()) + .externalOrderId(externalOrderId) // 필요시 Order 엔티티에 필드 추가 + .build(); + order = orderRepository.save(order); + + // 4) 포인트 선차감 (구매는 balance만 차감) + wallet.purchaseWithPoints(totalPoint); + + // 5) Kakao 요청 바디 구성 (receiver_id = 직원 휴대폰 번호) + String receiverPhone = Optional.ofNullable(employee.getPhoneNumber()) + .map(this::normalizePhone) + .filter(s -> s != null && !s.isBlank()) + .orElseThrow(() -> new IllegalStateException("Employee has no phone number")); + if (receiverPhone.length() < 8) { + throw new IllegalStateException("Invalid receiver phone format: " + receiverPhone); + } + + Map receiverObj = new HashMap<>(); + receiverObj.put("receiver_id", receiverPhone); + + KakaoTemplateOrderRequest kakaoReq = new KakaoTemplateOrderRequest( + template.getTemplateToken(), + "PHONE", + List.of(receiverObj), + emptyToNull(successCallbackUrl), + emptyToNull(failCallbackUrl), + emptyToNull(giftCancelCallbackUrl), + template.getName(), + externalOrderId + ); + + // 6) Kakao 호출 (dry-run 지원) + try { + if (!dryRun) { + String kakaoBody = kakao.sendTemplateOrder(kakaoReq); + log.debug("[KAKAO] ORDER OK externalOrderId={} resBody={}", externalOrderId, kakaoBody); + } else { + log.warn("[DRY-RUN] Skipping Kakao call. Would send: {}", kakaoReq); + } + + // 7) 거래 이력 저장(성공시에만) + RewardPointTransaction tx = RewardPointTransaction.builder() + .sender(employee) + .receiver(null) + .pointAmount(totalPoint) + .message("KAKAO_SELF_PURCHASE:" + template.getTemplateId()) + .type(TransactionType.REDEEM_ITEM) + .build(); + transactionRepository.save(tx); + + // 8) 주문 상태 갱신 -> PLACED + order.setStatus(OrderStatus.PLACED); + order = orderRepository.save(order); + + return OrderResponse.from(order, template.getThumbnailUrl()); + + } catch (ResponseStatusException ex) { + // Kakao 4xx/네트워크 오류 등 → FAILED 처리 + 포인트 환불 + refundWalletSilently(wallet, totalPoint); + order.setStatus(OrderStatus.FAILED); + orderRepository.save(order); + throw ex; // 400/502 그대로 클라이언트에게 + + } catch (RuntimeException ex) { + // 기타 예외 → FAILED 처리 + 포인트 환불 + refundWalletSilently(wallet, totalPoint); + order.setStatus(OrderStatus.FAILED); + orderRepository.save(order); + throw ex; + } + } + + private void refundWalletSilently(Wallet wallet, int totalPoint) { + try { + wallet.refundPoints(totalPoint); + } catch (Exception e) { + log.error("Failed to refund points on error (amount={}): {}", totalPoint, e.getMessage(), e); + // 환불 실패시에도 본 예외를 삼키지 않는다. + } + } + + private String emptyToNull(String s) { + return (s == null || s.isBlank()) ? null : s; + } + + private String normalizePhone(String raw) { + if (raw == null) return null; + return raw.replaceAll("\\D", ""); // 숫자만 남김 (010-xxxx-xxxx → 010xxxxxxxx) + } + + private String buildExternalOrderId(Long employeeId, String templateId) { + // 길이 ≤ 70: "JC-" + employeeId + "-" + 12자리 랜덤 + String rand = UUID.randomUUID().toString().replace("-", "").substring(0, 12); + return "JC-" + employeeId + "-" + rand; + } + + private long stableHashToLong(String s) { + long h = 1469598103934665603L; // FNV-1a 64-bit + for (byte b : s.getBytes()) { h ^= b; h *= 1099511628211L; } + return h & Long.MAX_VALUE; + } +} diff --git a/src/main/java/com/joycrew/backend/service/OrderService.java b/src/main/java/com/joycrew/backend/service/OrderService.java index 7e6e65c..80df97e 100644 --- a/src/main/java/com/joycrew/backend/service/OrderService.java +++ b/src/main/java/com/joycrew/backend/service/OrderService.java @@ -1,139 +1,47 @@ package com.joycrew.backend.service; -import com.joycrew.backend.dto.CreateOrderRequest; import com.joycrew.backend.dto.OrderResponse; import com.joycrew.backend.dto.PagedOrderResponse; -import com.joycrew.backend.entity.*; -import com.joycrew.backend.entity.enums.OrderStatus; -import com.joycrew.backend.entity.enums.TransactionType; -import com.joycrew.backend.repository.*; +import com.joycrew.backend.entity.Order; +import com.joycrew.backend.repository.OrderRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; import java.util.List; -import java.util.Map; import java.util.NoSuchElementException; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor public class OrderService { - private final OrderRepository orderRepository; - private final ProductRepository productRepository; - private final WalletRepository walletRepository; - private final EmployeeRepository employeeRepository; - private final RewardPointTransactionRepository transactionRepository; - - @Transactional - public OrderResponse createOrder(Long employeeId, CreateOrderRequest req) { - Product product = productRepository.findById(req.productId()) - .orElseThrow(() -> new NoSuchElementException("Product not found")); - - Employee employee = employeeRepository.findById(employeeId) - .orElseThrow(() -> new NoSuchElementException("Employee not found")); - - Wallet wallet = walletRepository.findByEmployee_EmployeeId(employeeId) - .orElseThrow(() -> new NoSuchElementException("Wallet not found")); - - int qty = (req.quantity() == null || req.quantity() <= 0) ? 1 : req.quantity(); - int total = product.getPrice() * qty; - - wallet.purchaseWithPoints(total); - - // Create and save the transaction history - RewardPointTransaction transaction = RewardPointTransaction.builder() - .sender(employee) - .receiver(null) // No specific receiver for item redemption - .pointAmount(total) - .message(String.format("Purchased: %s", product.getName())) - .type(TransactionType.REDEEM_ITEM) - .build(); - transactionRepository.save(transaction); - - Order order = Order.builder() - .employee(employee) - .productId(product.getId()) - .productName(product.getName()) - .productItemId(product.getItemId()) - .productUnitPrice(product.getPrice()) - .quantity(qty) - .totalPrice(total) - .status(OrderStatus.PLACED) - .orderedAt(LocalDateTime.now()) - .build(); - - Order saved = orderRepository.save(order); - - return OrderResponse.from(saved, product.getThumbnailUrl()); - } @Transactional(readOnly = true) public PagedOrderResponse getMyOrders(Long employeeId, int page, int size) { PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "orderedAt")); - Page orderPage = orderRepository.findByEmployee_EmployeeId(employeeId, pageable); - - List productIds = orderPage.getContent().stream() - .map(Order::getProductId) - .distinct() - .toList(); - - Map productThumbnailMap = productRepository.findAllById(productIds).stream() - .collect(Collectors.toMap(Product::getId, Product::getThumbnailUrl)); + Page pageData = orderRepository.findByEmployee_EmployeeId(employeeId, pageable); - List orderResponses = orderPage.getContent().stream() - .map(order -> { - String thumbnailUrl = productThumbnailMap.get(order.getProductId()); - return OrderResponse.from(order, thumbnailUrl); - }) - .toList(); + List items = pageData.getContent().stream() + .map(o -> OrderResponse.from(o, null)) + .toList(); return new PagedOrderResponse( - orderResponses, - orderPage.getNumber(), - orderPage.getSize(), - orderPage.getTotalElements(), - orderPage.getTotalPages(), - orderPage.hasNext(), - orderPage.hasPrevious() + items, + pageData.getNumber(), + pageData.getSize(), + pageData.getTotalElements(), + pageData.getTotalPages(), + pageData.hasNext(), + pageData.hasPrevious() ); } @Transactional(readOnly = true) public OrderResponse getMyOrderDetail(Long employeeId, Long orderId) { - Order order = orderRepository.findByOrderIdAndEmployee_EmployeeId(orderId, employeeId) - .orElseThrow(() -> new NoSuchElementException("Order not found")); - - String thumbnailUrl = productRepository.findById(order.getProductId()) - .map(Product::getThumbnailUrl) - .orElse(null); - - return OrderResponse.from(order, thumbnailUrl); + Order order = orderRepository.findByIdAndEmployee_EmployeeId(orderId, employeeId) + .orElseThrow(() -> new NoSuchElementException("Order not found")); + return OrderResponse.from(order, null); } - @Transactional - public OrderResponse cancelMyOrder(Long employeeId, Long orderId) { - Order order = orderRepository.findByOrderIdAndEmployee_EmployeeId(orderId, employeeId) - .orElseThrow(() -> new NoSuchElementException("Order not found")); - - if (order.getStatus() == OrderStatus.SHIPPED || order.getStatus() == OrderStatus.DELIVERED) { - throw new IllegalStateException("Order cannot be canceled after it has been shipped."); - } - - String thumbnailUrl = null; - if (order.getStatus() != OrderStatus.CANCELED) { - Wallet wallet = order.getEmployee().getWallet(); - wallet.refundPoints(order.getTotalPrice()); - order.setStatus(OrderStatus.CANCELED); - } - - thumbnailUrl = productRepository.findById(order.getProductId()) - .map(Product::getThumbnailUrl) - .orElse(null); - - return OrderResponse.from(order, thumbnailUrl); - } -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/service/ProductQueryService.java b/src/main/java/com/joycrew/backend/service/ProductQueryService.java deleted file mode 100644 index bc19baa..0000000 --- a/src/main/java/com/joycrew/backend/service/ProductQueryService.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.joycrew.backend.service; - -import com.joycrew.backend.dto.PagedProductResponse; -import com.joycrew.backend.dto.ProductResponse; -import com.joycrew.backend.entity.Product; -import com.joycrew.backend.entity.enums.Category; -import com.joycrew.backend.repository.ProductRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Optional; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class ProductQueryService { - - private final ProductRepository productRepository; - - // Get all products (paginated) - public PagedProductResponse getAllProducts(int page, int size) { - PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id")); - Page result = productRepository.findAll(pageable); - return PagedProductResponse.from(result); - } - - // Get a single product - public ProductResponse getProductById(Long id) { - return productRepository.findById(id) - .map(ProductResponse::from) - .orElse(null); - } - - // Get by category (paginated) - public PagedProductResponse getProductsByCategory(Category category, int page, int size) { - PageRequest pageable = PageRequest.of( - page, size, - Sort.by(Sort.Direction.ASC, "rankOrder").and(Sort.by(Sort.Direction.DESC, "id")) - ); - Page result = productRepository.findByKeyword(category, pageable); - return PagedProductResponse.from(result); - } - - // Search (fallback to all products if query is empty, with optional category) - public PagedProductResponse searchProducts(String q, Category category, int page, int size) { - PageRequest pageable = PageRequest.of( - page, size, - Sort.by(Sort.Direction.ASC, "rankOrder").and(Sort.by(Sort.Direction.DESC, "id")) - ); - - // If query is null or empty, behavior depends on category - if (q == null || q.trim().isEmpty()) { - return (category == null) - ? getAllProducts(page, size) - : getProductsByCategory(category, page, size); - } - - String keyword = q.trim(); - Page result = (category == null) - ? productRepository.searchByQuery(keyword, pageable) - : productRepository.searchByCategoryAndQuery(category, keyword, pageable); - - return PagedProductResponse.from(result); - } -} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/RecentProductViewService.java b/src/main/java/com/joycrew/backend/service/RecentProductViewService.java deleted file mode 100644 index 144249d..0000000 --- a/src/main/java/com/joycrew/backend/service/RecentProductViewService.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.joycrew.backend.service; - -import com.joycrew.backend.dto.RecentViewedProductResponse; -import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.entity.Product; -import com.joycrew.backend.entity.RecentProductView; -import com.joycrew.backend.repository.EmployeeRepository; -import com.joycrew.backend.repository.ProductRepository; -import com.joycrew.backend.repository.RecentProductViewRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; -import java.util.List; -import java.util.NoSuchElementException; - -@Service -@RequiredArgsConstructor -public class RecentProductViewService { - - private static final int DEFAULT_LIMIT = 20; - - private final RecentProductViewRepository recentProductViewRepository; - private final EmployeeRepository employeeRepository; - private final ProductRepository productRepository; - - // Upsert viewed record - @Transactional - public void recordView(Long employeeId, Long productId) { - Employee employee = employeeRepository.findById(employeeId) - .orElseThrow(() -> new NoSuchElementException("Employee not found")); - Product product = productRepository.findById(productId) - .orElseThrow(() -> new NoSuchElementException("Product not found")); - - LocalDateTime now = LocalDateTime.now(); - - recentProductViewRepository.findByEmployee_EmployeeIdAndProduct_Id(employeeId, productId) - .ifPresentOrElse(existing -> { - existing.setViewedAt(now); - }, () -> { - RecentProductView view = RecentProductView.builder() - .employee(employee) - .product(product) - .viewedAt(now) - .build(); - recentProductViewRepository.save(view); - }); - } - - @Transactional(readOnly = true) - public List getRecentViews(Long employeeId, Integer limit) { - int size = (limit == null || limit <= 0) ? DEFAULT_LIMIT : Math.min(limit, 100); - LocalDateTime threshold = LocalDateTime.now().minus(3, ChronoUnit.MONTHS); - - var views = recentProductViewRepository - .findByEmployee_EmployeeIdAndViewedAtAfterOrderByViewedAtDesc( - employeeId, threshold, PageRequest.of(0, size) - ); - - return views.stream() - .map(v -> RecentViewedProductResponse.of(v.getProduct(), v.getViewedAt())) - .toList(); - } - - // Called by a scheduler to keep the table small - @Transactional - public long cleanupOldViews() { - LocalDateTime threshold = LocalDateTime.now().minusMonths(3); - return recentProductViewRepository.deleteByViewedAtBefore(threshold); - } -} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7ab6cc5..7d9eb32 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -83,3 +83,17 @@ jobs: recent-view-cleanup: enabled: true cron: "0 0 3 * * *" + +kakao: + giftbiz: + base-url: https://gateway-giftbiz.kakao.com/openapi/giftbiz + api-key: ${KAKAO_GIFT_BIZ_API_KEY} + timeout-ms: 5000 + callback: + success-url: https://api.joycrew.co.kr/kakao/callback/success + fail-url: https://api.joycrew.co.kr/kakao/callback/fail + gift-cancel-url: https://api.joycrew.co.kr/kakao/callback/gift-cancel + +joycrew: + points: + krw_per_point: 40 \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java b/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java deleted file mode 100644 index ad048be..0000000 --- a/src/test/java/com/joycrew/backend/controller/AdminEmployeeControllerTest.java +++ /dev/null @@ -1,137 +0,0 @@ -package com.joycrew.backend.controller; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.joycrew.backend.dto.AdminEmployeeUpdateRequest; -import com.joycrew.backend.dto.AdminPointDistributionRequest; -import com.joycrew.backend.dto.EmployeeRegistrationRequest; -import com.joycrew.backend.dto.PointDistributionDetail; -import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.entity.enums.AdminLevel; -import com.joycrew.backend.entity.enums.TransactionType; -import com.joycrew.backend.security.WithMockUserPrincipal; -import com.joycrew.backend.service.AdminDashboardService; -import com.joycrew.backend.service.AdminPointService; -import com.joycrew.backend.service.EmployeeManagementService; -import com.joycrew.backend.service.EmployeeRegistrationService; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.mock.web.MockMultipartFile; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.web.servlet.MockMvc; - -import java.nio.charset.StandardCharsets; -import java.util.List; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -@WebMvcTest(controllers = AdminEmployeeController.class) -class AdminEmployeeControllerTest { - - @Autowired private MockMvc mockMvc; - @Autowired private ObjectMapper objectMapper; - - @MockBean private EmployeeRegistrationService registrationService; - @MockBean private EmployeeManagementService managementService; - @MockBean private AdminPointService pointService; - @MockBean private AdminDashboardService adminDashboardService; // MockBean 추가 - - @Test - @WithMockUser(roles = "SUPER_ADMIN") - @DisplayName("POST /api/admin/employees - Should register employee successfully") - void registerEmployee_success() throws Exception { - // Given - EmployeeRegistrationRequest request = new EmployeeRegistrationRequest( - "Jane Doe", "jane.doe@example.com", "password123!", - "JoyCrew", "HR", "Staff", AdminLevel.EMPLOYEE, - null, null, null - ); - Employee mockEmployee = Employee.builder().employeeId(1L).build(); - when(registrationService.registerEmployee(any(EmployeeRegistrationRequest.class))) - .thenReturn(mockEmployee); - - // When & Then - mockMvc.perform(post("/api/admin/employees") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - .with(csrf())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("Employee created successfully (ID: 1)")); - } - - @Test - @WithMockUser(roles = "SUPER_ADMIN") - @DisplayName("POST /api/admin/employees/bulk - Should bulk register employees successfully") - void registerEmployeesFromCsv_success() throws Exception { - // Given - MockMultipartFile file = new MockMultipartFile( - "file", "employees.csv", "text/csv", - "name,email,initialPassword,companyName,departmentName,position,role\n".getBytes(StandardCharsets.UTF_8) - ); - - // When & Then - mockMvc.perform(multipart("/api/admin/employees/bulk") - .file(file) - .with(csrf())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("CSV processed and employee registration completed.")); - } - - @Test - @DisplayName("PATCH /api/admin/employees/{id} - Should update employee successfully") - @WithMockUser(roles = "SUPER_ADMIN") - void updateEmployee_Success() throws Exception { - // Given - AdminEmployeeUpdateRequest request = new AdminEmployeeUpdateRequest("Updated Name", null, "Manager", null, null); - - // When & Then - mockMvc.perform(patch("/api/admin/employees/1") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - .with(csrf())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("Employee information updated successfully.")); - } - - @Test - @DisplayName("DELETE /api/admin/employees/{id} - Should deactivate employee successfully") - @WithMockUser(roles = "SUPER_ADMIN") - void deleteEmployee_Success() throws Exception { - // When & Then - mockMvc.perform(delete("/api/admin/employees/1") - .with(csrf())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("Employee successfully deactivated.")); - } - - @Test - @DisplayName("POST /api/admin/points/distribute - Should distribute points successfully") - @WithMockUserPrincipal(role="SUPER_ADMIN") - void distributePoints_Success() throws Exception { - // Given - List distributions = List.of( - new PointDistributionDetail(1L, 100), - new PointDistributionDetail(2L, 100) - ); - - AdminPointDistributionRequest request = new AdminPointDistributionRequest( - distributions, - "Bonus", - TransactionType.ADMIN_ADJUSTMENT - ); - // When & Then - mockMvc.perform(post("/api/admin/employees/points/distribute") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - .with(csrf())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("Point distribution process completed successfully.")); - } -} diff --git a/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java b/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java deleted file mode 100644 index b7103a8..0000000 --- a/src/test/java/com/joycrew/backend/controller/EmployeeQueryControllerTest.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.joycrew.backend.controller; - -import com.joycrew.backend.dto.EmployeeQueryResponse; -import com.joycrew.backend.dto.PagedEmployeeResponse; -import com.joycrew.backend.security.EmployeeDetailsService; -import com.joycrew.backend.security.JwtUtil; -import com.joycrew.backend.security.WithMockUserPrincipal; -import com.joycrew.backend.service.EmployeeQueryService; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MockMvc; - -import java.util.List; - -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -@WebMvcTest(controllers = EmployeeQueryController.class) -class EmployeeQueryControllerTest { - - @Autowired - private MockMvc mockMvc; - @MockBean - private EmployeeQueryService employeeQueryService; - @MockBean - private JwtUtil jwtUtil; - @MockBean - private EmployeeDetailsService employeeDetailsService; - - @Test - @DisplayName("GET /api/employee/query - Should search employees successfully") - @WithMockUserPrincipal - void searchEmployees_success() throws Exception { - // Given - EmployeeQueryResponse mockEmployee = new EmployeeQueryResponse( - 2L, "https://cdn.joycrew.com/profile/user1.jpg", - "Jane Doe", "HR", "Staff" - ); - PagedEmployeeResponse mockResponse = new PagedEmployeeResponse(List.of(mockEmployee), 0, 1, true); - when(employeeQueryService.getEmployees(anyString(), anyInt(), anyInt(), anyLong())) - .thenReturn(mockResponse); - - // When & Then - mockMvc.perform(get("/api/employee/query") - .param("keyword", "Jane") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.employees[0].employeeName").value("Jane Doe")) - .andExpect(jsonPath("$.currentPage").value(0)); - } -} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/StatisticsControllerTest.java b/src/test/java/com/joycrew/backend/controller/StatisticsControllerTest.java deleted file mode 100644 index 151eef5..0000000 --- a/src/test/java/com/joycrew/backend/controller/StatisticsControllerTest.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.joycrew.backend.controller; - -import com.joycrew.backend.dto.PointStatisticsResponse; -import com.joycrew.backend.dto.TransactionHistoryResponse; -import com.joycrew.backend.security.EmployeeDetailsService; -import com.joycrew.backend.security.JwtUtil; -import com.joycrew.backend.security.WithMockUserPrincipal; -import com.joycrew.backend.service.StatisticsService; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MockMvc; - -import java.util.Collections; -import java.util.List; - -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -@WebMvcTest(StatisticsController.class) -class StatisticsControllerTest { - - @Autowired - private MockMvc mockMvc; - - @MockBean - private StatisticsService statisticsService; - - @MockBean - private JwtUtil jwtUtil; - @MockBean - private EmployeeDetailsService employeeDetailsService; - - @Test - @DisplayName("GET /api/statistics/me - Should return detailed statistics successfully") - @WithMockUserPrincipal - void getMyStatistics_Success() throws Exception { - // Given - List mockTagCounts = List.of(3L, 5L, 6L, 0L, 0L, 0L, 0L, 0L); - List mockRecentTransactions = Collections.emptyList(); - PointStatisticsResponse mockResponse = new PointStatisticsResponse( - 100, 50, mockTagCounts, mockRecentTransactions - ); - - when(statisticsService.getPointStatistics(anyString())).thenReturn(mockResponse); - - // When & Then - mockMvc.perform(get("/api/statistics/me")) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.totalPointsReceived").value(100)) - .andExpect(jsonPath("$.totalPointsSent").value(50)) - .andExpect(jsonPath("$.tagCounts[0]").value(3)) - .andExpect(jsonPath("$.tagCounts[1]").value(5)) - .andExpect(jsonPath("$.recentTransactions").isArray()); - } -} \ No newline at end of file diff --git a/src/test/java/com/joycrew/backend/controller/UserControllerTest.java b/src/test/java/com/joycrew/backend/controller/UserControllerTest.java deleted file mode 100644 index fd2f15b..0000000 --- a/src/test/java/com/joycrew/backend/controller/UserControllerTest.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.joycrew.backend.controller; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.joycrew.backend.dto.PasswordChangeRequest; -import com.joycrew.backend.dto.UserProfileResponse; -import com.joycrew.backend.dto.UserProfileUpdateRequest; -import com.joycrew.backend.entity.enums.AdminLevel; -import com.joycrew.backend.security.EmployeeDetailsService; -import com.joycrew.backend.security.JwtUtil; -import com.joycrew.backend.security.WithMockUserPrincipal; -import com.joycrew.backend.service.EmployeeService; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.mock.web.MockMultipartFile; -import org.springframework.test.web.servlet.MockMvc; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.when; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@WebMvcTest(controllers = UserController.class) -class UserControllerTest { - - @Autowired - private MockMvc mockMvc; - @Autowired - private ObjectMapper objectMapper; - @MockBean - private EmployeeService employeeService; - @MockBean - private JwtUtil jwtUtil; - @MockBean - private EmployeeDetailsService employeeDetailsService; - - @Test - @DisplayName("GET /api/user/profile - Should get profile successfully") - @WithMockUserPrincipal - void getProfile_Success() throws Exception { - // Given - UserProfileResponse mockResponse = new UserProfileResponse( - 1L, "Test User", "testuser@joycrew.com", null, - 1500, 100, AdminLevel.EMPLOYEE, "Engineering", "Staff", - null, null, null - ); - when(employeeService.getUserProfile("testuser@joycrew.com")).thenReturn(mockResponse); - - // When & Then - mockMvc.perform(get("/api/user/profile")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.name").value("Test User")); - } - - @Test - @DisplayName("POST /api/user/password - Should change password successfully") - @WithMockUserPrincipal - void forcePasswordChange_Success() throws Exception { - // Given - PasswordChangeRequest request = new PasswordChangeRequest("newPassword123!"); - doNothing().when(employeeService).forcePasswordChange(eq("testuser@joycrew.com"), any(PasswordChangeRequest.class)); - - // When & Then - mockMvc.perform(post("/api/user/password") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - .with(csrf())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("Password changed successfully.")); - } - - @Test - @DisplayName("PATCH /api/user/profile - Should update profile successfully") - @WithMockUserPrincipal - void updateMyProfile_Success() throws Exception { - // Given - UserProfileUpdateRequest requestDto = new UserProfileUpdateRequest("New Name", null, null, null, null, "New Address"); - MockMultipartFile requestPart = new MockMultipartFile("request", "", "application/json", objectMapper.writeValueAsBytes(requestDto)); - MockMultipartFile imagePart = new MockMultipartFile("profileImage", "image.jpg", MediaType.IMAGE_JPEG_VALUE, "image_bytes".getBytes()); - - // When & Then - mockMvc.perform(multipart("/api/user/profile") - .file(requestPart) - .file(imagePart) - .with(req -> { - req.setMethod("PATCH"); - return req; - }) - .with(csrf())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("Your information has been updated successfully.")); - } -} \ No newline at end of file From 9d8a9a11f1baff8c506999f9242b12b854ae9bc0 Mon Sep 17 00:00:00 2001 From: Jiwon Kim Date: Thu, 2 Oct 2025 14:34:20 +0900 Subject: [PATCH 111/135] =?UTF-8?q?release=20:=20kakao-biz=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 80 ++++++++++++++++++++----- src/main/resources/application-prod.yml | 74 ++++++++++++++++------- src/main/resources/application.yml | 65 +++++--------------- 3 files changed, 133 insertions(+), 86 deletions(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index c3ce73b..b99d939 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -1,39 +1,87 @@ # =================================================================== -# DEVELOPMENT PROFILE +# DEVELOPMENT PROFILE (개발 환경) # -# Properties that are active when the 'dev' profile is enabled. +# 'dev' 프로파일이 활성화되었을 때 적용되는 설정입니다. +# 민감 정보는 더미(dummy) 값으로 채워져 있어, 별도 설정 없이도 +# 애플리케이션을 즉시 실행하고 기본적인 기능을 테스트할 수 있습니다. # =================================================================== + spring: - # For development, an in-memory H2 database is used. + # 개발 중에는 H2 인메모리 데이터베이스를 사용합니다. datasource: - url: jdbc:h2:mem:testdb + url: jdbc:h2:mem:testdb;MODE=MySQL driver-class-name: org.h2.Driver username: sa - password: - # Enables the H2 database web console for direct access. + password: "" + + # H2 데이터베이스 콘솔 활성화 (http://localhost:8082/h2-console) h2: console: enabled: true - # The database schema is created from scratch on every application start. + path: /h2-console + + # JPA 및 Hibernate 설정 jpa: + # 실행 시마다 스키마를 새로 생성하여 깨끗한 상태에서 시작합니다. hibernate: ddl-auto: create-drop + properties: + hibernate: + format_sql: true defer-datasource-initialization: true - - # Uses development-specific Gmail credentials. - # NOTE: Using an App Password instead of the actual password is recommended. + # 개발용 메일 서버 설정 (실제 발송 테스트 시 환경변수로 설정 필요) mail: username: joycrew.team@gmail.com - password: abcd efgh ijkl mnop + password: ${DEV_MAIL_PASSWORD:dummy-mail-password} -# Development JWT secret key (safe to expose for local development). +# 개발용 JWT 시크릿 키 jwt: - secret: dev-test-secret-key-for-joycrew-backend-at-least-256-bits-long! + expiration-ms: 3600000 + password-reset-expiration-ms: 900000 + secret: ${DEV_JWT_SECRET:dev-super-secret-key-that-is-long-enough-for-hs256} + +# 개발용 프론트엔드 및 KYC 설정 +app: + frontend-url: http://localhost:3000 + sms: + provider: solapi + from-number: "01044907174" # 개발용 발신번호 + kyc: + token-secret: ${DEV_KYC_TOKEN_SECRET:dev-kyc-super-secret-key-for-testing-purposes} + token-ttl-minutes: 10 + +# 개발용 Solapi (SMS) 설정 (실제 테스트 시 환경변수로 설정 필요) +solapi: + api-key: ${DEV_SOLAPI_API_KEY:dummy-solapi-key} + api-secret: ${DEV_SOLAPI_API_SECRET:dummy-solapi-secret} + base-url: "https://api.solapi.com" + +# 개발용 OTP 정책 +otp: + ttl-minutes: 5 + max-attempts: 5 + resend-cooldown-seconds: 30 + +# 개발용 카카오 API 설정 (실제 테스트 시 환경변수로 설정 필요) +kakao: + giftbiz: + base-url: https://gateway-giftbiz.kakao.com/openapi/giftbiz + api-key: ${DEV_KAKAO_API_KEY:dummy-kakao-api-key} + timeout-ms: 5000 + callback: + success-url: http://localhost:8082/kakao/callback/success + fail-url: http://localhost:8082/kakao/callback/fail + gift-cancel-url: http://localhost:8082/kakao/callback/gift-cancel + +# 개발용 포인트 설정 +joycrew: + points: + krw_per_point: 40 -# Log levels are adjusted for detailed output during development. +# 개발 시 상세한 로그를 보기 위한 레벨 설정 logging: level: + com.joycrew.backend: DEBUG org.hibernate.SQL: DEBUG - org.hibernate.type.descriptor.sql: TRACE - com.joycrew.backend: DEBUG \ No newline at end of file + org.hibernate.orm.jdbc.bind: TRACE \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 5eeb437..e544d21 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -1,40 +1,72 @@ # =================================================================== -# PRODUCTION PROFILE +# PRODUCTION PROFILE (운영 환경) # -# Properties that are active when the 'prod' profile is enabled. +# 'prod' 프로파일이 활성화되었을 때 적용되는 설정입니다. +# 모든 민감 정보는 배포 시 AWS Secrets Manager를 통해 환경변수로 주입됩니다. # =================================================================== + spring: - config: - import: optional:aws-secretsmanager:security - # In production, the application connects to an external MySQL database. - # For security, database credentials are read from environment variables. + # 운영 환경에서는 외부 MySQL 데이터베이스를 사용합니다. datasource: driver-class-name: com.mysql.cj.jdbc.Driver - # Updates the schema on application start if it differs from the entities. + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + + # 운영 환경용 메일 계정 정보 + mail: + username: ${SPRING_MAIL_USERNAME} + password: ${SPRING_MAIL_PASSWORD} + + # JPA 및 Hibernate 설정 jpa: hibernate: - ddl-auto: update + ddl-auto: update # 운영 환경에서는 스키마를 자동으로 변경 show-sql: false -# In production, logs are written to a file for persistence and analysis. +# 운영 환경용 JWT 시크릿 키 +jwt: + expiration-ms: 3600000 + password-reset-expiration-ms: 900000 + secret: ${JWT_SECRET} + +# 운영 환경의 프론트엔드 서비스 주소 +app: + frontend-url: https://www.joycrew.co.kr + kyc: + token-secret: ${KYC_TOKEN_SECRET} + token-ttl-minutes: 10 + +# 운영용 Solapi (SMS) 키 +solapi: + api-key: ${SOLAPI_API_KEY} + api-secret: ${SOLAPI_API_SECRET} + base-url: "https://api.solapi.com" + +# 운영용 카카오 API 설정 +kakao: + giftbiz: + base-url: ${KAKAO_GIFTBIZ_BASE-URL} + api-key: ${KAKAO_GIFTBIZ_API-KEY} + timeout-ms: 5000 + callback: + success-url: ${KAKAO_CALLBACK_SUCCESS-URL} + fail-url: ${KAKAO_CALLBACK_FAIL-URL} + gift-cancel-url: ${KAKAO_CALLBACK_GIFT-CANCEL-URL} + +# 운영용 포인트 설정 +joycrew: + points: + krw_per_point: ${JOYCREW_POINTS_KRW_PER_POINT:40} + +# 운영 환경에서는 로그를 파일로 기록하여 관리합니다. logging: - level: - com.joycrew.backend: INFO file: name: /var/log/joycrew/app.log max-size: 10MB max-history: 7 -# The frontend service URL for the production environment. -app: - frontend-url: https://www.joycrew.co.kr - -# AWS specific settings for production -aws: - s3: - # Production S3 bucket name - bucket-name: 'joycrew-prod-bucket' - +# 운영 서버의 톰캣 접근 로그 설정 server: tomcat: accesslog: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7ab6cc5..33deac7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,19 +1,23 @@ # =================================================================== -# COMMON CONFIGURATION +# COMMON CONFIGURATION (모든 환경 공통 설정) # -# This file contains default properties shared across all profiles (dev, prod, etc.). -# Profile-specific properties in files like 'application-dev.yml' will override these values. +# 이 파일은 dev, prod 등 모든 프로파일에 공통적으로 적용되는, +# 민감하지 않고 거의 변경되지 않는 기본 설정만 포함합니다. # =================================================================== + server: port: 8082 spring: application: name: joycrew - # Specifies the default active profile. + + # 기본 활성 프로파일을 'dev'로 설정합니다. + # EC2 배포 시에는 '-Dspring.profiles.active=prod' 옵션으로 'prod'가 활성화됩니다. profiles: active: dev - # Common mail server settings used by all profiles. + + # 모든 환경에서 공통으로 사용하는 메일 서버 정보 mail: host: smtp.gmail.com port: 587 @@ -24,62 +28,25 @@ spring: starttls: enable: true -# API documentation (Swagger/OpenAPI) settings. +# API 문서 (Swagger/OpenAPI) 공통 설정 springdoc: api-docs: path: /v3/api-docs swagger-ui: path: /swagger-ui.html -# Default JWT settings. -jwt: - # Default access token expiration: 1 hour (3,600,000 ms) - expiration-ms: 3600000 - # Password reset token expiration: 15 minutes (900,000 ms) - password-reset-expiration-ms: 900000 - -# Common logging settings. +# 로깅 공통 설정 logging: pattern: console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" level: + # 기본 로그 레벨은 INFO로 설정하고, 각 프로파일에서 필요에 따라 오버라이드합니다. com.joycrew.backend: INFO + org.springframework.web: INFO + org.hibernate: INFO -# Application-specific common properties. -app: - # Default frontend URL, primarily for the 'dev' environment. - frontend-url: http://localhost:3000 - - # KYC / SMS verification (defaults for all profiles) - sms: - provider: solapi - from-number: "01044907174" - - kyc: - # KYC 토큰 서명키(32바이트+ 권장). 환경변수로 주입 가능. - token-secret: ${KYC_TOKEN_SECRET:CHANGE_ME_TO_LONG_RANDOM_SECRET} - # KYC 토큰 유효시간(분) - token-ttl-minutes: 10 - -solapi: - api-key: ${SOLAPI_API_KEY} - api-secret: ${SOLAPI_API_SECRET} - base-url: "https://api.solapi.com" - -# OTP policy (코드 유효/재전송/시도 제한) -otp: - ttl-minutes: 5 # OTP 유효시간(분) - max-attempts: 5 # 최대 시도 횟수 - resend-cooldown-seconds: 30 # 재전송 쿨다운(초) - -# AWS specific settings +# AWS 공통 설정 aws: region: 'ap-northeast-2' s3: - bucket-name: 'joycrew-dev-bucket' - -# view-cleanup-scheduling -jobs: - recent-view-cleanup: - enabled: true - cron: "0 0 3 * * *" + bucket-name: 'joycrew-dev-bucket' \ No newline at end of file From 9c2ff3b0a2ce70dffeea6c53b2c095063cbe9fba Mon Sep 17 00:00:00 2001 From: Jiwon Kim Date: Thu, 2 Oct 2025 16:13:40 +0900 Subject: [PATCH 112/135] release : fix RDS --- .github/workflows/ci-cd.yml | 132 ++++++++---------- .../backend/entity/enums/GiftCategory.java | 1 - src/main/resources/application-prod.yml | 29 ++-- 3 files changed, 68 insertions(+), 94 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 27bf5b7..2405946 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -1,8 +1,10 @@ name: Deploy to EC2 (Self-contained) + on: push: branches: - main + jobs: build-and-deploy: runs-on: ubuntu-latest @@ -10,22 +12,22 @@ jobs: # 1. 소스 코드 체크아웃 - name: Checkout source code uses: actions/checkout@v3 - + # 2. JDK 17 설정 - name: Set up JDK 17 uses: actions/setup-java@v3 with: java-version: '17' distribution: 'temurin' - + # 3. gradlew 실행 권한 추가 - name: Grant execute permission for gradlew run: chmod +x ./gradlew - + # 4. Gradle로 프로젝트 빌드 - name: Build with Gradle run: ./gradlew build -x test - + # 5. 빌드된 JAR 파일을 EC2로 전송 - name: Transfer JAR to EC2 uses: appleboy/scp-action@v0.1.4 @@ -36,112 +38,94 @@ jobs: source: "build/libs/*.jar" target: "/home/${{ secrets.SSH_USER }}/app" strip_components: 2 - - # 6. 배포 스크립트 생성 및 실행 + + # 6. EC2에 접속하여 배포 스크립트 실행 - name: Deploy on EC2 uses: appleboy/ssh-action@v0.1.10 with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_KEY }} - envs: AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY - command_timeout: 15m + envs: AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY # AWS 시크릿 전달 script: | - # 배포 스크립트를 파일로 생성 + # --- 아래 스크립트는 EC2 서버 내부에서 실행됩니다 --- + cat > /tmp/deploy.sh << 'DEPLOY_SCRIPT_EOF' #!/bin/bash - - # AWS 환경변수 설정 (부모 프로세스에서 전달받음) + set -e # 한 명령어라도 실패하면 즉시 스크립트를 중단합니다. + + # GitHub Actions로부터 전달받은 변수 설정 export AWS_ACCESS_KEY_ID="$1" export AWS_SECRET_ACCESS_KEY="$2" export SSH_USER="$3" - - # -------------------------------------------------- - # AWS Secrets Manager 설정 - # -------------------------------------------------- + + APP_DIR="/home/${SSH_USER}/app" + LOG_DIR="${APP_DIR}/logs" + + # 로그 디렉토리 생성 (없을 경우) + mkdir -p $LOG_DIR + + echo "> Secrets Manager에서 환경변수를 가져옵니다." SECRET_NAME="security" AWS_REGION="ap-northeast-2" - - echo "> Secrets Manager에서 환경변수를 가져옵니다." SECRET_JSON=$(aws secretsmanager get-secret-value --secret-id $SECRET_NAME --region $AWS_REGION --query SecretString --output text) - - # 환경변수를 파일로 저장 후 source - echo "$SECRET_JSON" | jq -r 'to_entries | map({key: .key | ascii_upcase | gsub("\\."; "_"), value: .value}) | map(.key + "=\"" + (.value | tostring) + "\"") | .[]' > /tmp/env_vars.sh - source /tmp/env_vars.sh - rm /tmp/env_vars.sh - echo "> 환경변수 로딩 완료." - - # 애플리케이션 디렉토리 설정 - APP_DIR="/home/${SSH_USER}/app" - + + # [핵심 수정] source 방식 대신, Spring Boot가 직접 읽을 .env 파일을 생성합니다. + echo "> JAR 파일과 동일한 위치에 .env 파일을 생성합니다." + echo "$SECRET_JSON" | jq -r 'to_entries | map({key: .key | ascii_upcase | gsub("\\."; "_"), value: .value}) | map(.key + "=" + .value) | .[]' > "${APP_DIR}/.env" + echo "> .env 파일 생성 완료." + # 기존 애플리케이션 종료 - echo "> 기존 애플리케이션 확인 중..." - CURRENT_PID=$(pgrep -f "java.*\.jar") - - if [ -n "$CURRENT_PID" ]; then + echo "> 기존 애플리케이션을 확인하고 종료합니다." + if pgrep -f "java.*\.jar" > /dev/null; then + CURRENT_PID=$(pgrep -f "java.*\.jar") echo "> 실행 중인 애플리케이션을 종료합니다. (PID: $CURRENT_PID)" kill -15 $CURRENT_PID - - # 프로세스가 종료될 때까지 대기 (최대 30초) - for i in {1..30}; do - if ! ps -p $CURRENT_PID > /dev/null 2>&1; then - echo "> 애플리케이션이 정상 종료되었습니다." - break - fi - echo "> 종료 대기 중... ($i/30)" - sleep 1 - done - - # 여전히 실행 중이면 강제 종료 - if ps -p $CURRENT_PID > /dev/null 2>&1; then - echo "> 강제 종료합니다." - kill -9 $CURRENT_PID - sleep 2 - fi + sleep 10 # 종료 대기 else echo "> 현재 실행 중인 애플리케이션이 없습니다." fi - + # 새 애플리케이션 실행 - JAR_NAME=$(ls -tr $APP_DIR/*.jar 2>/dev/null | tail -n 1) - + JAR_NAME=$(ls -tr $APP_DIR/*.jar | tail -n 1) if [ -z "$JAR_NAME" ]; then - echo "> ERROR: JAR 파일을 찾을 수 없습니다." + echo "> ERROR: 배포할 JAR 파일을 찾을 수 없습니다." exit 1 fi - + echo "> 새 애플리케이션을 배포합니다: $JAR_NAME" - - # nohup으로 백그라운드 실행 - nohup java -jar -Dspring.profiles.active=prod "$JAR_NAME" > "$APP_DIR/application.log" 2>&1 < /dev/null & - NEW_PID=$! - - # 프로세스 시작 확인 - sleep 3 - if ps -p $NEW_PID > /dev/null 2>&1; then - echo "> 애플리케이션이 성공적으로 시작되었습니다. (PID: $NEW_PID)" - echo "$NEW_PID" > "$APP_DIR/app.pid" + + # [핵심 수정] Spring Boot가 .env 파일을 자동으로 읽도록 실행 옵션을 추가합니다. + nohup java -jar \ + -Dspring.profiles.active=prod \ + -Dspring.config.import=optional:file:.env \ + "$JAR_NAME" > "$LOG_DIR/application.log" 2>&1 & + + # 애플리케이션 시작 확인 (더 긴 대기시간과 상세한 로그 출력) + echo "> 애플리케이션 시작을 15초간 기다립니다..." + sleep 15 + + if pgrep -f "$JAR_NAME" > /dev/null; then + echo "> 애플리케이션이 성공적으로 시작되었습니다." else + echo "----------------------------------------" echo "> ERROR: 애플리케이션 시작에 실패했습니다." - echo "> 로그 확인: tail -50 $APP_DIR/application.log" + echo "> 아래 로그를 확인하여 원인을 파악하세요." + echo "--- 최근 로그 50줄 ---" + tail -n 50 "$LOG_DIR/application.log" + echo "----------------------------------------" exit 1 fi echo "> 배포 완료!" DEPLOY_SCRIPT_EOF - + # 스크립트 실행 권한 부여 chmod +x /tmp/deploy.sh - # 백그라운드에서 배포 스크립트 실행 (SSH 세션과 분리) - echo "> 배포 스크립트를 백그라운드에서 실행합니다..." + # 백그라운드에서 배포 스크립트 실행 + echo "> 배포 스크립트를 백그라운드에서 실행합니다." nohup /tmp/deploy.sh "$AWS_ACCESS_KEY_ID" "$AWS_SECRET_ACCESS_KEY" "${{ secrets.SSH_USER }}" > /tmp/deploy.log 2>&1 & - - echo "> 배포 스크립트가 시작되었습니다." - echo "> 배포 로그 확인: tail -f /tmp/deploy.log" - - # 배포 스크립트가 시작될 시간을 준 후 SSH 종료 - sleep 2 env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/enums/GiftCategory.java b/src/main/java/com/joycrew/backend/entity/enums/GiftCategory.java index 0dc99e6..c85fd9e 100644 --- a/src/main/java/com/joycrew/backend/entity/enums/GiftCategory.java +++ b/src/main/java/com/joycrew/backend/entity/enums/GiftCategory.java @@ -1,4 +1,3 @@ -// src/main/java/com/joycrew/backend/entity/enums/GiftCategory.java package com.joycrew.backend.entity.enums; import lombok.Getter; diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index e544d21..37b2fb1 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -6,72 +6,63 @@ # =================================================================== spring: - # 운영 환경에서는 외부 MySQL 데이터베이스를 사용합니다. datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: ${SPRING_DATASOURCE_URL} username: ${SPRING_DATASOURCE_USERNAME} password: ${SPRING_DATASOURCE_PASSWORD} - # 운영 환경용 메일 계정 정보 mail: username: ${SPRING_MAIL_USERNAME} password: ${SPRING_MAIL_PASSWORD} - # JPA 및 Hibernate 설정 jpa: hibernate: - ddl-auto: update # 운영 환경에서는 스키마를 자동으로 변경 + ddl-auto: update show-sql: false -# 운영 환경용 JWT 시크릿 키 jwt: expiration-ms: 3600000 password-reset-expiration-ms: 900000 secret: ${JWT_SECRET} -# 운영 환경의 프론트엔드 서비스 주소 app: frontend-url: https://www.joycrew.co.kr kyc: token-secret: ${KYC_TOKEN_SECRET} token-ttl-minutes: 10 -# 운영용 Solapi (SMS) 키 solapi: api-key: ${SOLAPI_API_KEY} api-secret: ${SOLAPI_API_SECRET} base-url: "https://api.solapi.com" -# 운영용 카카오 API 설정 +# [수정] 플레이스홀더의 '.'을 '_'로 변경하여 배포 스크립트와 일치시킴 kakao: giftbiz: - base-url: ${KAKAO_GIFTBIZ_BASE-URL} - api-key: ${KAKAO_GIFTBIZ_API-KEY} + base-url: ${KAKAO_GIFTBIZ_BASE_URL} + api-key: ${KAKAO_GIFTBIZ_API_KEY} timeout-ms: 5000 callback: - success-url: ${KAKAO_CALLBACK_SUCCESS-URL} - fail-url: ${KAKAO_CALLBACK_FAIL-URL} - gift-cancel-url: ${KAKAO_CALLBACK_GIFT-CANCEL-URL} + success-url: ${KAKAO_CALLBACK_SUCCESS_URL} + fail-url: ${KAKAO_CALLBACK_FAIL_URL} + gift-cancel-url: ${KAKAO_CALLBACK_GIFT_CANCEL_URL} -# 운영용 포인트 설정 joycrew: points: krw_per_point: ${JOYCREW_POINTS_KRW_PER_POINT:40} -# 운영 환경에서는 로그를 파일로 기록하여 관리합니다. logging: file: - name: /var/log/joycrew/app.log + name: /home/${SSH_USER}/app/logs/app.log max-size: 10MB max-history: 7 -# 운영 서버의 톰캣 접근 로그 설정 server: tomcat: accesslog: enabled: true - directory: /var/log/joycrew + directory: /home/${SSH_USER}/app/logs prefix: access_log suffix: .log - pattern: common \ No newline at end of file + pattern: common From 9e35060386f11abb623869584705dc0d689d2215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Thu, 2 Oct 2025 16:25:00 +0900 Subject: [PATCH 113/135] Update application-prod.yml --- src/main/resources/application-prod.yml | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 37b2fb1..396f607 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -51,18 +51,3 @@ kakao: joycrew: points: krw_per_point: ${JOYCREW_POINTS_KRW_PER_POINT:40} - -logging: - file: - name: /home/${SSH_USER}/app/logs/app.log - max-size: 10MB - max-history: 7 - -server: - tomcat: - accesslog: - enabled: true - directory: /home/${SSH_USER}/app/logs - prefix: access_log - suffix: .log - pattern: common From 4976a42a1524ae0244800515d87fc17aaf3e235c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Thu, 2 Oct 2025 16:35:51 +0900 Subject: [PATCH 114/135] Update application-prod.yml --- src/main/resources/application-prod.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 396f607..7870996 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -51,3 +51,9 @@ kakao: joycrew: points: krw_per_point: ${JOYCREW_POINTS_KRW_PER_POINT:40} + +logging: + file: + name: /home/${SSH_USER}/app/logs/app.log + max-size: 10MB + max-history: 7 From 532c357c158708c8512b480c3f16a914d625d217 Mon Sep 17 00:00:00 2001 From: Jiwon Kim Date: Fri, 3 Oct 2025 13:34:07 +0900 Subject: [PATCH 115/135] release : fast release --- .github/workflows/ci-cd.yml | 68 ++++++------------------- src/main/resources/application-prod.yml | 28 +++++----- 2 files changed, 28 insertions(+), 68 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 2405946..e66f542 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -46,86 +46,50 @@ jobs: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_KEY }} - envs: AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY # AWS 시크릿 전달 script: | - # --- 아래 스크립트는 EC2 서버 내부에서 실행됩니다 --- + # --- EC2 내부에서 실행되는 배포 스크립트 --- + set -e - cat > /tmp/deploy.sh << 'DEPLOY_SCRIPT_EOF' - #!/bin/bash - set -e # 한 명령어라도 실패하면 즉시 스크립트를 중단합니다. - - # GitHub Actions로부터 전달받은 변수 설정 - export AWS_ACCESS_KEY_ID="$1" - export AWS_SECRET_ACCESS_KEY="$2" - export SSH_USER="$3" - - APP_DIR="/home/${SSH_USER}/app" + APP_DIR="/home/${{ secrets.SSH_USER }}/app" LOG_DIR="${APP_DIR}/logs" - # 로그 디렉토리 생성 (없을 경우) + # 로그 디렉토리 생성 mkdir -p $LOG_DIR - echo "> Secrets Manager에서 환경변수를 가져옵니다." - SECRET_NAME="security" - AWS_REGION="ap-northeast-2" - SECRET_JSON=$(aws secretsmanager get-secret-value --secret-id $SECRET_NAME --region $AWS_REGION --query SecretString --output text) - - # [핵심 수정] source 방식 대신, Spring Boot가 직접 읽을 .env 파일을 생성합니다. - echo "> JAR 파일과 동일한 위치에 .env 파일을 생성합니다." - echo "$SECRET_JSON" | jq -r 'to_entries | map({key: .key | ascii_upcase | gsub("\\."; "_"), value: .value}) | map(.key + "=" + .value) | .[]' > "${APP_DIR}/.env" - echo "> .env 파일 생성 완료." - - # 기존 애플리케이션 종료 - echo "> 기존 애플리케이션을 확인하고 종료합니다." + echo "> 기존 애플리케이션 종료" if pgrep -f "java.*\.jar" > /dev/null; then CURRENT_PID=$(pgrep -f "java.*\.jar") - echo "> 실행 중인 애플리케이션을 종료합니다. (PID: $CURRENT_PID)" + echo "> 실행 중인 애플리케이션 종료 (PID: $CURRENT_PID)" kill -15 $CURRENT_PID - sleep 10 # 종료 대기 + sleep 10 else - echo "> 현재 실행 중인 애플리케이션이 없습니다." + echo "> 실행 중인 애플리케이션 없음" fi - # 새 애플리케이션 실행 + # 최신 JAR 선택 JAR_NAME=$(ls -tr $APP_DIR/*.jar | tail -n 1) if [ -z "$JAR_NAME" ]; then - echo "> ERROR: 배포할 JAR 파일을 찾을 수 없습니다." + echo "> ERROR: 배포할 JAR 없음" exit 1 fi - echo "> 새 애플리케이션을 배포합니다: $JAR_NAME" - - # [핵심 수정] Spring Boot가 .env 파일을 자동으로 읽도록 실행 옵션을 추가합니다. + echo "> 새 애플리케이션 실행: $JAR_NAME" nohup java -jar \ -Dspring.profiles.active=prod \ - -Dspring.config.import=optional:file:.env \ "$JAR_NAME" > "$LOG_DIR/application.log" 2>&1 & - # 애플리케이션 시작 확인 (더 긴 대기시간과 상세한 로그 출력) - echo "> 애플리케이션 시작을 15초간 기다립니다..." + echo "> 애플리케이션 시작 확인 (15초 대기)" sleep 15 - + if pgrep -f "$JAR_NAME" > /dev/null; then - echo "> 애플리케이션이 성공적으로 시작되었습니다." + echo "> ✅ 애플리케이션 시작 성공" else echo "----------------------------------------" - echo "> ERROR: 애플리케이션 시작에 실패했습니다." - echo "> 아래 로그를 확인하여 원인을 파악하세요." + echo "> ❌ 애플리케이션 시작 실패" echo "--- 최근 로그 50줄 ---" tail -n 50 "$LOG_DIR/application.log" echo "----------------------------------------" exit 1 fi - - echo "> 배포 완료!" - DEPLOY_SCRIPT_EOF - # 스크립트 실행 권한 부여 - chmod +x /tmp/deploy.sh - - # 백그라운드에서 배포 스크립트 실행 - echo "> 배포 스크립트를 백그라운드에서 실행합니다." - nohup /tmp/deploy.sh "$AWS_ACCESS_KEY_ID" "$AWS_SECRET_ACCESS_KEY" "${{ secrets.SSH_USER }}" > /tmp/deploy.log 2>&1 & - env: - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} \ No newline at end of file + echo "> 배포 완료" \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 7870996..9dba15d 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -2,19 +2,25 @@ # PRODUCTION PROFILE (운영 환경) # # 'prod' 프로파일이 활성화되었을 때 적용되는 설정입니다. -# 모든 민감 정보는 배포 시 AWS Secrets Manager를 통해 환경변수로 주입됩니다. +# 모든 민감 정보는 AWS Secrets Manager에서 직접 불러옵니다. # =================================================================== spring: + config: + import: aws-secretsmanager:joycrew-backend-secrets + datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: ${SPRING_DATASOURCE_URL} - username: ${SPRING_DATASOURCE_USERNAME} - password: ${SPRING_DATASOURCE_PASSWORD} mail: - username: ${SPRING_MAIL_USERNAME} - password: ${SPRING_MAIL_PASSWORD} + host: email-smtp.ap-northeast-2.amazonaws.com + port: 587 + properties: + mail: + smtp: + auth: true + starttls: + enable: true jpa: hibernate: @@ -24,29 +30,19 @@ spring: jwt: expiration-ms: 3600000 password-reset-expiration-ms: 900000 - secret: ${JWT_SECRET} app: frontend-url: https://www.joycrew.co.kr kyc: - token-secret: ${KYC_TOKEN_SECRET} token-ttl-minutes: 10 solapi: - api-key: ${SOLAPI_API_KEY} - api-secret: ${SOLAPI_API_SECRET} base-url: "https://api.solapi.com" -# [수정] 플레이스홀더의 '.'을 '_'로 변경하여 배포 스크립트와 일치시킴 kakao: giftbiz: - base-url: ${KAKAO_GIFTBIZ_BASE_URL} - api-key: ${KAKAO_GIFTBIZ_API_KEY} timeout-ms: 5000 callback: - success-url: ${KAKAO_CALLBACK_SUCCESS_URL} - fail-url: ${KAKAO_CALLBACK_FAIL_URL} - gift-cancel-url: ${KAKAO_CALLBACK_GIFT_CANCEL_URL} joycrew: points: From 21da183d1eddfc10329ea81882316fa0c7b07d70 Mon Sep 17 00:00:00 2001 From: Jiwon Kim Date: Fri, 3 Oct 2025 13:48:05 +0900 Subject: [PATCH 116/135] release v1 --- .github/workflows/ci-cd.yml | 51 ++----------------------- src/main/resources/application-prod.yml | 27 +++++++------ 2 files changed, 19 insertions(+), 59 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index e66f542..4efab13 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -9,26 +9,21 @@ jobs: build-and-deploy: runs-on: ubuntu-latest steps: - # 1. 소스 코드 체크아웃 - name: Checkout source code uses: actions/checkout@v3 - # 2. JDK 17 설정 - name: Set up JDK 17 uses: actions/setup-java@v3 with: java-version: '17' distribution: 'temurin' - # 3. gradlew 실행 권한 추가 - name: Grant execute permission for gradlew run: chmod +x ./gradlew - # 4. Gradle로 프로젝트 빌드 - name: Build with Gradle run: ./gradlew build -x test - # 5. 빌드된 JAR 파일을 EC2로 전송 - name: Transfer JAR to EC2 uses: appleboy/scp-action@v0.1.4 with: @@ -39,7 +34,6 @@ jobs: target: "/home/${{ secrets.SSH_USER }}/app" strip_components: 2 - # 6. EC2에 접속하여 배포 스크립트 실행 - name: Deploy on EC2 uses: appleboy/ssh-action@v0.1.10 with: @@ -47,49 +41,12 @@ jobs: username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_KEY }} script: | - # --- EC2 내부에서 실행되는 배포 스크립트 --- - set -e - APP_DIR="/home/${{ secrets.SSH_USER }}/app" LOG_DIR="${APP_DIR}/logs" + DEPLOY_SCRIPT="$APP_DIR/deploy.sh" - # 로그 디렉토리 생성 mkdir -p $LOG_DIR - echo "> 기존 애플리케이션 종료" - if pgrep -f "java.*\.jar" > /dev/null; then - CURRENT_PID=$(pgrep -f "java.*\.jar") - echo "> 실행 중인 애플리케이션 종료 (PID: $CURRENT_PID)" - kill -15 $CURRENT_PID - sleep 10 - else - echo "> 실행 중인 애플리케이션 없음" - fi - - # 최신 JAR 선택 - JAR_NAME=$(ls -tr $APP_DIR/*.jar | tail -n 1) - if [ -z "$JAR_NAME" ]; then - echo "> ERROR: 배포할 JAR 없음" - exit 1 - fi - - echo "> 새 애플리케이션 실행: $JAR_NAME" - nohup java -jar \ - -Dspring.profiles.active=prod \ - "$JAR_NAME" > "$LOG_DIR/application.log" 2>&1 & - - echo "> 애플리케이션 시작 확인 (15초 대기)" - sleep 15 - - if pgrep -f "$JAR_NAME" > /dev/null; then - echo "> ✅ 애플리케이션 시작 성공" - else - echo "----------------------------------------" - echo "> ❌ 애플리케이션 시작 실패" - echo "--- 최근 로그 50줄 ---" - tail -n 50 "$LOG_DIR/application.log" - echo "----------------------------------------" - exit 1 - fi - - echo "> 배포 완료" \ No newline at end of file + echo "> 배포 스크립트 실행" + chmod +x $DEPLOY_SCRIPT + $DEPLOY_SCRIPT \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 9dba15d..8ca5ff6 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -2,25 +2,19 @@ # PRODUCTION PROFILE (운영 환경) # # 'prod' 프로파일이 활성화되었을 때 적용되는 설정입니다. -# 모든 민감 정보는 AWS Secrets Manager에서 직접 불러옵니다. +# 모든 민감 정보는 .env 파일을 통해 주입됩니다. # =================================================================== spring: - config: - import: aws-secretsmanager:joycrew-backend-secrets - datasource: driver-class-name: com.mysql.cj.jdbc.Driver + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} mail: - host: email-smtp.ap-northeast-2.amazonaws.com - port: 587 - properties: - mail: - smtp: - auth: true - starttls: - enable: true + username: ${SPRING_MAIL_USERNAME} + password: ${SPRING_MAIL_PASSWORD} jpa: hibernate: @@ -30,19 +24,28 @@ spring: jwt: expiration-ms: 3600000 password-reset-expiration-ms: 900000 + secret: ${JWT_SECRET} app: frontend-url: https://www.joycrew.co.kr kyc: + token-secret: ${KYC_TOKEN_SECRET} token-ttl-minutes: 10 solapi: + api-key: ${SOLAPI_API_KEY} + api-secret: ${SOLAPI_API_SECRET} base-url: "https://api.solapi.com" kakao: giftbiz: + base-url: ${KAKAO_GIFTBIZ_BASE_URL} + api-key: ${KAKAO_GIFTBIZ_API_KEY} timeout-ms: 5000 callback: + success-url: ${KAKAO_CALLBACK_SUCCESS_URL} + fail-url: ${KAKAO_CALLBACK_FAIL_URL} + gift-cancel-url: ${KAKAO_CALLBACK_GIFT_CANCEL_URL} joycrew: points: From 8537bb21c8d13f0cb2358a3a713743ee034cd866 Mon Sep 17 00:00:00 2001 From: Jiwon Kim Date: Fri, 3 Oct 2025 13:58:58 +0900 Subject: [PATCH 117/135] release v2 --- src/main/resources/application-prod.yml | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 8ca5ff6..81f47d2 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -1,9 +1,13 @@ -# =================================================================== -# PRODUCTION PROFILE (운영 환경) -# -# 'prod' 프로파일이 활성화되었을 때 적용되는 설정입니다. -# 모든 민감 정보는 .env 파일을 통해 주입됩니다. -# =================================================================== +# src/main/resources/application-prod.yml + +server: + tomcat: + accesslog: + enabled: true + directory: /home/ec2-user/app/logs # 권한 문제 해결을 위해 로그 경로 변경 + prefix: access_log + suffix: .log + pattern: common spring: datasource: @@ -19,6 +23,9 @@ spring: jpa: hibernate: ddl-auto: update + properties: + hibernate: + dialect: org.hibernate.dialect.MySQL8Dialect # 환경 변수 로드 실패 시를 대비해 Dialect 명시 show-sql: false jwt: @@ -53,6 +60,6 @@ joycrew: logging: file: - name: /home/${SSH_USER}/app/logs/app.log + name: /home/ec2-user/app/logs/app.log max-size: 10MB - max-history: 7 + max-history: 7 \ No newline at end of file From 693770756407d02853e26ea43b546ef1f244a383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Fri, 3 Oct 2025 18:55:49 +0900 Subject: [PATCH 118/135] Update application.yml --- src/main/resources/application.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 33deac7..ebfb856 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -7,6 +7,7 @@ server: port: 8082 + forward-headers-strategy: framework spring: application: @@ -49,4 +50,4 @@ logging: aws: region: 'ap-northeast-2' s3: - bucket-name: 'joycrew-dev-bucket' \ No newline at end of file + bucket-name: 'joycrew-dev-bucket' From eb8ff140692efe4be229eb9ff4c2e410d564d9e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90?= <110761377+andrewkimswe@users.noreply.github.com> Date: Fri, 3 Oct 2025 19:06:23 +0900 Subject: [PATCH 119/135] Update SwaggerConfig.java --- .../java/com/joycrew/backend/config/SwaggerConfig.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/com/joycrew/backend/config/SwaggerConfig.java b/src/main/java/com/joycrew/backend/config/SwaggerConfig.java index 144d0a1..632c709 100644 --- a/src/main/java/com/joycrew/backend/config/SwaggerConfig.java +++ b/src/main/java/com/joycrew/backend/config/SwaggerConfig.java @@ -4,10 +4,13 @@ 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 io.swagger.v3.oas.models.Components; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.util.List; + @Configuration public class SwaggerConfig { @@ -16,6 +19,10 @@ public OpenAPI openAPI() { final String securitySchemeName = "Authorization"; return new OpenAPI() + .servers(List.of( + new Server().url("https://api.joycrew.co.kr").description("Production Server"), + new Server().url("http://localhost:8082").description("Local Development Server") + )) .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) .components(new Components().addSecuritySchemes(securitySchemeName, new SecurityScheme() From 313eb0358aa4997fd4e7a6db5a8843fab9901979 Mon Sep 17 00:00:00 2001 From: Jiwon Kim Date: Fri, 3 Oct 2025 20:11:35 +0900 Subject: [PATCH 120/135] fix : add api to kyc --- src/main/java/com/joycrew/backend/controller/KycController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/joycrew/backend/controller/KycController.java b/src/main/java/com/joycrew/backend/controller/KycController.java index 9cbdc75..e28abbb 100644 --- a/src/main/java/com/joycrew/backend/controller/KycController.java +++ b/src/main/java/com/joycrew/backend/controller/KycController.java @@ -18,7 +18,7 @@ import java.util.stream.Stream; @RestController -@RequestMapping("/kyc/phone") +@RequestMapping("/api/kyc/phone") @RequiredArgsConstructor public class KycController { private final PhoneVerificationService svc; From 09e8ed52c732344152cc4670e79808eaf0269a44 Mon Sep 17 00:00:00 2001 From: Jiwon Kim Date: Fri, 3 Oct 2025 20:59:31 +0900 Subject: [PATCH 121/135] fix : add api to securityconfig: --- src/main/java/com/joycrew/backend/config/SecurityConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/joycrew/backend/config/SecurityConfig.java b/src/main/java/com/joycrew/backend/config/SecurityConfig.java index 0f5152e..1fca438 100644 --- a/src/main/java/com/joycrew/backend/config/SecurityConfig.java +++ b/src/main/java/com/joycrew/backend/config/SecurityConfig.java @@ -55,7 +55,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", - "/kyc/phone/**", + "/api/kyc/phone/**", "/accounts/emails/by-phone" ).permitAll() .requestMatchers(HttpMethod.GET, "/api/catalog/**").permitAll() From fdf43f839060ea1030ba54731334cbfeb0eacfbc Mon Sep 17 00:00:00 2001 From: Jiwon Kim Date: Sat, 4 Oct 2025 12:28:27 +0900 Subject: [PATCH 122/135] =?UTF-8?q?fix=20:=20security=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/config/SecurityConfig.java | 1 - .../security/JwtAuthenticationFilter.java | 153 +++++++++--------- 2 files changed, 78 insertions(+), 76 deletions(-) diff --git a/src/main/java/com/joycrew/backend/config/SecurityConfig.java b/src/main/java/com/joycrew/backend/config/SecurityConfig.java index 1fca438..a5beab9 100644 --- a/src/main/java/com/joycrew/backend/config/SecurityConfig.java +++ b/src/main/java/com/joycrew/backend/config/SecurityConfig.java @@ -59,7 +59,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/accounts/emails/by-phone" ).permitAll() .requestMatchers(HttpMethod.GET, "/api/catalog/**").permitAll() - .requestMatchers("/api/admin/employees").permitAll() .requestMatchers("/api/admin/**").hasAuthority(AdminLevel.SUPER_ADMIN.name()) .anyRequest().authenticated() ) diff --git a/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java b/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java index dca415a..531d781 100644 --- a/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java @@ -24,79 +24,82 @@ @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { - private final JwtUtil jwtUtil; - private final UserDetailsService userDetailsService; - // AntPathMatcher를 사용하여 URL 패턴을 비교합니다. (e.g., /api/docs/**) - private final AntPathMatcher pathMatcher = new AntPathMatcher(); - - // 1. 여기에 JWT 토큰 검사를 건너뛸 경로 목록을 정의합니다. - private static final List EXCLUDE_URLS = Arrays.asList( - "/", - "/h2-console/**", - "/api/auth/login", - "/api/auth/password-reset/**", - "/v3/api-docs/**", - "/swagger-ui/**", - "/swagger-ui.html", - "/api/products/**", - "/api/crawl/**", - // 최초 관리자 등록을 위해 이 경로를 필터 예외 목록에 추가합니다. - "/api/admin/employees" - ); - - @Override - protected void doFilterInternal(HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain) - throws ServletException, IOException { - - // 2. 현재 요청 경로가 EXCLUDE_URLS 목록에 포함되는지 확인합니다. - String path = request.getServletPath(); - boolean isExcluded = EXCLUDE_URLS.stream() - .anyMatch(excludeUrl -> pathMatcher.match(excludeUrl, path)); - - // 3. 예외 목록에 포함된 경로라면, 필터 로직을 실행하지 않고 즉시 다음 필터로 넘깁니다. - if (isExcluded) { - log.info("JWT Filter bypassed for path: {}", path); - filterChain.doFilter(request, response); - return; + private final JwtUtil jwtUtil; + private final UserDetailsService userDetailsService; + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + + // JWT 토큰 검사를 건너뛸 경로 목록 (SecurityConfig와 일치하도록 수정) + private static final List EXCLUDE_URLS = Arrays.asList( + "/", + "/error", + "/actuator/health", + "/h2-console/**", + "/api/auth/**", // 로그인, 비밀번호 재설정 등 모든 인증 관련 경로 + "/api/kyc/phone/**", // ### KYC 관련 경로 추가 (문제의 직접적인 원인) ### + "/accounts/emails/by-phone", // ### 이메일 조회 경로 추가 (문제의 직접적인 원인) ### + "/api/catalog/**", // 상품 목록 조회 경로 추가 + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html" + // "/api/admin/employees" // 보안상 이 경로는 필터 예외에서 제거하는 것이 올바릅니다. + ); + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + + String path = request.getServletPath(); + + // CORS Preflight 요청(OPTIONS)은 항상 통과 + if (request.getMethod().equalsIgnoreCase("OPTIONS")) { + filterChain.doFilter(request, response); + return; + } + + boolean isExcluded = EXCLUDE_URLS.stream() + .anyMatch(excludeUrl -> pathMatcher.match(excludeUrl, path)); + + if (isExcluded) { + log.info("JWT Filter bypassed for path: {}", path); + filterChain.doFilter(request, response); + return; + } + + log.info("===== JWT Filter Executed for path: {} =====", path); + + String authHeader = request.getHeader("Authorization"); + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + log.warn("Authorization header is missing or invalid for protected path: {}", path); + filterChain.doFilter(request, response); + return; + } + + String token = authHeader.substring(7); + String email = null; + try { + email = jwtUtil.getEmailFromToken(token); + } catch (ExpiredJwtException e) { + log.warn("JWT token has expired: {}", e.getMessage()); + } catch (JwtException e) { + log.warn("Invalid JWT token: {}", e.getMessage()); + } + + if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetails userDetails = this.userDetailsService.loadUserByUsername(email); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + log.info("User '{}' authenticated successfully.", email); + } + + filterChain.doFilter(request, response); } - - // --- 아래는 기존 필터 로직과 동일합니다 --- - - log.info("===== JWT Filter Executed for path: {} =====", path); - - String authHeader = request.getHeader("Authorization"); - - if (authHeader == null || !authHeader.startsWith("Bearer ")) { - log.warn("Authorization header is missing or invalid for protected path: {}", path); - filterChain.doFilter(request, response); - return; - } - - String token = authHeader.substring(7); - String email = null; - try { - email = jwtUtil.getEmailFromToken(token); - } catch (ExpiredJwtException e) { - log.warn("JWT token has expired: {}", e.getMessage()); - } catch (JwtException e) { - log.warn("Invalid JWT token: {}", e.getMessage()); - } - - if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) { - UserDetails userDetails = this.userDetailsService.loadUserByUsername(email); - - UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( - userDetails, - null, - userDetails.getAuthorities() - ); - authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(authentication); - log.info("User '{}' authenticated successfully.", email); - } - - filterChain.doFilter(request, response); - } -} +} \ No newline at end of file From 4fc15d671f2c1ebe6cc19851238ebb1d315e4441 Mon Sep 17 00:00:00 2001 From: Jiwon Kim Date: Sat, 4 Oct 2025 17:01:11 +0900 Subject: [PATCH 123/135] fix : add api logout --- src/main/java/com/joycrew/backend/config/SecurityConfig.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/joycrew/backend/config/SecurityConfig.java b/src/main/java/com/joycrew/backend/config/SecurityConfig.java index a5beab9..9a350bb 100644 --- a/src/main/java/com/joycrew/backend/config/SecurityConfig.java +++ b/src/main/java/com/joycrew/backend/config/SecurityConfig.java @@ -50,6 +50,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/actuator/health", "/h2-console/**", "/api/auth/login", + "/api/auth/logout", "/api/auth/password-reset/request", "/api/auth/password-reset/confirm", "/v3/api-docs/**", From afc1fb8e1bc5cf866086daf737282e193986cf4b Mon Sep 17 00:00:00 2001 From: Jiwon Kim Date: Sat, 4 Oct 2025 17:15:50 +0900 Subject: [PATCH 124/135] =?UTF-8?q?fix=20:=20yml=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ebfb856..d58ed5b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -50,4 +50,4 @@ logging: aws: region: 'ap-northeast-2' s3: - bucket-name: 'joycrew-dev-bucket' + bucket-name: 'joycrew-prod-bucket' From 8e00522bf12f516f3e3310c02333825941b3a57b Mon Sep 17 00:00:00 2001 From: Jiwon Kim Date: Sat, 4 Oct 2025 17:34:02 +0900 Subject: [PATCH 125/135] =?UTF-8?q?fix=20:=20yml=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 87 ------------------------- src/main/resources/application-prod.yml | 3 + 2 files changed, 3 insertions(+), 87 deletions(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index b99d939..e69de29 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -1,87 +0,0 @@ -# =================================================================== -# DEVELOPMENT PROFILE (개발 환경) -# -# 'dev' 프로파일이 활성화되었을 때 적용되는 설정입니다. -# 민감 정보는 더미(dummy) 값으로 채워져 있어, 별도 설정 없이도 -# 애플리케이션을 즉시 실행하고 기본적인 기능을 테스트할 수 있습니다. -# =================================================================== - -spring: - # 개발 중에는 H2 인메모리 데이터베이스를 사용합니다. - datasource: - url: jdbc:h2:mem:testdb;MODE=MySQL - driver-class-name: org.h2.Driver - username: sa - password: "" - - # H2 데이터베이스 콘솔 활성화 (http://localhost:8082/h2-console) - h2: - console: - enabled: true - path: /h2-console - - # JPA 및 Hibernate 설정 - jpa: - # 실행 시마다 스키마를 새로 생성하여 깨끗한 상태에서 시작합니다. - hibernate: - ddl-auto: create-drop - properties: - hibernate: - format_sql: true - defer-datasource-initialization: true - - # 개발용 메일 서버 설정 (실제 발송 테스트 시 환경변수로 설정 필요) - mail: - username: joycrew.team@gmail.com - password: ${DEV_MAIL_PASSWORD:dummy-mail-password} - -# 개발용 JWT 시크릿 키 -jwt: - expiration-ms: 3600000 - password-reset-expiration-ms: 900000 - secret: ${DEV_JWT_SECRET:dev-super-secret-key-that-is-long-enough-for-hs256} - -# 개발용 프론트엔드 및 KYC 설정 -app: - frontend-url: http://localhost:3000 - sms: - provider: solapi - from-number: "01044907174" # 개발용 발신번호 - kyc: - token-secret: ${DEV_KYC_TOKEN_SECRET:dev-kyc-super-secret-key-for-testing-purposes} - token-ttl-minutes: 10 - -# 개발용 Solapi (SMS) 설정 (실제 테스트 시 환경변수로 설정 필요) -solapi: - api-key: ${DEV_SOLAPI_API_KEY:dummy-solapi-key} - api-secret: ${DEV_SOLAPI_API_SECRET:dummy-solapi-secret} - base-url: "https://api.solapi.com" - -# 개발용 OTP 정책 -otp: - ttl-minutes: 5 - max-attempts: 5 - resend-cooldown-seconds: 30 - -# 개발용 카카오 API 설정 (실제 테스트 시 환경변수로 설정 필요) -kakao: - giftbiz: - base-url: https://gateway-giftbiz.kakao.com/openapi/giftbiz - api-key: ${DEV_KAKAO_API_KEY:dummy-kakao-api-key} - timeout-ms: 5000 - callback: - success-url: http://localhost:8082/kakao/callback/success - fail-url: http://localhost:8082/kakao/callback/fail - gift-cancel-url: http://localhost:8082/kakao/callback/gift-cancel - -# 개발용 포인트 설정 -joycrew: - points: - krw_per_point: 40 - -# 개발 시 상세한 로그를 보기 위한 레벨 설정 -logging: - level: - com.joycrew.backend: DEBUG - org.hibernate.SQL: DEBUG - org.hibernate.orm.jdbc.bind: TRACE \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 81f47d2..3494285 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -35,6 +35,9 @@ jwt: app: frontend-url: https://www.joycrew.co.kr + sms: # <-- [수정 1] sms 설정 블록 추가 + provider: solapi # <-- [수정 2] solapi를 사용하도록 명시 + from-number: ${APP_SMS_FROM_NUMBER} # <-- [수정 3] Solapi에 등록된 발신번호 (환경 변수 사용) kyc: token-secret: ${KYC_TOKEN_SECRET} token-ttl-minutes: 10 From 850ac842721dcdd0b807ca2a50577d558ec533be Mon Sep 17 00:00:00 2001 From: yeoEun Date: Sun, 5 Oct 2025 21:51:19 +0900 Subject: [PATCH 126/135] feat/add-brand --- .../joycrew/backend/dto/kakao/CreateGiftOrderRequest.java | 1 - .../backend/dto/kakao/ExternalProductDetailResponse.java | 1 + .../backend/dto/kakao/ExternalProductOptionPointDto.java | 8 -------- .../backend/dto/kakao/ExternalProductResponse.java | 1 + src/main/java/com/joycrew/backend/entity/Order.java | 3 --- .../com/joycrew/backend/entity/kakao/KakaoTemplate.java | 3 +++ .../joycrew/backend/service/ExternalCatalogService.java | 3 ++- .../com/joycrew/backend/service/GiftPurchaseService.java | 1 - 8 files changed, 7 insertions(+), 14 deletions(-) delete mode 100644 src/main/java/com/joycrew/backend/dto/kakao/ExternalProductOptionPointDto.java diff --git a/src/main/java/com/joycrew/backend/dto/kakao/CreateGiftOrderRequest.java b/src/main/java/com/joycrew/backend/dto/kakao/CreateGiftOrderRequest.java index 9b6b31f..6439185 100644 --- a/src/main/java/com/joycrew/backend/dto/kakao/CreateGiftOrderRequest.java +++ b/src/main/java/com/joycrew/backend/dto/kakao/CreateGiftOrderRequest.java @@ -2,7 +2,6 @@ public record CreateGiftOrderRequest( String externalProductId, // = templateId - String itemId, Integer quantity, String receiverPhone ) {} diff --git a/src/main/java/com/joycrew/backend/dto/kakao/ExternalProductDetailResponse.java b/src/main/java/com/joycrew/backend/dto/kakao/ExternalProductDetailResponse.java index eb6a860..9f84a43 100644 --- a/src/main/java/com/joycrew/backend/dto/kakao/ExternalProductDetailResponse.java +++ b/src/main/java/com/joycrew/backend/dto/kakao/ExternalProductDetailResponse.java @@ -3,6 +3,7 @@ public record ExternalProductDetailResponse( String templateId, String name, + String brand, int pointPrice, // 환산 포인트 (ceil(basePriceKrw / krwPerPoint)) int priceKrw, String thumbnailUrl diff --git a/src/main/java/com/joycrew/backend/dto/kakao/ExternalProductOptionPointDto.java b/src/main/java/com/joycrew/backend/dto/kakao/ExternalProductOptionPointDto.java deleted file mode 100644 index 7acc248..0000000 --- a/src/main/java/com/joycrew/backend/dto/kakao/ExternalProductOptionPointDto.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.joycrew.backend.dto.kakao; - -public record ExternalProductOptionPointDto( - String itemId, - String name, - Integer pricePoint, - Integer priceKrw -) {} diff --git a/src/main/java/com/joycrew/backend/dto/kakao/ExternalProductResponse.java b/src/main/java/com/joycrew/backend/dto/kakao/ExternalProductResponse.java index bd531b7..6d00bdf 100644 --- a/src/main/java/com/joycrew/backend/dto/kakao/ExternalProductResponse.java +++ b/src/main/java/com/joycrew/backend/dto/kakao/ExternalProductResponse.java @@ -3,6 +3,7 @@ public record ExternalProductResponse( String templateId, String name, + String brand, int pointPrice, int priceKrw, String thumbnailUrl diff --git a/src/main/java/com/joycrew/backend/entity/Order.java b/src/main/java/com/joycrew/backend/entity/Order.java index 9c76db7..6b18d20 100644 --- a/src/main/java/com/joycrew/backend/entity/Order.java +++ b/src/main/java/com/joycrew/backend/entity/Order.java @@ -30,9 +30,6 @@ public class Order { /** 상품 이름 */ private String productName; - /** 옵션 상품 ID (옵션 없는 경우 null) */ - private Long productItemId; - /** 단가(포인트 단위) */ private Integer productUnitPrice; diff --git a/src/main/java/com/joycrew/backend/entity/kakao/KakaoTemplate.java b/src/main/java/com/joycrew/backend/entity/kakao/KakaoTemplate.java index d46082e..2955f85 100644 --- a/src/main/java/com/joycrew/backend/entity/kakao/KakaoTemplate.java +++ b/src/main/java/com/joycrew/backend/entity/kakao/KakaoTemplate.java @@ -21,6 +21,9 @@ public class KakaoTemplate { @Column(nullable = false, length = 255) private String name; + @Column(length = 128) + private String brand; + @Column(nullable = false) private Integer basePriceKrw; diff --git a/src/main/java/com/joycrew/backend/service/ExternalCatalogService.java b/src/main/java/com/joycrew/backend/service/ExternalCatalogService.java index e90c998..7cc66c2 100644 --- a/src/main/java/com/joycrew/backend/service/ExternalCatalogService.java +++ b/src/main/java/com/joycrew/backend/service/ExternalCatalogService.java @@ -33,7 +33,7 @@ public List listByCategory(GiftCategory category, int p return p.getContent().stream().map(t -> { int point = (int) Math.ceil(t.getBasePriceKrw() / (double) krwPerPoint); return new ExternalProductResponse( - t.getTemplateId(), t.getName(), point, t.getBasePriceKrw(), t.getThumbnailUrl() + t.getTemplateId(), t.getName(), t.getBrand(), point, t.getBasePriceKrw(), t.getThumbnailUrl() ); }).toList(); } @@ -47,6 +47,7 @@ public ExternalProductDetailResponse getDetailWithPoints(String templateId) { return new ExternalProductDetailResponse( t.getTemplateId(), t.getName(), + t.getBrand(), basePoint, t.getBasePriceKrw(), t.getThumbnailUrl() diff --git a/src/main/java/com/joycrew/backend/service/GiftPurchaseService.java b/src/main/java/com/joycrew/backend/service/GiftPurchaseService.java index 33dbebf..36657f1 100644 --- a/src/main/java/com/joycrew/backend/service/GiftPurchaseService.java +++ b/src/main/java/com/joycrew/backend/service/GiftPurchaseService.java @@ -77,7 +77,6 @@ public OrderResponse purchaseWithPoints(Long employeeId, CreateOrderRequest req) .employee(employee) .productId(stableHashToLong(template.getTemplateId())) .productName(template.getName()) - .productItemId(null) // 옵션 없음 .productUnitPrice((int) Math.ceil(unitKrw / (double) krwPerPoint)) .quantity(qty) .totalPrice(totalPoint) From a858d2c359bacf92713153853e7bcfdc486f2581 Mon Sep 17 00:00:00 2001 From: Jiwon Kim Date: Sat, 25 Oct 2025 12:33:00 +0900 Subject: [PATCH 127/135] =?UTF-8?q?fix=20:=20FE=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/controller/CatalogController.java | 9 ++- .../backend/controller/KycController.java | 43 +++++------ .../backend/dto/kyc/PhoneVerifyResponse.java | 6 +- .../backend/dto/kyc/VerifiedEmailInfo.java | 11 +++ .../exception/GlobalExceptionHandler.java | 72 ++++++++++-------- .../repository/KakaoTemplateRepository.java | 5 ++ .../service/ExternalCatalogService.java | 20 ++++- src/main/resources/application-dev.yml | 74 +++++++++++++++++++ 8 files changed, 183 insertions(+), 57 deletions(-) create mode 100644 src/main/java/com/joycrew/backend/dto/kyc/VerifiedEmailInfo.java diff --git a/src/main/java/com/joycrew/backend/controller/CatalogController.java b/src/main/java/com/joycrew/backend/controller/CatalogController.java index 4d37ba2..4e889a3 100644 --- a/src/main/java/com/joycrew/backend/controller/CatalogController.java +++ b/src/main/java/com/joycrew/backend/controller/CatalogController.java @@ -23,10 +23,13 @@ public ResponseEntity> listKakaoByCategory( @PathVariable String category, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, - @RequestParam(defaultValue = "POPULAR") SortOption sort + @RequestParam(defaultValue = "POPULAR") SortOption sort, + @RequestParam(required = false) String searchName // 상품명 검색 파라미터 추가 ) { GiftCategory gc = GiftCategory.valueOf(category.toUpperCase()); - return ResponseEntity.ok(catalog.listByCategory(gc, page, size, sort)); + + // 서비스 레이어에도 searchName 파라미터 전달 (ExternalCatalogService.listByCategory 수정 필요) + return ResponseEntity.ok(catalog.listByCategory(gc, page, size, sort, searchName)); } @GetMapping(value = "/kakao/product/{templateId}", produces = "application/json; charset=UTF-8") @@ -34,4 +37,4 @@ public ResponseEntity detailPoints(@PathVariable var resp = catalog.getDetailWithPoints(templateId); return resp == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(resp); } -} +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/controller/KycController.java b/src/main/java/com/joycrew/backend/controller/KycController.java index e28abbb..a7442e0 100644 --- a/src/main/java/com/joycrew/backend/controller/KycController.java +++ b/src/main/java/com/joycrew/backend/controller/KycController.java @@ -1,9 +1,6 @@ package com.joycrew.backend.controller; -import com.joycrew.backend.dto.kyc.PhoneStartRequest; -import com.joycrew.backend.dto.kyc.PhoneStartResponse; -import com.joycrew.backend.dto.kyc.PhoneVerifyRequest; -import com.joycrew.backend.dto.kyc.PhoneVerifyResponse; +import com.joycrew.backend.dto.kyc.*; import com.joycrew.backend.service.PhoneVerificationService; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.util.EmailMasker; @@ -11,7 +8,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; -import java.time.LocalDateTime; import java.util.Comparator; import java.util.List; import java.util.Objects; @@ -35,25 +31,30 @@ public PhoneVerifyResponse verify(@RequestBody @Valid PhoneVerifyRequest req) { // 1) 코드 검증 + KYC 토큰 생성 + phone 획득 var r = svc.verify(req.requestId(), req.code()); - // 2) 해당 phone으로 직원들 조회 → 이메일/최근로그인 추출 + // 2) 해당 phone으로 직원들 조회 var employees = employeeRepo.findByPhoneNumber(r.phone()); - List emails = employees.stream() - .flatMap(e -> Stream.of(e.getEmail(), e.getPersonalEmail())) - .filter(Objects::nonNull) - .map(EmailMasker::mask) - .distinct() + // 3) [수정된 로직] + // Employee 엔티티를 VerifiedEmailInfo DTO 리스트로 변환 + // (회사 이메일과 개인 이메일을 별도 항목으로 취급) + List accounts = employees.stream() + .flatMap(e -> Stream.of( + // 회사 이메일 정보 + new VerifiedEmailInfo(EmailMasker.mask(e.getEmail()), e.getLastLoginAt()), + // 개인 이메일 정보 (null이 아닐 경우에만 생성) + (e.getPersonalEmail() != null) + ? new VerifiedEmailInfo(EmailMasker.mask(e.getPersonalEmail()), e.getLastLoginAt()) + : null + )) + .filter(Objects::nonNull) // personalEmail이 null이었던 스트림 제거 + .filter(info -> info.email() != null) // 마스킹된 이메일이 null이 아닌 경우 + .distinct() // (이메일, 날짜)가 완전히 동일한 경우 중복 제거 + // 최근 로그인 날짜 기준으로 내림차순 정렬 (프론트 편의성) + .sorted(Comparator.comparing(VerifiedEmailInfo::recentLoginAt, Comparator.nullsLast(Comparator.reverseOrder()))) .toList(); - LocalDateTime recent = employees.stream() - .map(e -> e.getLastLoginAt()) // LocalDateTime - .filter(Objects::nonNull) - .max(Comparator.naturalOrder()) - .orElse(null); - - String recentStr = (recent != null) ? recent.toString() : null; // ISO-8601 문자열 - - // 3) 응답 - return new PhoneVerifyResponse(r.verified(), r.kycToken(), emails, recentStr); + // 4) [수정된 생성자 호출] (3-인수) + return new PhoneVerifyResponse(r.verified(), r.kycToken(), accounts); } } + diff --git a/src/main/java/com/joycrew/backend/dto/kyc/PhoneVerifyResponse.java b/src/main/java/com/joycrew/backend/dto/kyc/PhoneVerifyResponse.java index bdd54ea..66510af 100644 --- a/src/main/java/com/joycrew/backend/dto/kyc/PhoneVerifyResponse.java +++ b/src/main/java/com/joycrew/backend/dto/kyc/PhoneVerifyResponse.java @@ -2,9 +2,11 @@ import java.util.List; +/** + * 핸드폰 인증 완료 시 최종 응답 DTO + */ public record PhoneVerifyResponse( boolean verified, String kycToken, - List emails, - String recentLoginAt + List accounts // 기존 List emails, String recentLoginAt -> List accounts ) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/kyc/VerifiedEmailInfo.java b/src/main/java/com/joycrew/backend/dto/kyc/VerifiedEmailInfo.java new file mode 100644 index 0000000..e05f3c1 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/kyc/VerifiedEmailInfo.java @@ -0,0 +1,11 @@ +package com.joycrew.backend.dto.kyc; + +import java.time.LocalDateTime; + +/** + * KYC(본인인증) 완료 시 반환하는 계정(이메일)과 최근 로그인 날짜 DTO + */ +public record VerifiedEmailInfo( + String email, + LocalDateTime recentLoginAt // JSON 직렬화 시 ISO-8601 날짜 문자열로 자동 변환 +) {} diff --git a/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java b/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java index 962665c..d8c7465 100644 --- a/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java @@ -5,6 +5,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; // import 추가 import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime; @@ -13,32 +14,45 @@ @RestControllerAdvice public class GlobalExceptionHandler { - @ExceptionHandler(InsufficientPointsException.class) - @ResponseStatus(HttpStatus.BAD_REQUEST) - public ErrorResponse handleInsufficientPoints(InsufficientPointsException ex, HttpServletRequest req) { - return new ErrorResponse("INSUFFICIENT_POINTS", ex.getMessage(), LocalDateTime.now(), req.getRequestURI()); - } - - @ExceptionHandler(NoSuchElementException.class) - @ResponseStatus(HttpStatus.NOT_FOUND) - public ErrorResponse handleNoSuchElement(NoSuchElementException ex, HttpServletRequest req) { - return new ErrorResponse("NOT_FOUND", ex.getMessage(), LocalDateTime.now(), req.getRequestURI()); - } - - @ExceptionHandler(IllegalStateException.class) - @ResponseStatus(HttpStatus.BAD_REQUEST) - public ErrorResponse handleIllegalState(IllegalStateException ex, HttpServletRequest req) { - return new ErrorResponse("ORDER_CANNOT_CANCEL", ex.getMessage(), LocalDateTime.now(), req.getRequestURI()); - } - - @ExceptionHandler(BadCredentialsException.class) - public ResponseEntity handleAuthenticationFailed(BadCredentialsException ex, HttpServletRequest req) { // 1. HttpServletRequest 추가 - ErrorResponse errorResponse = new ErrorResponse( - "AUTHENTICATION_FAILED", - ex.getMessage(), - LocalDateTime.now(), - req.getRequestURI() - ); - return new ResponseEntity<>(errorResponse, HttpStatus.UNAUTHORIZED); - } -} + @ExceptionHandler(InsufficientPointsException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleInsufficientPoints(InsufficientPointsException ex, HttpServletRequest req) { + return new ErrorResponse("INSUFFICIENT_POINTS", ex.getMessage(), LocalDateTime.now(), req.getRequestURI()); + } + + @ExceptionHandler(NoSuchElementException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public ErrorResponse handleNoSuchElement(NoSuchElementException ex, HttpServletRequest req) { + return new ErrorResponse("NOT_FOUND", ex.getMessage(), LocalDateTime.now(), req.getRequestURI()); + } + + @ExceptionHandler(IllegalStateException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleIllegalState(IllegalStateException ex, HttpServletRequest req) { + return new ErrorResponse("ORDER_CANNOT_CANCEL", ex.getMessage(), LocalDateTime.now(), req.getRequestURI()); + } + + // '가입되지 않은 이메일' 처리 + @ExceptionHandler(UsernameNotFoundException.class) + public ResponseEntity handleUsernameNotFound(UsernameNotFoundException ex, HttpServletRequest req) { + ErrorResponse errorResponse = new ErrorResponse( + "AUTH_002", // 가입되지 않은 이메일 + "이메일 또는 비밀번호가 잘못되었습니다.", // 보안을 위해 메시지는 동일하게 유지 + LocalDateTime.now(), + req.getRequestURI() + ); + return new ResponseEntity<>(errorResponse, HttpStatus.UNAUTHORIZED); + } + + // '비밀번호 불일치' 처리 + @ExceptionHandler(BadCredentialsException.class) + public ResponseEntity handleAuthenticationFailed(BadCredentialsException ex, HttpServletRequest req) { + ErrorResponse errorResponse = new ErrorResponse( + "AUTH_003", // 비밀번호 불일치 + "이메일 또는 비밀번호가 잘못되었습니다.", // 보안을 위해 메시지는 동일하게 유지 + LocalDateTime.now(), + req.getRequestURI() + ); + return new ResponseEntity<>(errorResponse, HttpStatus.UNAUTHORIZED); + } +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/repository/KakaoTemplateRepository.java b/src/main/java/com/joycrew/backend/repository/KakaoTemplateRepository.java index 9540cac..f0bdd69 100644 --- a/src/main/java/com/joycrew/backend/repository/KakaoTemplateRepository.java +++ b/src/main/java/com/joycrew/backend/repository/KakaoTemplateRepository.java @@ -6,5 +6,10 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface KakaoTemplateRepository extends JpaRepository { + + // Existing method Page findByJoyCategory(GiftCategory category, Pageable pageable); + + // [NEW] Added method for searching by name (resolves error in ExternalCatalogService) + Page findByJoyCategoryAndNameContainingIgnoreCase(GiftCategory category, String name, Pageable pageable); } diff --git a/src/main/java/com/joycrew/backend/service/ExternalCatalogService.java b/src/main/java/com/joycrew/backend/service/ExternalCatalogService.java index e90c998..81baccd 100644 --- a/src/main/java/com/joycrew/backend/service/ExternalCatalogService.java +++ b/src/main/java/com/joycrew/backend/service/ExternalCatalogService.java @@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.*; import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; // 1. StringUtils import 추가 import java.util.List; @@ -22,14 +23,28 @@ public class ExternalCatalogService { @Value("${joycrew.points.krw_per_point:40}") private int krwPerPoint; - public List listByCategory(GiftCategory category, int page, int size, SortOption sort) { + // 2. 메소드 시그니처에 searchName 파라미터 추가 + public List listByCategory(GiftCategory category, int page, int size, SortOption sort, String searchName) { Sort s = switch (sort) { case PRICE_ASC -> Sort.by(Sort.Direction.ASC, "basePriceKrw"); case PRICE_DESC -> Sort.by(Sort.Direction.DESC, "basePriceKrw"); case POPULAR, NEW -> Sort.by(Sort.Direction.DESC, "updatedAt"); }; - Page p = templateRepo.findByJoyCategory(category, PageRequest.of(page, size, s)); + Pageable pageRequest = PageRequest.of(page, size, s); + Page p; + + // 3. searchName (검색어) 유무에 따라 분기 처리 + if (StringUtils.hasText(searchName)) { + // 검색어가 있는 경우: 이름으로 검색 + // (참고: KakaoTemplateRepository에 이 메소드가 정의되어 있어야 합니다) + p = templateRepo.findByJoyCategoryAndNameContainingIgnoreCase(category, searchName, pageRequest); + } else { + // 검색어가 없는 경우: 기존 로직 (카테고리로만 조회) + p = templateRepo.findByJoyCategory(category, pageRequest); + } + + // 4. 조회 결과를 DTO로 변환하여 반환 return p.getContent().stream().map(t -> { int point = (int) Math.ceil(t.getBasePriceKrw() / (double) krwPerPoint); return new ExternalProductResponse( @@ -53,3 +68,4 @@ public ExternalProductDetailResponse getDetailWithPoints(String templateId) { ); } } + diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index e69de29..6a3feec 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,74 @@ +# H2 Database settings for 'dev' profile +spring: + # --- H2 Datasource Configuration --- + datasource: + # Use H2 in-memory database named 'testdb' + # DB_CLOSE_DELAY=-1 keeps the database alive as long as the JVM is running + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 + # H2 JDBC Driver + driver-class-name: org.h2.Driver + # Default H2 username + username: sa + # Default H2 password (can be empty or set) + password: password + + # --- H2 Console Configuration (Optional but recommended) --- + h2: + console: + # Enable H2 web console + enabled: true + # Access path for H2 console (e.g., http://localhost:8082/h2-console) + path: /h2-console + settings: + # Allows access from web browsers + web-allow-others: false + # Disables trace output in console + trace: false + + # --- JPA/Hibernate Configuration --- + jpa: + # Let Hibernate automatically detect H2 dialect (no need to specify) + # database-platform: org.hibernate.dialect.H2Dialect # Optional + hibernate: + # Automatically update schema based on entities (create/update tables) + ddl-auto: update + properties: + hibernate: + # format_sql: true # Optional: Pretty print SQL logs + # use_sql_comments: true # Optional: Add comments to SQL logs + # Show executed SQL statements in the logs (optional) + show-sql: true + +# --- Other configurations (like jwt, mail, etc. remain the same) --- +jwt: + expiration-ms: 3600000 + password-reset-expiration-ms: 900000 + secret: ${JWT_SECRET:defaultSecretValueForDev} # Provide a default for dev if env var is not set + +app: + frontend-url: http://localhost:3000 # Use localhost for dev frontend + sms: + provider: solapi + from-number: 123 + kyc: + token-secret: 123 + token-ttl-minutes: 10 + +solapi: + api-key: 123 + api-secret: 123 + base-url: "https://api.solapi.com" + +kakao: + giftbiz: + base-url: 123 + api-key: 123 + timeout-ms: 5000 + callback: + success-url: 123 + fail-url: 123 + gift-cancel-url: 123 + +joycrew: + points: + krw_per_point: ${JOYCREW_POINTS_KRW_PER_POINT:40} \ No newline at end of file From 2f39dc9ab687fa60a694e6d7b9452d8c708cb906 Mon Sep 17 00:00:00 2001 From: Jiwon Kim Date: Sun, 2 Nov 2025 18:36:09 +0900 Subject: [PATCH 128/135] =?UTF-8?q?feat:=20=EC=B9=B4=ED=83=88=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20API=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=ED=8E=98=EC=9D=B4=EC=A7=95=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/controller/CatalogController.java | 65 ++++++++++--- .../dto/kakao/PagedCatalogResponse.java | 15 +++ .../repository/KakaoTemplateRepository.java | 19 +++- .../service/ExternalCatalogService.java | 92 ++++++++++++++++--- 4 files changed, 162 insertions(+), 29 deletions(-) create mode 100644 src/main/java/com/joycrew/backend/dto/kakao/PagedCatalogResponse.java diff --git a/src/main/java/com/joycrew/backend/controller/CatalogController.java b/src/main/java/com/joycrew/backend/controller/CatalogController.java index 4e889a3..338bc05 100644 --- a/src/main/java/com/joycrew/backend/controller/CatalogController.java +++ b/src/main/java/com/joycrew/backend/controller/CatalogController.java @@ -2,6 +2,7 @@ import com.joycrew.backend.dto.kakao.ExternalProductDetailResponse; import com.joycrew.backend.dto.kakao.ExternalProductResponse; +import com.joycrew.backend.dto.kakao.PagedCatalogResponse; import com.joycrew.backend.entity.enums.GiftCategory; import com.joycrew.backend.entity.enums.SortOption; import com.joycrew.backend.service.ExternalCatalogService; @@ -16,25 +17,65 @@ @RequestMapping("/api/catalog") public class CatalogController { - private final ExternalCatalogService catalog; + // (참고: 사장님 코드에서 변수명이 catalog 였는데, catalogService로 바꿨습니다.) + private final ExternalCatalogService catalogService; + /** + * 추천 상품 (랜덤) - 이건 List가 맞음 (수정 X) + */ + @GetMapping(value = "/featured", produces = "application/json; charset=UTF-8") + public ResponseEntity> getFeaturedProducts() { + return ResponseEntity.ok(catalogService.getFeaturedProducts()); + } + + /** + * [수정] 카테고리 없이 이름으로만 전체 상품 검색 + * (반환 타입을 List -> PagedCatalogResponse 로 변경) + */ + @GetMapping(value = "/search", produces = "application/json; charset=UTF-8") + public ResponseEntity searchProducts( // [수정] 반환 타입 + @RequestParam String searchName, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "POPULAR") SortOption sort + ) { + PagedCatalogResponse response = catalogService.searchProductsByName(searchName, page, size, sort); // [수정] + return ResponseEntity.ok(response); + } + + /** + * [수정] 카테고리별 상품 조회 (검색 포함) + * (반환 타입을 List -> PagedCatalogResponse 로 변경) + */ @GetMapping(value = "/kakao/{category}", produces = "application/json; charset=UTF-8") - public ResponseEntity> listKakaoByCategory( - @PathVariable String category, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, - @RequestParam(defaultValue = "POPULAR") SortOption sort, - @RequestParam(required = false) String searchName // 상품명 검색 파라미터 추가 + public ResponseEntity listKakaoByCategory( // [수정] 반환 타입 + @PathVariable String category, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "POPULAR") SortOption sort, + @RequestParam(required = false) String searchName ) { - GiftCategory gc = GiftCategory.valueOf(category.toUpperCase()); + GiftCategory gc; + try { + // (참고: .trim()을 추가하여 " CAFE " 같은 공백 포함 입력도 처리) + gc = GiftCategory.valueOf(category.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().build(); // 잘못된 카테고리명 + } - // 서비스 레이어에도 searchName 파라미터 전달 (ExternalCatalogService.listByCategory 수정 필요) - return ResponseEntity.ok(catalog.listByCategory(gc, page, size, sort, searchName)); + PagedCatalogResponse response = catalogService.listByCategory(gc, page, size, sort, searchName); // [수정] + return ResponseEntity.ok(response); } + /** + * 상품 상세 정보 조회 (수정 X) + * (참고: 사장님 코드에서 메서드명이 detailPoints 였는데 getProductDetail로 바꿨습니다.) + */ @GetMapping(value = "/kakao/product/{templateId}", produces = "application/json; charset=UTF-8") - public ResponseEntity detailPoints(@PathVariable String templateId) { - var resp = catalog.getDetailWithPoints(templateId); + public ResponseEntity getProductDetail( + @PathVariable String templateId + ) { + var resp = catalogService.getDetailWithPoints(templateId); return resp == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(resp); } } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/kakao/PagedCatalogResponse.java b/src/main/java/com/joycrew/backend/dto/kakao/PagedCatalogResponse.java new file mode 100644 index 0000000..b35a2cb --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/kakao/PagedCatalogResponse.java @@ -0,0 +1,15 @@ +package com.joycrew.backend.dto.kakao; + +import java.util.List; + +/** + * 카탈로그 API용 페이지 응답 DTO + * (페이지 정보 + 상품 목록을 포함) + */ +public record PagedCatalogResponse( + List products, // 상품 목록 (기존 content) + int currentPage, // 현재 페이지 (0부터 시작) + int totalPages, // 전체 페이지 수 + long totalElements, // 전체 상품 개수 + boolean last // 마지막 페이지 여부 (hasNext 대신) +) {} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/repository/KakaoTemplateRepository.java b/src/main/java/com/joycrew/backend/repository/KakaoTemplateRepository.java index f0bdd69..16798bb 100644 --- a/src/main/java/com/joycrew/backend/repository/KakaoTemplateRepository.java +++ b/src/main/java/com/joycrew/backend/repository/KakaoTemplateRepository.java @@ -4,12 +4,27 @@ import com.joycrew.backend.entity.kakao.KakaoTemplate; import org.springframework.data.domain.*; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.util.List; public interface KakaoTemplateRepository extends JpaRepository { // Existing method Page findByJoyCategory(GiftCategory category, Pageable pageable); - // [NEW] Added method for searching by name (resolves error in ExternalCatalogService) + // Existing method Page findByJoyCategoryAndNameContainingIgnoreCase(GiftCategory category, String name, Pageable pageable); -} + + /** + * [NEW] 카테고리 없이 이름으로만 전체 검색 + */ + Page findByNameContainingIgnoreCase(String name, Pageable pageable); + + /** + * [NEW] 추천 상품용: 랜덤 N개 조회 + */ + @Query(value = "SELECT * FROM kakao_template ORDER BY RAND() LIMIT :limit", + nativeQuery = true) + List findRandomProducts(@Param("limit") int limit); +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/ExternalCatalogService.java b/src/main/java/com/joycrew/backend/service/ExternalCatalogService.java index 81baccd..1f5d292 100644 --- a/src/main/java/com/joycrew/backend/service/ExternalCatalogService.java +++ b/src/main/java/com/joycrew/backend/service/ExternalCatalogService.java @@ -2,6 +2,7 @@ import com.joycrew.backend.dto.kakao.ExternalProductDetailResponse; import com.joycrew.backend.dto.kakao.ExternalProductResponse; +import com.joycrew.backend.dto.kakao.PagedCatalogResponse; import com.joycrew.backend.entity.enums.GiftCategory; import com.joycrew.backend.entity.enums.SortOption; import com.joycrew.backend.entity.kakao.KakaoTemplate; @@ -10,9 +11,10 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.*; import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; // 1. StringUtils import 추가 +import org.springframework.util.StringUtils; import java.util.List; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -23,8 +25,51 @@ public class ExternalCatalogService { @Value("${joycrew.points.krw_per_point:40}") private int krwPerPoint; - // 2. 메소드 시그니처에 searchName 파라미터 추가 - public List listByCategory(GiftCategory category, int page, int size, SortOption sort, String searchName) { + /** + * 추천 상품 (랜덤) - 이건 페이지 정보가 필요 없으므로 List 유지 + */ + public List getFeaturedProducts() { + // (레포지토리에 findRandomProducts(10)이 정의되어 있어야 함) + List templates = templateRepo.findRandomProducts(10); + return templates.stream() + .map(this::convertToExternalProductResponse) + .collect(Collectors.toList()); + } + + /** + * [수정] 카테고리 없이 이름으로만 전체 상품 검색 + * (반환 타입을 List -> PagedCatalogResponse 로 변경) + */ + public PagedCatalogResponse searchProductsByName(String searchName, int page, int size, SortOption sort) { + Sort s = switch (sort) { + case PRICE_ASC -> Sort.by(Sort.Direction.ASC, "basePriceKrw"); + case PRICE_DESC -> Sort.by(Sort.Direction.DESC, "basePriceKrw"); + case POPULAR, NEW -> Sort.by(Sort.Direction.DESC, "updatedAt"); + }; + + Pageable pageRequest = PageRequest.of(page, size, s); + + // (레포지토리에 findByNameContainingIgnoreCase(name, pageable)이 정의되어 있어야 함) + Page p = templateRepo.findByNameContainingIgnoreCase(searchName, pageRequest); + + // [수정] p.getContent() 대신 p.map()을 사용해 DTO로 변환 + Page dtoPage = p.map(this::convertToExternalProductResponse); + + // [수정] 새로 만든 PagedCatalogResponse DTO로 감싸서 반환 + return new PagedCatalogResponse( + dtoPage.getContent(), + dtoPage.getNumber(), + dtoPage.getTotalPages(), + dtoPage.getTotalElements(), + dtoPage.isLast() + ); + } + + /** + * [수정] 카테고리별 상품 조회 (검색 기능 포함) + * (반환 타입을 List -> PagedCatalogResponse 로 변경) + */ + public PagedCatalogResponse listByCategory(GiftCategory category, int page, int size, SortOption sort, String searchName) { Sort s = switch (sort) { case PRICE_ASC -> Sort.by(Sort.Direction.ASC, "basePriceKrw"); case PRICE_DESC -> Sort.by(Sort.Direction.DESC, "basePriceKrw"); @@ -34,25 +79,28 @@ public List listByCategory(GiftCategory category, int p Pageable pageRequest = PageRequest.of(page, size, s); Page p; - // 3. searchName (검색어) 유무에 따라 분기 처리 if (StringUtils.hasText(searchName)) { - // 검색어가 있는 경우: 이름으로 검색 - // (참고: KakaoTemplateRepository에 이 메소드가 정의되어 있어야 합니다) p = templateRepo.findByJoyCategoryAndNameContainingIgnoreCase(category, searchName, pageRequest); } else { - // 검색어가 없는 경우: 기존 로직 (카테고리로만 조회) p = templateRepo.findByJoyCategory(category, pageRequest); } - // 4. 조회 결과를 DTO로 변환하여 반환 - return p.getContent().stream().map(t -> { - int point = (int) Math.ceil(t.getBasePriceKrw() / (double) krwPerPoint); - return new ExternalProductResponse( - t.getTemplateId(), t.getName(), point, t.getBasePriceKrw(), t.getThumbnailUrl() - ); - }).toList(); + // [수정] p.map()을 사용해 DTO로 변환 + Page dtoPage = p.map(this::convertToExternalProductResponse); + + // [수정] 새로 만든 PagedCatalogResponse DTO로 감싸서 반환 + return new PagedCatalogResponse( + dtoPage.getContent(), + dtoPage.getNumber(), + dtoPage.getTotalPages(), + dtoPage.getTotalElements(), + dtoPage.isLast() + ); } + /** + * 상품 상세 정보 (이건 단건 조회라 수정 필요 없음) + */ public ExternalProductDetailResponse getDetailWithPoints(String templateId) { var t = templateRepo.findById(templateId).orElse(null); if (t == null) return null; @@ -67,5 +115,19 @@ public ExternalProductDetailResponse getDetailWithPoints(String templateId) { t.getThumbnailUrl() ); } -} + /** + * DTO 변환 헬퍼 (공통 로직) + */ + private ExternalProductResponse convertToExternalProductResponse(KakaoTemplate t) { + int point = (int) Math.ceil(t.getBasePriceKrw() / (double) krwPerPoint); + + return new ExternalProductResponse( + t.getTemplateId(), + t.getName(), + point, + t.getBasePriceKrw(), + t.getThumbnailUrl() + ); + } +} \ No newline at end of file From 138c9863a54e59d6ae9bfe3507e90786abfca757 Mon Sep 17 00:00:00 2001 From: yeoEun Date: Thu, 6 Nov 2025 17:54:31 +0900 Subject: [PATCH 129/135] feat/superadmin --- build.gradle | 1 + .../controller/EmployeeQueryController.java | 68 ++++++++-------- .../repository/EmployeeQueryRepository.java | 73 ----------------- .../backend/service/EmployeeQueryService.java | 70 ++++++++-------- .../service/EmployeeQueryServiceTest.java | 81 ------------------- 5 files changed, 75 insertions(+), 218 deletions(-) delete mode 100644 src/main/java/com/joycrew/backend/repository/EmployeeQueryRepository.java delete mode 100644 src/test/java/com/joycrew/backend/service/EmployeeQueryServiceTest.java diff --git a/build.gradle b/build.gradle index 5f4ccee..c00610e 100644 --- a/build.gradle +++ b/build.gradle @@ -49,6 +49,7 @@ dependencies { // AWS SDK for S3 and Secrets Manager Integration implementation platform('io.awspring.cloud:spring-cloud-aws-dependencies:3.1.1') implementation 'io.awspring.cloud:spring-cloud-aws-starter-secrets-manager' + implementation(platform("software.amazon.awssdk:bom:2.21.46")) implementation 'software.amazon.awssdk:s3' implementation 'org.springframework.cloud:spring-cloud-starter-bootstrap' diff --git a/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java b/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java index 5d3d3df..33384e2 100644 --- a/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java +++ b/src/main/java/com/joycrew/backend/controller/EmployeeQueryController.java @@ -1,7 +1,6 @@ package com.joycrew.backend.controller; import com.joycrew.backend.dto.PagedEmployeeResponse; -import com.joycrew.backend.entity.enums.AccessStatus; import com.joycrew.backend.security.UserPrincipal; import com.joycrew.backend.service.EmployeeQueryService; import io.swagger.v3.oas.annotations.Operation; @@ -21,35 +20,40 @@ @Tag(name = "Employee Query", description = "API for searching employees") public class EmployeeQueryController { - private final EmployeeQueryService employeeQueryService; + private final EmployeeQueryService employeeQueryService; - @Operation( - summary = "Search employee list", - description = "Performs a unified search by name, email, or department. The current user is excluded from the search results.", - parameters = { - @Parameter(name = "keyword", description = "Search keyword", example = "John"), - @Parameter(name = "page", description = "Page number (0-based)", example = "0"), - @Parameter(name = "size", description = "Items per page", example = "20") - }, - responses = { - @ApiResponse( - responseCode = "200", - description = "Employee list retrieved successfully", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = PagedEmployeeResponse.class) - ) - ) - } - ) - @GetMapping - public ResponseEntity searchEmployees( - @RequestParam(required = false) String keyword, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, - @AuthenticationPrincipal UserPrincipal principal - ) { - PagedEmployeeResponse response = employeeQueryService.getEmployees(keyword, page, size, principal.getEmployee().getEmployeeId(), AccessStatus.ACTIVE); - return ResponseEntity.ok(response); - } -} \ No newline at end of file + @Operation( + summary = "Search employee list", + description = "Performs a unified search by name, email, or department. The current user is excluded from the search results.", + parameters = { + @Parameter(name = "keyword", description = "Search keyword", example = "John"), + @Parameter(name = "page", description = "Page number (0-based)", example = "0"), + @Parameter(name = "size", description = "Items per page", example = "20") + }, + responses = { + @ApiResponse( + responseCode = "200", + description = "Employee list retrieved successfully", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = PagedEmployeeResponse.class) + ) + ) + } + ) + @GetMapping + public ResponseEntity searchEmployees( + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @AuthenticationPrincipal UserPrincipal principal + ) { + var me = principal.getEmployee(); + PagedEmployeeResponse response = employeeQueryService.getEmployees( + keyword, page, size, + me.getEmployeeId(), + me.getRole() // 요청자 역할 전달 + ); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/joycrew/backend/repository/EmployeeQueryRepository.java b/src/main/java/com/joycrew/backend/repository/EmployeeQueryRepository.java deleted file mode 100644 index fab9d0c..0000000 --- a/src/main/java/com/joycrew/backend/repository/EmployeeQueryRepository.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.joycrew.backend.repository; - -import com.joycrew.backend.dto.EmployeeQueryResponse; -import com.joycrew.backend.dto.PagedEmployeeResponse; -import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.service.mapper.EmployeeMapper; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import jakarta.persistence.TypedQuery; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Repository; -import org.springframework.util.StringUtils; - -import java.util.List; -import java.util.stream.Collectors; - -@Repository -@RequiredArgsConstructor -public class EmployeeQueryRepository { - - @PersistenceContext - private final EntityManager em; - private final EmployeeMapper employeeMapper; - - public PagedEmployeeResponse getEmployees(String keyword, int page, int size, Long currentEmployeeId) { - StringBuilder whereClause = new StringBuilder(); - boolean hasKeyword = StringUtils.hasText(keyword); - - // Base condition to exclude the current user - whereClause.append("WHERE e.id != :currentEmployeeId "); - - // Dynamic condition for keyword search - if (hasKeyword) { - whereClause.append("AND (LOWER(e.employeeName) LIKE :keyword ") - .append("OR LOWER(e.email) LIKE :keyword ") - .append("OR LOWER(e.department.name) LIKE :keyword) "); - } - - // Count Query - String countJpql = "SELECT COUNT(e) FROM Employee e LEFT JOIN e.department d " + whereClause; - TypedQuery countQuery = em.createQuery(countJpql, Long.class); - countQuery.setParameter("currentEmployeeId", currentEmployeeId); - if (hasKeyword) { - countQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); - } - long totalCount = countQuery.getSingleResult(); - int totalPages = (int) Math.ceil((double) totalCount / size); - - // Data Query - String dataJpql = "SELECT e FROM Employee e " + - "LEFT JOIN FETCH e.department d " + - whereClause + - "ORDER BY e.employeeName ASC"; - TypedQuery dataQuery = em.createQuery(dataJpql, Employee.class); - dataQuery.setParameter("currentEmployeeId", currentEmployeeId); - if (hasKeyword) { - dataQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); - } - dataQuery.setFirstResult(page * size); - dataQuery.setMaxResults(size); - - List employees = dataQuery.getResultList().stream() - .map(employeeMapper::toEmployeeQueryResponse) - .collect(Collectors.toList()); - - return new PagedEmployeeResponse( - employees, - page, - totalPages, - page >= totalPages - 1 - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java b/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java index 1e142e0..0c2da99 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java @@ -3,7 +3,7 @@ import com.joycrew.backend.dto.EmployeeQueryResponse; import com.joycrew.backend.dto.PagedEmployeeResponse; import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.entity.enums.AccessStatus; +import com.joycrew.backend.entity.enums.AdminLevel; import com.joycrew.backend.service.mapper.EmployeeMapper; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; @@ -25,51 +25,57 @@ public class EmployeeQueryService { private final EntityManager em; private final EmployeeMapper employeeMapper; - public PagedEmployeeResponse getEmployees(String keyword, int page, int size, Long currentUserId, AccessStatus accessStatus) { - StringBuilder whereClause = new StringBuilder(); - boolean hasKeyword = StringUtils.hasText(keyword); + // EmployeeQueryService.java + public PagedEmployeeResponse getEmployees( + String keyword, int page, int size, Long currentUserId, AdminLevel requesterRole + ) { + StringBuilder where = new StringBuilder("WHERE e.employeeId != :currentUserId "); + boolean hasKeyword = StringUtils.hasText(keyword); if (hasKeyword) { - whereClause.append("WHERE (LOWER(e.employeeName) LIKE :keyword ") - .append("OR LOWER(e.email) LIKE :keyword ") - .append("OR LOWER(d.name) LIKE :keyword) "); + where.append("AND (LOWER(e.employeeName) LIKE :keyword ") + .append("OR LOWER(e.email) LIKE :keyword ") + .append("OR LOWER(d.name) LIKE :keyword) "); } - whereClause.append(hasKeyword ? "AND " : "WHERE "); - whereClause.append("e.id != :currentUserId "); - - String countJpql = "SELECT COUNT(e) FROM Employee e LEFT JOIN e.department d " + whereClause; - TypedQuery countQuery = em.createQuery(countJpql, Long.class); - countQuery.setParameter("currentUserId", currentUserId); - if (hasKeyword) { - countQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); + boolean hideSuperAdmin = (requesterRole != AdminLevel.SUPER_ADMIN); + if (hideSuperAdmin) { + // JPQL에 SUPER_ADMIN 제외 + where.append("AND e.role <> :superAdmin "); } + + String countJpql = "SELECT COUNT(e) FROM Employee e LEFT JOIN e.department d " + where; + TypedQuery countQuery = em.createQuery(countJpql, Long.class) + .setParameter("currentUserId", currentUserId); + if (hasKeyword) countQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); + if (hideSuperAdmin) countQuery.setParameter("superAdmin", AdminLevel.SUPER_ADMIN); + long totalCount = countQuery.getSingleResult(); int totalPages = (int) Math.ceil((double) totalCount / size); - String dataJpql = "SELECT e FROM Employee e " + - "JOIN FETCH e.company c " + - "LEFT JOIN FETCH e.department d " + - whereClause + - "ORDER BY e.employeeName ASC"; - TypedQuery dataQuery = em.createQuery(dataJpql, Employee.class); - dataQuery.setParameter("currentUserId", currentUserId); - if (hasKeyword) { - dataQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); - } + String dataJpql = + "SELECT e FROM Employee e " + + "JOIN FETCH e.company c " + + "LEFT JOIN FETCH e.department d " + + where + + "ORDER BY e.employeeName ASC"; + TypedQuery dataQuery = em.createQuery(dataJpql, Employee.class) + .setParameter("currentUserId", currentUserId); + if (hasKeyword) dataQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); + if (hideSuperAdmin) dataQuery.setParameter("superAdmin", AdminLevel.SUPER_ADMIN); dataQuery.setFirstResult(page * size); dataQuery.setMaxResults(size); + // ✅ 최종 안전망: 결과에서 SUPER_ADMIN 제거 (요청자가 SUPER_ADMIN이 아닐 때) List employees = dataQuery.getResultList().stream() - .map(employeeMapper::toEmployeeQueryResponse) - .collect(Collectors.toList()); + .filter(e -> !(hideSuperAdmin && e.getRole() == AdminLevel.SUPER_ADMIN)) + .map(employeeMapper::toEmployeeQueryResponse) + .collect(Collectors.toList()); return new PagedEmployeeResponse( - employees, - page, - totalPages, - page >= totalPages - 1 + employees, page, totalPages, page >= totalPages - 1 ); } -} \ No newline at end of file + +} diff --git a/src/test/java/com/joycrew/backend/service/EmployeeQueryServiceTest.java b/src/test/java/com/joycrew/backend/service/EmployeeQueryServiceTest.java deleted file mode 100644 index 561636e..0000000 --- a/src/test/java/com/joycrew/backend/service/EmployeeQueryServiceTest.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.joycrew.backend.service; - -import com.joycrew.backend.dto.EmployeeQueryResponse; -import com.joycrew.backend.dto.PagedEmployeeResponse; -import com.joycrew.backend.entity.Employee; -import com.joycrew.backend.service.mapper.EmployeeMapper; -import jakarta.persistence.EntityManager; -import jakarta.persistence.TypedQuery; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class EmployeeQueryServiceTest { - - @Mock - private EntityManager em; - @Mock - private EmployeeMapper employeeMapper; - - @InjectMocks - private EmployeeQueryService employeeQueryService; - - @Test - @DisplayName("[Unit] Get employee list - Should return with paging information") - void getEmployees_Success() { - // Given - String keyword = "test"; - int page = 0; - int size = 10; - Long currentUserId = 1L; - - // Mocking TypedQuery for both count and data - TypedQuery countQuery = mock(TypedQuery.class); - TypedQuery dataQuery = mock(TypedQuery.class); - Employee mockEmployee = Employee.builder().employeeId(2L).employeeName("Test User").build(); - EmployeeQueryResponse mockDto = new EmployeeQueryResponse(2L, null, "Test User", "Test Dept", "Tester"); - - // Mocking for the count query - when(em.createQuery(anyString(), eq(Long.class))).thenReturn(countQuery); - when(countQuery.setParameter(anyString(), any())).thenReturn(countQuery); - when(countQuery.getSingleResult()).thenReturn(1L); - - // Mocking for the data query - when(em.createQuery(anyString(), eq(Employee.class))).thenReturn(dataQuery); - when(dataQuery.setParameter(anyString(), any())).thenReturn(dataQuery); - when(dataQuery.setFirstResult(anyInt())).thenReturn(dataQuery); - when(dataQuery.setMaxResults(anyInt())).thenReturn(dataQuery); - when(dataQuery.getResultList()).thenReturn(List.of(mockEmployee)); - - // Mocking the mapper's behavior - when(employeeMapper.toEmployeeQueryResponse(any(Employee.class))).thenReturn(mockDto); - - // When - PagedEmployeeResponse response = employeeQueryService.getEmployees(keyword, page, size, currentUserId); - - // Then - assertThat(response).isNotNull(); - assertThat(response.employees()).hasSize(1); - assertThat(response.employees().get(0).employeeName()).isEqualTo("Test User"); - assertThat(response.currentPage()).isEqualTo(page); - assertThat(response.totalPages()).isEqualTo(1); - assertThat(response.isLastPage()).isTrue(); - - verify(em, times(1)).createQuery(contains("SELECT COUNT(e)"), eq(Long.class)); - verify(em, times(1)).createQuery(contains("SELECT e FROM Employee e"), eq(Employee.class)); - verify(dataQuery, times(1)).setParameter("keyword", "%" + keyword.toLowerCase() + "%"); - verify(dataQuery, times(1)).setParameter("currentUserId", currentUserId); - } -} \ No newline at end of file From 287d521938c5f52fbfc5db6bd8d3cd223bd77562 Mon Sep 17 00:00:00 2001 From: yeoEun Date: Mon, 10 Nov 2025 16:13:47 +0900 Subject: [PATCH 130/135] feat/subdomainTenant --- .../joycrew/backend/config/FilterConfig.java | 20 +++++ .../backend/config/SecurityConfig.java | 1 + .../joycrew/backend/dto/LoginResponse.java | 38 +++++---- .../joycrew/backend/entity/CompanyDomain.java | 34 ++++++++ .../repository/CompanyDomainRepository.java | 19 +++++ .../repository/DepartmentRepository.java | 10 +++ .../repository/EmployeeRepository.java | 8 ++ .../RewardPointTransactionRepository.java | 17 ++++ .../backend/repository/WalletRepository.java | 2 + .../joycrew/backend/service/AuthService.java | 84 ++++++++++++------- .../backend/service/EmployeeQueryService.java | 24 +++--- .../backend/service/EmployeeService.java | 52 ++++++------ .../service/TransactionHistoryService.java | 43 +++++----- .../backend/service/WalletService.java | 20 +++-- .../backend/tenant/DomainTenantFilter.java | 63 ++++++++++++++ .../com/joycrew/backend/tenant/Tenant.java | 11 +++ .../joycrew/backend/tenant/TenantContext.java | 11 +++ 17 files changed, 340 insertions(+), 117 deletions(-) create mode 100644 src/main/java/com/joycrew/backend/config/FilterConfig.java create mode 100644 src/main/java/com/joycrew/backend/entity/CompanyDomain.java create mode 100644 src/main/java/com/joycrew/backend/repository/CompanyDomainRepository.java create mode 100644 src/main/java/com/joycrew/backend/tenant/DomainTenantFilter.java create mode 100644 src/main/java/com/joycrew/backend/tenant/Tenant.java create mode 100644 src/main/java/com/joycrew/backend/tenant/TenantContext.java diff --git a/src/main/java/com/joycrew/backend/config/FilterConfig.java b/src/main/java/com/joycrew/backend/config/FilterConfig.java new file mode 100644 index 0000000..51cb13a --- /dev/null +++ b/src/main/java/com/joycrew/backend/config/FilterConfig.java @@ -0,0 +1,20 @@ +// src/main/java/com/joycrew/backend/config/FilterConfig.java +package com.joycrew.backend.config; + +import com.joycrew.backend.tenant.DomainTenantFilter; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class FilterConfig { + + @Bean + public FilterRegistrationBean tenantFilterRegistration(DomainTenantFilter filter) { + FilterRegistrationBean reg = new FilterRegistrationBean<>(); + reg.setFilter(filter); + reg.setOrder(1); // SecurityFilterChain보다 앞서도록 1 등 낮은 순서 + reg.addUrlPatterns("/*"); // 전 요청 적용 + return reg; + } +} diff --git a/src/main/java/com/joycrew/backend/config/SecurityConfig.java b/src/main/java/com/joycrew/backend/config/SecurityConfig.java index 9a350bb..c8c1f37 100644 --- a/src/main/java/com/joycrew/backend/config/SecurityConfig.java +++ b/src/main/java/com/joycrew/backend/config/SecurityConfig.java @@ -80,6 +80,7 @@ public CorsConfigurationSource corsConfigurationSource() { "http://localhost:5173", "http://localhost:8082", "https://joycrew.co.kr", + "https://*.joycrew.co.kr", "https://www.joycrew.co.kr", "https://api.joycrew.co.kr" )); diff --git a/src/main/java/com/joycrew/backend/dto/LoginResponse.java b/src/main/java/com/joycrew/backend/dto/LoginResponse.java index 3514ab7..a943b53 100644 --- a/src/main/java/com/joycrew/backend/dto/LoginResponse.java +++ b/src/main/java/com/joycrew/backend/dto/LoginResponse.java @@ -5,24 +5,26 @@ @Schema(description = "Login Response DTO") public record LoginResponse( - @Schema(description = "JWT access token") - String accessToken, - @Schema(description = "Response message") - String message, - @Schema(description = "Unique ID of the user") - Long userId, - @Schema(description = "Name of the user") - String name, - @Schema(description = "Email of the user") - String email, - @Schema(description = "Role of the user") - AdminLevel role, - @Schema(description = "Total points balance") - Integer totalPoint, - @Schema(description = "URL of the profile image") - String profileImageUrl + @Schema(description = "JWT access token") + String accessToken, + @Schema(description = "Response message") + String message, + @Schema(description = "Unique ID of the user") + Long userId, + @Schema(description = "Name of the user") + String name, + @Schema(description = "Email of the user") + String email, + @Schema(description = "Role of the user") + AdminLevel role, + @Schema(description = "Total points balance") + Integer totalPoint, + @Schema(description = "URL of the profile image") + String profileImageUrl, + @Schema(description = "Tenant subdomain (e.g., 'alko', 'BDL')") + String subdomain ) { public static LoginResponse fail(String message) { - return new LoginResponse(null, message, null, null, null, null, null, null); + return new LoginResponse(null, message, null, null, null, null, null, null, null); } -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/entity/CompanyDomain.java b/src/main/java/com/joycrew/backend/entity/CompanyDomain.java new file mode 100644 index 0000000..1dac98c --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/CompanyDomain.java @@ -0,0 +1,34 @@ +// src/main/java/com/joycrew/backend/entity/CompanyDomain.java +package com.joycrew.backend.entity; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "company_domain") +@Getter @Setter +@NoArgsConstructor @AllArgsConstructor @Builder +public class CompanyDomain { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "company_id", nullable = false) + private Company company; + + @Column(nullable = false, unique = true, length = 255) + private String domain; + + @Column(name = "primary_domain", nullable = false) + private Boolean primaryDomain; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + @PrePersist void pre(){ createdAt = updatedAt = LocalDateTime.now(); if (primaryDomain==null) primaryDomain=true; } + @PreUpdate void upd(){ updatedAt = LocalDateTime.now(); } +} diff --git a/src/main/java/com/joycrew/backend/repository/CompanyDomainRepository.java b/src/main/java/com/joycrew/backend/repository/CompanyDomainRepository.java new file mode 100644 index 0000000..b91ea4b --- /dev/null +++ b/src/main/java/com/joycrew/backend/repository/CompanyDomainRepository.java @@ -0,0 +1,19 @@ +// src/main/java/com/joycrew/backend/repository/CompanyDomainRepository.java +package com.joycrew.backend.repository; + +import java.util.Optional; + +import com.joycrew.backend.entity.CompanyDomain; +import org.springframework.data.jpa.repository.*; +import org.springframework.data.repository.query.Param; + +public interface CompanyDomainRepository extends JpaRepository { + + @Query(""" + select cd.company.companyId + from CompanyDomain cd + where cd.domain = :domain + """) + Optional findCompanyIdByDomain(@Param("domain") String domain); + Optional findFirstByCompanyCompanyIdAndPrimaryDomainTrueOrderByIdDesc(Long companyId); +} diff --git a/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java b/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java index 3ed066e..9196339 100644 --- a/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java +++ b/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java @@ -2,6 +2,8 @@ import com.joycrew.backend.entity.Company; import com.joycrew.backend.entity.Department; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; @@ -10,4 +12,12 @@ public interface DepartmentRepository extends JpaRepository { List findAllByCompanyCompanyId(Long companyId); Optional findByCompanyAndName(Company company, String name); + + Page findByCompanyCompanyId(Long companyId, Pageable pageable); + + Optional findByCompanyCompanyIdAndDepartmentId(Long companyId, Long departmentId); + + Optional findByCompanyCompanyIdAndName(Long companyId, String name); + + boolean existsByCompanyCompanyIdAndName(Long companyId, String name); } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java b/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java index e668e1b..914a310 100644 --- a/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java +++ b/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java @@ -32,4 +32,12 @@ public interface EmployeeRepository extends JpaRepository { Optional findByIdWithCompany(@Param("id") Long id); List findByPhoneNumber(String phoneNumber); + + Page findByCompanyCompanyIdAndStatus(Long companyId, String status, Pageable pageable); + + Optional findByCompanyCompanyIdAndEmail(Long companyId, String email); + + Optional findByCompanyCompanyIdAndEmployeeId(Long companyId, Long employeeId); + + boolean existsByCompanyCompanyIdAndEmail(Long companyId, String email); } diff --git a/src/main/java/com/joycrew/backend/repository/RewardPointTransactionRepository.java b/src/main/java/com/joycrew/backend/repository/RewardPointTransactionRepository.java index 9f78788..4e01cc0 100644 --- a/src/main/java/com/joycrew/backend/repository/RewardPointTransactionRepository.java +++ b/src/main/java/com/joycrew/backend/repository/RewardPointTransactionRepository.java @@ -2,8 +2,12 @@ import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.RewardPointTransaction; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; @@ -15,4 +19,17 @@ public interface RewardPointTransactionRepository extends JpaRepository findBySenderOrReceiver(Employee sender, Employee receiver); + + Page findBySenderCompanyCompanyIdOrReceiverCompanyCompanyId(Long companyId1, Long companyId2, Pageable pageable); + + @Query(""" + select tx + from RewardPointTransaction tx + left join fetch tx.sender s + left join fetch tx.receiver r + where + (s.company.companyId = :companyId) or (r.company.companyId = :companyId) + order by tx.transactionDate desc + """) + List findAllByCompanyScope(@Param("companyId") Long companyId); } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/repository/WalletRepository.java b/src/main/java/com/joycrew/backend/repository/WalletRepository.java index 71d2c82..03d35eb 100644 --- a/src/main/java/com/joycrew/backend/repository/WalletRepository.java +++ b/src/main/java/com/joycrew/backend/repository/WalletRepository.java @@ -16,4 +16,6 @@ public interface WalletRepository extends JpaRepository { // 일반 조회는 기존 메서드 유지 Optional findByEmployee_EmployeeId(Long employeeId); + + Optional findByEmployeeCompanyCompanyIdAndEmployeeEmployeeId(Long companyId, Long employeeId); } \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/service/AuthService.java b/src/main/java/com/joycrew/backend/service/AuthService.java index dfb4c8d..00cb14d 100644 --- a/src/main/java/com/joycrew/backend/service/AuthService.java +++ b/src/main/java/com/joycrew/backend/service/AuthService.java @@ -7,13 +7,14 @@ import com.joycrew.backend.exception.UserNotFoundException; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.WalletRepository; +import com.joycrew.backend.repository.CompanyDomainRepository; import com.joycrew.backend.security.JwtUtil; import com.joycrew.backend.security.UserPrincipal; +import com.joycrew.backend.tenant.Tenant; import io.jsonwebtoken.JwtException; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; @@ -24,12 +25,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor public class AuthService { - private static final Logger log = LoggerFactory.getLogger(AuthService.class); - @Value("${jwt.password-reset-expiration-ms}") private long passwordResetExpirationMs; @@ -37,45 +37,54 @@ public class AuthService { private final AuthenticationManager authenticationManager; private final WalletRepository walletRepository; private final EmployeeRepository employeeRepository; + private final CompanyDomainRepository companyDomainRepository; private final PasswordEncoder passwordEncoder; private final EmailService emailService; + /** + * 로그인: 인증 성공 시 JWT와 사용자 정보 + subdomain(예: alko.joycrew.co.kr)을 반환 + */ @Transactional public LoginResponse login(LoginRequest request) { log.info("Attempting login for email: {}", request.email()); - if ("dev@joycrew.co.kr".equals(request.email())) { - String correctHash = passwordEncoder.encode("password123!"); - log.warn("================== DEBUG HASH =================="); - log.warn("Correct hash for 'password123!': {}", correctHash); - log.warn("================================================"); - } - try { + // 인증 (EmployeeDetailsService는 반드시 테넌트 스코프 조회를 하도록 구현되어 있어야 함) Authentication authentication = authenticationManager.authenticate( - new UsernamePasswordAuthenticationToken(request.email(), request.password()) + new UsernamePasswordAuthenticationToken(request.email(), request.password()) ); - UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal(); - Employee employee = userPrincipal.getEmployee(); + UserPrincipal principal = (UserPrincipal) authentication.getPrincipal(); + Employee employee = principal.getEmployee(); + // 지갑 잔액 Integer totalPoint = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) - .map(Wallet::getBalance) - .orElse(0); + .map(Wallet::getBalance) + .orElse(0); + // 마지막 로그인 시간 업데이트 employee.updateLastLogin(); + // 토큰 생성 (subject: email) String accessToken = jwtUtil.generateToken(employee.getEmail()); + // subdomain: 현재 테넌트의 primary 도메인을 그대로 반환 (예: alko.joycrew.co.kr) + Long tenant = Tenant.id(); + String subdomain = companyDomainRepository + .findFirstByCompanyCompanyIdAndPrimaryDomainTrueOrderByIdDesc(tenant) + .map(cd -> cd.getDomain().toLowerCase()) + .orElse(null); // 등록이 안 되어 있다면 null + return new LoginResponse( - accessToken, - "Login successful", - employee.getEmployeeId(), - employee.getEmployeeName(), - employee.getEmail(), - employee.getRole(), - totalPoint, - employee.getProfileImageUrl() + accessToken, + "Login successful", + employee.getEmployeeId(), + employee.getEmployeeName(), + employee.getEmail(), + employee.getRole(), + totalPoint, + employee.getProfileImageUrl(), + subdomain ); } catch (UsernameNotFoundException | BadCredentialsException e) { @@ -84,23 +93,34 @@ public LoginResponse login(LoginRequest request) { } } + /** + * 로그아웃: 서버 사이드 블랙리스트를 쓴다면 여기에서 처리 (현재는 로그만) + */ public void logout(HttpServletRequest request) { final String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { String jwt = authHeader.substring(7); - log.info("Logout request received. Token blacklisting can be implemented here if needed."); + log.info("Logout requested (token: {}). Add blacklist handling if needed.", jwt); } } + /** + * 비밀번호 재설정 요청: 도메인 기반 테넌트에서 이메일 검색 -> 토큰 발행 후 메일 발송 + * (응답은 존재 여부와 무관하게 동일) + */ @Transactional(readOnly = true) public void requestPasswordReset(String email) { - employeeRepository.findByEmail(email).ifPresent(employee -> { + Long tenant = Tenant.id(); + employeeRepository.findByCompanyCompanyIdAndEmail(tenant, email).ifPresent(emp -> { String token = jwtUtil.generateToken(email, passwordResetExpirationMs); emailService.sendPasswordResetEmail(email, token); - log.info("Password reset requested for email: {}", email); + log.info("Password reset requested for email: {} (tenant={})", email, tenant); }); } + /** + * 비밀번호 재설정 확정: 토큰에서 이메일 추출 후 같은 테넌트 범위에서 사용자 조회 -> 비밀번호 변경 + */ @Transactional public void confirmPasswordReset(String token, String newPassword) { String email; @@ -110,10 +130,12 @@ public void confirmPasswordReset(String token, String newPassword) { throw new BadCredentialsException("Invalid or expired token.", e); } - Employee employee = employeeRepository.findByEmail(email) - .orElseThrow(() -> new UserNotFoundException("User not found.")); + Long tenant = Tenant.id(); + Employee employee = employeeRepository + .findByCompanyCompanyIdAndEmail(tenant, email) + .orElseThrow(() -> new UserNotFoundException("User not found.")); employee.changePassword(newPassword, passwordEncoder); - log.info("Password has been reset for: {}", email); + log.info("Password has been reset for: {} (tenant={})", email, tenant); } -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java b/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java index 0c2da99..a43e6a3 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java @@ -5,6 +5,7 @@ import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.enums.AdminLevel; import com.joycrew.backend.service.mapper.EmployeeMapper; +import com.joycrew.backend.tenant.Tenant; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import jakarta.persistence.TypedQuery; @@ -25,11 +26,10 @@ public class EmployeeQueryService { private final EntityManager em; private final EmployeeMapper employeeMapper; - // EmployeeQueryService.java - public PagedEmployeeResponse getEmployees( - String keyword, int page, int size, Long currentUserId, AdminLevel requesterRole - ) { - StringBuilder where = new StringBuilder("WHERE e.employeeId != :currentUserId "); + public PagedEmployeeResponse getEmployees(String keyword, int page, int size, Long currentUserId, AdminLevel requesterRole) { + Long tenant = Tenant.id(); // ✅ 테넌트 + + StringBuilder where = new StringBuilder("WHERE c.companyId = :tenant AND e.employeeId != :currentUserId "); boolean hasKeyword = StringUtils.hasText(keyword); if (hasKeyword) { @@ -40,12 +40,12 @@ public PagedEmployeeResponse getEmployees( boolean hideSuperAdmin = (requesterRole != AdminLevel.SUPER_ADMIN); if (hideSuperAdmin) { - // JPQL에 SUPER_ADMIN 제외 where.append("AND e.role <> :superAdmin "); } - String countJpql = "SELECT COUNT(e) FROM Employee e LEFT JOIN e.department d " + where; + String countJpql = "SELECT COUNT(e) FROM Employee e JOIN e.company c LEFT JOIN e.department d " + where; TypedQuery countQuery = em.createQuery(countJpql, Long.class) + .setParameter("tenant", tenant) .setParameter("currentUserId", currentUserId); if (hasKeyword) countQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); if (hideSuperAdmin) countQuery.setParameter("superAdmin", AdminLevel.SUPER_ADMIN); @@ -59,7 +59,9 @@ public PagedEmployeeResponse getEmployees( "LEFT JOIN FETCH e.department d " + where + "ORDER BY e.employeeName ASC"; + TypedQuery dataQuery = em.createQuery(dataJpql, Employee.class) + .setParameter("tenant", tenant) .setParameter("currentUserId", currentUserId); if (hasKeyword) dataQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); if (hideSuperAdmin) dataQuery.setParameter("superAdmin", AdminLevel.SUPER_ADMIN); @@ -67,15 +69,11 @@ public PagedEmployeeResponse getEmployees( dataQuery.setFirstResult(page * size); dataQuery.setMaxResults(size); - // ✅ 최종 안전망: 결과에서 SUPER_ADMIN 제거 (요청자가 SUPER_ADMIN이 아닐 때) List employees = dataQuery.getResultList().stream() - .filter(e -> !(hideSuperAdmin && e.getRole() == AdminLevel.SUPER_ADMIN)) + .filter(e -> !(hideSuperAdmin && e.getRole() == AdminLevel.SUPER_ADMIN)) // 최종 안전망 .map(employeeMapper::toEmployeeQueryResponse) .collect(Collectors.toList()); - return new PagedEmployeeResponse( - employees, page, totalPages, page >= totalPages - 1 - ); + return new PagedEmployeeResponse(employees, page, totalPages, page >= totalPages - 1); } - } diff --git a/src/main/java/com/joycrew/backend/service/EmployeeService.java b/src/main/java/com/joycrew/backend/service/EmployeeService.java index bd2cd60..084d487 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeService.java @@ -10,6 +10,7 @@ import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.WalletRepository; import com.joycrew.backend.service.mapper.EmployeeMapper; +import com.joycrew.backend.tenant.Tenant; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.crypto.password.PasswordEncoder; @@ -21,6 +22,7 @@ @RequiredArgsConstructor @Transactional public class EmployeeService { + private final EmployeeRepository employeeRepository; private final WalletRepository walletRepository; private final PasswordEncoder passwordEncoder; @@ -29,23 +31,30 @@ public class EmployeeService { @Transactional(readOnly = true) public UserProfileResponse getUserProfile(String userEmail) { - Employee employee = employeeRepository.findByEmail(userEmail) - .orElseThrow(() -> new UserNotFoundException("Authenticated user not found.")); + Long tenant = Tenant.id(); + Employee employee = employeeRepository + .findByCompanyCompanyIdAndEmail(tenant, userEmail) + .orElseThrow(() -> new UserNotFoundException("Authenticated user not found.")); - Wallet wallet = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) - .orElse(new Wallet(employee)); + Wallet wallet = walletRepository + .findByEmployeeCompanyCompanyIdAndEmployeeEmployeeId(tenant, employee.getEmployeeId()) + .orElse(new Wallet(employee)); return employeeMapper.toUserProfileResponse(employee, wallet); } public void forcePasswordChange(String userEmail, PasswordChangeRequest request) { - Employee employee = employeeRepository.findByEmail(userEmail) - .orElseThrow(() -> new UserNotFoundException("Authenticated user not found.")); + Long tenant = Tenant.id(); + Employee employee = employeeRepository + .findByCompanyCompanyIdAndEmail(tenant, userEmail) + .orElseThrow(() -> new UserNotFoundException("Authenticated user not found.")); employee.changePassword(request.newPassword(), passwordEncoder); } public void verifyCurrentPassword(String userEmail, PasswordVerifyRequest request) { - Employee employee = employeeRepository.findByEmail(userEmail) + Long tenant = Tenant.id(); + Employee employee = employeeRepository + .findByCompanyCompanyIdAndEmail(tenant, userEmail) .orElseThrow(() -> new UserNotFoundException("Authenticated user not found.")); if (!passwordEncoder.matches(request.currentPassword(), employee.getPasswordHash())) { @@ -54,27 +63,20 @@ public void verifyCurrentPassword(String userEmail, PasswordVerifyRequest reques } public void updateUserProfile(String userEmail, UserProfileUpdateRequest request, MultipartFile profileImage) { - Employee employee = employeeRepository.findByEmail(userEmail) - .orElseThrow(() -> new UserNotFoundException("Authenticated user not found.")); + Long tenant = Tenant.id(); + Employee employee = employeeRepository + .findByCompanyCompanyIdAndEmail(tenant, userEmail) + .orElseThrow(() -> new UserNotFoundException("Authenticated user not found.")); + + if (request.name() != null) employee.updateName(request.name()); - if (request.name() != null) { - employee.updateName(request.name()); - } if (profileImage != null && !profileImage.isEmpty()) { String profileImageUrl = s3FileStorageService.uploadFile(profileImage); employee.updateProfileImageUrl(profileImageUrl); } - if (request.personalEmail() != null) { - employee.updatePersonalEmail(request.personalEmail()); - } - if (request.phoneNumber() != null) { - employee.updatePhoneNumber(request.phoneNumber()); - } - if (request.birthday() != null) { - employee.updateBirthday(request.birthday()); - } - if (request.address() != null) { - employee.updateAddress(request.address()); - } + if (request.personalEmail() != null) employee.updatePersonalEmail(request.personalEmail()); + if (request.phoneNumber() != null) employee.updatePhoneNumber(request.phoneNumber()); + if (request.birthday() != null) employee.updateBirthday(request.birthday()); + if (request.address() != null) employee.updateAddress(request.address()); } -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java b/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java index d6a06d8..aec002d 100644 --- a/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java +++ b/src/main/java/com/joycrew/backend/service/TransactionHistoryService.java @@ -6,13 +6,13 @@ import com.joycrew.backend.exception.UserNotFoundException; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.RewardPointTransactionRepository; +import com.joycrew.backend.tenant.Tenant; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.stream.Collectors; - @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -22,32 +22,33 @@ public class TransactionHistoryService { private final EmployeeRepository employeeRepository; public List getTransactionHistory(String userEmail) { - Employee user = employeeRepository.findByEmail(userEmail) + Long tenant = Tenant.id(); + + Employee me = employeeRepository + .findByCompanyCompanyIdAndEmail(tenant, userEmail) .orElseThrow(() -> new UserNotFoundException("User not found with email: " + userEmail)); - List personalTransactionTypes = List.of( - TransactionType.AWARD_P2P - ); + List personalTypes = List.of(TransactionType.AWARD_P2P); - return transactionRepository.findBySenderOrReceiverOrderByTransactionDateDesc(user, user) - .stream() - .filter(tx -> personalTransactionTypes.contains(tx.getType())) + return transactionRepository.findAllByCompanyScope(tenant).stream() + .filter(tx -> { + // 본인 관련(보낸/받은)만 추려내기 + Long senderId = (tx.getSender() != null) ? tx.getSender().getEmployeeId() : null; + Long receiverId = (tx.getReceiver() != null) ? tx.getReceiver().getEmployeeId() : null; + return (senderId != null && senderId.equals(me.getEmployeeId())) + || (receiverId != null && receiverId.equals(me.getEmployeeId())); + }) + .filter(tx -> personalTypes.contains(tx.getType())) .map(tx -> { - boolean isSender = user.equals(tx.getSender()); + boolean isSender = me.equals(tx.getSender()); int amount = isSender ? -tx.getPointAmount() : tx.getPointAmount(); - String counterparty = "System/Admin"; // 기본값 설정 - Employee counterpartyEmployee = null; - - if (isSender) { - counterpartyEmployee = tx.getReceiver(); - } else { - counterpartyEmployee = tx.getSender(); - } + String counterparty = "System/Admin"; + Employee cp = isSender ? tx.getReceiver() : tx.getSender(); - if (counterpartyEmployee != null) { - counterparty = counterpartyEmployee.getEmployeeName(); - // ⭐️ 여기에서 counterpartyProfileImageUrl, counterpartyDepartmentName도 매핑해야 함 + if (cp != null) { + counterparty = cp.getEmployeeName(); + // TODO: 필요 시 counterparty 이미지/부서명 등 추가 매핑 } else if (tx.getType() == TransactionType.AWARD_MANAGER_SPOT) { counterparty = "Admin"; } else if (tx.getType() == TransactionType.REDEEM_ITEM || tx.getType() == TransactionType.EXPIRE_POINTS) { @@ -65,4 +66,4 @@ public List getTransactionHistory(String userEmail) }) .collect(Collectors.toList()); } -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/service/WalletService.java b/src/main/java/com/joycrew/backend/service/WalletService.java index 719451a..16b1173 100644 --- a/src/main/java/com/joycrew/backend/service/WalletService.java +++ b/src/main/java/com/joycrew/backend/service/WalletService.java @@ -7,6 +7,7 @@ import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.WalletRepository; import com.joycrew.backend.service.mapper.EmployeeMapper; +import com.joycrew.backend.tenant.Tenant; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,22 +16,23 @@ @RequiredArgsConstructor @Transactional(readOnly = true) public class WalletService { + private final WalletRepository walletRepository; private final EmployeeRepository employeeRepository; private final EmployeeMapper employeeMapper; - @Transactional // ⭐️ Wallet 생성 및 저장 필요하므로 @Transactional 재정의 + @Transactional public PointBalanceResponse getPointBalance(String userEmail) { - Employee employee = employeeRepository.findByEmail(userEmail) + Long tenant = Tenant.id(); + + Employee employee = employeeRepository + .findByCompanyCompanyIdAndEmail(tenant, userEmail) .orElseThrow(() -> new UserNotFoundException("Authenticated user not found.")); - // ⭐️ orElseGet을 사용하여 Wallet이 없으면 생성 후 DB에 저장 (영속성 문제 해결) - Wallet wallet = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) - .orElseGet(() -> { - Wallet newWallet = new Wallet(employee); - return walletRepository.save(newWallet); - }); + Wallet wallet = walletRepository + .findByEmployeeCompanyCompanyIdAndEmployeeEmployeeId(tenant, employee.getEmployeeId()) + .orElseGet(() -> walletRepository.save(new Wallet(employee))); return employeeMapper.toPointBalanceResponse(wallet); } -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/tenant/DomainTenantFilter.java b/src/main/java/com/joycrew/backend/tenant/DomainTenantFilter.java new file mode 100644 index 0000000..bc7a03b --- /dev/null +++ b/src/main/java/com/joycrew/backend/tenant/DomainTenantFilter.java @@ -0,0 +1,63 @@ +// src/main/java/com/joycrew/backend/tenant/DomainTenantFilter.java +package com.joycrew.backend.tenant; + +import com.joycrew.backend.repository.CompanyDomainRepository; +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.Optional; + +@Component +public class DomainTenantFilter implements Filter { + + private final CompanyDomainRepository domainRepository; + + public DomainTenantFilter(CompanyDomainRepository domainRepository) { + this.domainRepository = domainRepository; + } + + @Override + public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest http = (HttpServletRequest) req; + + String host = extractHost(http); // X-Forwarded-Host 우선 + String normalized = normalizeHost(host); // 포트 제거, 소문자 변환 + + Long companyId = resolveCompanyId(normalized) + .orElseGet(this::fallbackCompanyId); // 없으면 기본값(개발/로컬용) + + try { + TenantContext.set(companyId); + chain.doFilter(req, res); + } finally { + TenantContext.clear(); + } + } + + private Optional resolveCompanyId(String host) { + if (host == null || host.isBlank()) return Optional.empty(); + return domainRepository.findCompanyIdByDomain(host); + } + + private Long fallbackCompanyId() { + // 운영에선 404(UNKNOWN DOMAIN)로 처리하고 싶다면 예외를 던지도록 바꾸세요. + // throw new ServletException("Unknown domain"); + return 1L; // 개발/로컬 환경 기본 테넌트 + } + + private String extractHost(HttpServletRequest http) { + String fwd = http.getHeader("X-Forwarded-Host"); + if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim(); + return http.getHeader("Host"); + } + + private String normalizeHost(String host) { + if (host == null) return null; + int idx = host.indexOf(':'); // :443 등 제거 + String h = (idx > -1) ? host.substring(0, idx) : host; + return h.toLowerCase(); + } +} diff --git a/src/main/java/com/joycrew/backend/tenant/Tenant.java b/src/main/java/com/joycrew/backend/tenant/Tenant.java new file mode 100644 index 0000000..172ae26 --- /dev/null +++ b/src/main/java/com/joycrew/backend/tenant/Tenant.java @@ -0,0 +1,11 @@ +// src/main/java/com/joycrew/backend/tenant/Tenant.java +package com.joycrew.backend.tenant; + +public final class Tenant { + public static Long id() { + Long id = TenantContext.get(); + if (id == null) throw new IllegalStateException("TenantContext is not set"); + return id; + } +} + diff --git a/src/main/java/com/joycrew/backend/tenant/TenantContext.java b/src/main/java/com/joycrew/backend/tenant/TenantContext.java new file mode 100644 index 0000000..e7446f2 --- /dev/null +++ b/src/main/java/com/joycrew/backend/tenant/TenantContext.java @@ -0,0 +1,11 @@ +// src/main/java/com/joycrew/backend/tenant/TenantContext.java +package com.joycrew.backend.tenant; + +public final class TenantContext { + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + private TenantContext() {} + + public static void set(Long companyId) { CURRENT.set(companyId); } + public static Long get() { return CURRENT.get(); } + public static void clear() { CURRENT.remove(); } +} From ae2c9005daeece10f9390870b49a0403cca9d0e2 Mon Sep 17 00:00:00 2001 From: yeoEun Date: Thu, 20 Nov 2025 17:50:26 +0900 Subject: [PATCH 131/135] feat/jwtcookie --- .../backend/controller/AuthController.java | 29 ++++++- .../security/JwtAuthenticationFilter.java | 76 +++++++++++++------ .../com/joycrew/backend/web/CookieUtil.java | 28 +++++++ 3 files changed, 106 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/joycrew/backend/web/CookieUtil.java diff --git a/src/main/java/com/joycrew/backend/controller/AuthController.java b/src/main/java/com/joycrew/backend/controller/AuthController.java index 2650947..587aafd 100644 --- a/src/main/java/com/joycrew/backend/controller/AuthController.java +++ b/src/main/java/com/joycrew/backend/controller/AuthController.java @@ -2,6 +2,7 @@ import com.joycrew.backend.dto.*; import com.joycrew.backend.service.AuthService; +import com.joycrew.backend.web.CookieUtil; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; @@ -23,18 +24,38 @@ public class AuthController { @ApiResponse(responseCode = "200", description = "Login successful") @ApiResponse(responseCode = "401", description = "Authentication failed") @PostMapping("/login") - public ResponseEntity login(@RequestBody @Valid LoginRequest request) { - LoginResponse loginResponse = authService.login(request); - return ResponseEntity.ok(loginResponse); + public ResponseEntity login(@RequestBody @Valid LoginRequest request, + HttpServletRequest httpReq) { + LoginResponse body = authService.login(request); + + // 운영/개발에 맞게 설정 + boolean secure = true; // prod는 true 고정 권장 + long maxAgeSec = 24 * 60 * 60; // access 토큰 유효시간과 동일하게 + String cookieDomain = ".joycrew.co.kr"; + + // 쿠키에 JWT 심기 + var cookie = CookieUtil.authCookie(body.accessToken(), cookieDomain, maxAgeSec, secure); + + return ResponseEntity.ok() + .header("Set-Cookie", cookie.toString()) + .body(body); } @Operation(summary = "Logout") @PostMapping("/logout") public ResponseEntity logout(HttpServletRequest request) { authService.logout(request); - return ResponseEntity.ok(new SuccessResponse("You have been logged out.")); + + boolean secure = true; + String cookieDomain = ".joycrew.co.kr"; + + var clear = CookieUtil.clearAuth(cookieDomain, secure); + return ResponseEntity.ok() + .header("Set-Cookie", clear.toString()) + .body(new SuccessResponse("You have been logged out.")); } + @Operation(summary = "Request password reset (sends email)", description = "Sends a magic link to the user's email to reset the password.") @ApiResponse(responseCode = "200", description = "The request was processed successfully (the response is the same regardless of whether the email exists).") @PostMapping("/password-reset/request") diff --git a/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java b/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java index 531d781..0ca3c70 100644 --- a/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java @@ -4,6 +4,7 @@ import io.jsonwebtoken.JwtException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -28,22 +29,53 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final UserDetailsService userDetailsService; private final AntPathMatcher pathMatcher = new AntPathMatcher(); - // JWT 토큰 검사를 건너뛸 경로 목록 (SecurityConfig와 일치하도록 수정) + // JWT 토큰 검사를 건너뛸 경로 목록 (SecurityConfig와 일치하도록 유지) private static final List EXCLUDE_URLS = Arrays.asList( "/", "/error", "/actuator/health", "/h2-console/**", - "/api/auth/**", // 로그인, 비밀번호 재설정 등 모든 인증 관련 경로 - "/api/kyc/phone/**", // ### KYC 관련 경로 추가 (문제의 직접적인 원인) ### - "/accounts/emails/by-phone", // ### 이메일 조회 경로 추가 (문제의 직접적인 원인) ### - "/api/catalog/**", // 상품 목록 조회 경로 추가 + "/api/auth/**", // 로그인, 비밀번호 재설정 등 인증 관련 경로 + "/api/kyc/phone/**", // KYC 관련 경로 + "/accounts/emails/by-phone", // 이메일 조회 경로 + "/api/catalog/**", // 상품 목록 조회 "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html" - // "/api/admin/employees" // 보안상 이 경로는 필터 예외에서 제거하는 것이 올바릅니다. + // "/api/admin/employees" // 보안상 필터 예외에서 제거하는 것이 올바름 ); + /** + * Authorization 헤더 또는 JC_AUTH 쿠키에서 토큰을 추출한다. + */ + private String resolveToken(HttpServletRequest request) { + // 1순위: Authorization 헤더 + String authHeader = request.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + return authHeader.substring(7); + } + + // 2순위: 쿠키(JC_AUTH)에서 추출 + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie c : cookies) { + if ("JC_AUTH".equals(c.getName())) { + String value = c.getValue(); + if (value != null && !value.isBlank()) { + return value; + } + } + } + } + + return null; + } + + private boolean isExcluded(String path) { + return EXCLUDE_URLS.stream() + .anyMatch(excludeUrl -> pathMatcher.match(excludeUrl, path)); + } + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, @@ -58,26 +90,23 @@ protected void doFilterInternal(HttpServletRequest request, return; } - boolean isExcluded = EXCLUDE_URLS.stream() - .anyMatch(excludeUrl -> pathMatcher.match(excludeUrl, path)); - - if (isExcluded) { - log.info("JWT Filter bypassed for path: {}", path); + // 화이트리스트 경로는 JWT 검사 스킵 + if (isExcluded(path)) { + log.debug("JWT Filter bypassed for path: {}", path); filterChain.doFilter(request, response); return; } - log.info("===== JWT Filter Executed for path: {} =====", path); - - String authHeader = request.getHeader("Authorization"); + log.debug("===== JWT Filter Executed for path: {} =====", path); - if (authHeader == null || !authHeader.startsWith("Bearer ")) { - log.warn("Authorization header is missing or invalid for protected path: {}", path); + // 헤더 또는 쿠키에서 토큰 추출 + String token = resolveToken(request); + if (token == null || token.isBlank()) { + log.warn("No JWT token found for protected path: {}", path); filterChain.doFilter(request, response); return; } - String token = authHeader.substring(7); String email = null; try { email = jwtUtil.getEmailFromToken(token); @@ -90,11 +119,12 @@ protected void doFilterInternal(HttpServletRequest request, if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = this.userDetailsService.loadUserByUsername(email); - UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( - userDetails, - null, - userDetails.getAuthorities() - ); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); log.info("User '{}' authenticated successfully.", email); @@ -102,4 +132,4 @@ protected void doFilterInternal(HttpServletRequest request, filterChain.doFilter(request, response); } -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/web/CookieUtil.java b/src/main/java/com/joycrew/backend/web/CookieUtil.java new file mode 100644 index 0000000..17c080d --- /dev/null +++ b/src/main/java/com/joycrew/backend/web/CookieUtil.java @@ -0,0 +1,28 @@ +// com.joycrew.backend.web.CookieUtil.java +package com.joycrew.backend.web; + +import org.springframework.http.ResponseCookie; + +public class CookieUtil { + public static ResponseCookie authCookie(String token, String domain, long maxAgeSeconds, boolean secure) { + return ResponseCookie.from("JC_AUTH", token) + .httpOnly(true) + .secure(secure) // prod: true, dev: false 가능 + .sameSite("None") // cross-site 이동을 위해 None + .domain(domain) // ".joycrew.co.kr" + .path("/") + .maxAge(maxAgeSeconds) + .build(); + } + + public static ResponseCookie clearAuth(String domain, boolean secure) { + return ResponseCookie.from("JC_AUTH", "") + .httpOnly(true) + .secure(secure) + .sameSite("None") + .domain(domain) + .path("/") + .maxAge(0) + .build(); + } +} From 4daa74f88c3c2b62340e16017e94dd56798c0cf3 Mon Sep 17 00:00:00 2001 From: yeoEun Date: Thu, 20 Nov 2025 20:27:08 +0900 Subject: [PATCH 132/135] fix/giftpointexample --- src/main/java/com/joycrew/backend/dto/GiftPointRequest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/joycrew/backend/dto/GiftPointRequest.java b/src/main/java/com/joycrew/backend/dto/GiftPointRequest.java index 81683ef..0445ab3 100644 --- a/src/main/java/com/joycrew/backend/dto/GiftPointRequest.java +++ b/src/main/java/com/joycrew/backend/dto/GiftPointRequest.java @@ -24,7 +24,7 @@ public record GiftPointRequest( @Size(max = 255, message = "Message cannot exceed 255 characters.") String message, - @Schema(description = "List of tags to send with the points (min 1, max 3)", example = "[\"TEAMWORK\", \"LEADERSHIP\"]") + @Schema(description = "List of tags to send with the points (min 1, max 3)", example = "[\"TEAMWORK\"]") @NotNull(message = "Tags are required.") @Size(min = 1, max = 3, message = "Between 1 and 3 tags can be selected.") List tags From c25e30f2f2275d7a9de8beda8ae8b86bbfb8d830 Mon Sep 17 00:00:00 2001 From: yeoEun Date: Fri, 12 Dec 2025 13:54:33 +0900 Subject: [PATCH 133/135] feat:subscription --- .../joycrew/backend/config/KcpProperties.java | 38 ++++++++ .../AdminCompanySubscriptionController.java | 43 +++++++++ .../backend/dto/SubscriptionRequestDto.java | 13 +++ .../com/joycrew/backend/entity/Company.java | 48 +++++++++- .../entity/CompanySubscriptionPayment.java | 80 ++++++++++++++++ .../enums/SubscriptionPaymentStatus.java | 8 ++ .../backend/payment/kcp/KcpClient.java | 33 +++++++ .../payment/kcp/dto/KcpPaymentRequest.java | 22 +++++ .../payment/kcp/dto/KcpPaymentResponse.java | 14 +++ .../CompanySubscriptionPaymentRepository.java | 10 ++ .../backend/repository/WalletRepository.java | 1 - .../service/CompanySubscriptionService.java | 93 +++++++++++++++++++ .../backend/service/EmployeeQueryService.java | 3 +- src/main/resources/application.yml | 6 ++ 14 files changed, 408 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/joycrew/backend/config/KcpProperties.java create mode 100644 src/main/java/com/joycrew/backend/controller/AdminCompanySubscriptionController.java create mode 100644 src/main/java/com/joycrew/backend/dto/SubscriptionRequestDto.java create mode 100644 src/main/java/com/joycrew/backend/entity/CompanySubscriptionPayment.java create mode 100644 src/main/java/com/joycrew/backend/entity/enums/SubscriptionPaymentStatus.java create mode 100644 src/main/java/com/joycrew/backend/payment/kcp/KcpClient.java create mode 100644 src/main/java/com/joycrew/backend/payment/kcp/dto/KcpPaymentRequest.java create mode 100644 src/main/java/com/joycrew/backend/payment/kcp/dto/KcpPaymentResponse.java create mode 100644 src/main/java/com/joycrew/backend/repository/CompanySubscriptionPaymentRepository.java create mode 100644 src/main/java/com/joycrew/backend/service/CompanySubscriptionService.java diff --git a/src/main/java/com/joycrew/backend/config/KcpProperties.java b/src/main/java/com/joycrew/backend/config/KcpProperties.java new file mode 100644 index 0000000..26fd46a --- /dev/null +++ b/src/main/java/com/joycrew/backend/config/KcpProperties.java @@ -0,0 +1,38 @@ +package com.joycrew.backend.config; + +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter +@Component +@ConfigurationProperties(prefix = "payment.kcp") +public class KcpProperties { + + /** + * 테스트 상점 코드 (예: T0000XXXX) + */ + private String siteCd = "T1234TEST"; + + /** + * 테스트 사이트 키 (임의) + */ + private String siteKey = "0123456789ABCDEF0123456789ABCDEF"; + + /** + * KCP 결제 요청 URL (테스트용) + */ + private String payUrl = "https://testpay.kcp.co.kr/pay"; + + public void setSiteCd(String siteCd) { + this.siteCd = siteCd; + } + + public void setSiteKey(String siteKey) { + this.siteKey = siteKey; + } + + public void setPayUrl(String payUrl) { + this.payUrl = payUrl; + } +} diff --git a/src/main/java/com/joycrew/backend/controller/AdminCompanySubscriptionController.java b/src/main/java/com/joycrew/backend/controller/AdminCompanySubscriptionController.java new file mode 100644 index 0000000..827b014 --- /dev/null +++ b/src/main/java/com/joycrew/backend/controller/AdminCompanySubscriptionController.java @@ -0,0 +1,43 @@ +package com.joycrew.backend.controller; + +import com.joycrew.backend.dto.SubscriptionRequestDto; +import com.joycrew.backend.entity.CompanySubscriptionPayment; +import com.joycrew.backend.service.CompanySubscriptionService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/admin/companies") +@RequiredArgsConstructor +public class AdminCompanySubscriptionController { + + private final CompanySubscriptionService subscriptionService; + + /** + * 1단계: admin이 “N개월 연장” 신청 → payment row 생성 + */ + @PostMapping("/{companyId}/subscription/payment") + public ResponseEntity createSubscriptionPayment( + @PathVariable Long companyId, + @RequestBody SubscriptionRequestDto request + ) { + CompanySubscriptionPayment payment = + subscriptionService.createPaymentRequest(companyId, request.getMonths()); + return ResponseEntity.ok(payment); + } + + /** + * 2단계: (예시) + * 특정 paymentId에 대해 실제 KCP 결제 요청 & 처리 + * - 실제 서비스에서는 프런트에서 KCP 결제창으로 리다이렉트/스크립트 처리 후 + * callback을 받는 구조로 바꾸면 됨. + */ + @PostMapping("/subscription/payment/{paymentId}/execute") + public ResponseEntity executeSubscriptionPayment( + @PathVariable Long paymentId + ) { + CompanySubscriptionPayment updated = subscriptionService.requestAndProcessPayment(paymentId); + return ResponseEntity.ok(updated); + } +} diff --git a/src/main/java/com/joycrew/backend/dto/SubscriptionRequestDto.java b/src/main/java/com/joycrew/backend/dto/SubscriptionRequestDto.java new file mode 100644 index 0000000..1c6e684 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/SubscriptionRequestDto.java @@ -0,0 +1,13 @@ +package com.joycrew.backend.dto; + +import jakarta.validation.constraints.Min; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class SubscriptionRequestDto { + + @Min(1) + private int months; +} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/entity/Company.java b/src/main/java/com/joycrew/backend/entity/Company.java index b70e563..0093f63 100644 --- a/src/main/java/com/joycrew/backend/entity/Company.java +++ b/src/main/java/com/joycrew/backend/entity/Company.java @@ -27,6 +27,13 @@ public class Company { @Column(nullable = false) private Double totalCompanyBalance; + /** + * 서비스 이용 가능 기한(마지막 날의 23:59:59 같은 느낌) + * null이면 아직 결제/구독 시작 전 상태로 취급 + */ + @Column(name = "subscription_end_at") + private LocalDateTime subscriptionEndAt; + @Builder.Default @OneToMany(mappedBy = "company") private List employees = new ArrayList<>(); @@ -67,6 +74,44 @@ public void spendBudget(double amount) { this.totalCompanyBalance -= amount; } + /** + * 구독 기간 연장 + * - subscriptionEndAt이 미래면 그 날짜 기준으로 연장 + * - 아니면 현재 시간을 기준으로 연장 + * - 연장 후 상태도 자동 업데이트 + */ + public void extendSubscription(int months) { + if (months <= 0) { + throw new IllegalArgumentException("months must be positive"); + } + + LocalDateTime base = + (this.subscriptionEndAt != null && this.subscriptionEndAt.isAfter(LocalDateTime.now())) + ? this.subscriptionEndAt + : LocalDateTime.now(); + + this.subscriptionEndAt = base.plusMonths(months); + + refreshStatusBySubscription(); + } + + /** + * 구독 만료일에 따라 회사 상태 갱신 + */ + public void refreshStatusBySubscription() { + if (this.subscriptionEndAt == null) { + // 최초 결제 전이라면 ACTIVE 유지 (원하면 PENDING 등으로 커스터마이징 가능) + return; + } + + if (this.subscriptionEndAt.isAfter(LocalDateTime.now())) { + this.status = "ACTIVE"; + } else { + this.status = "EXPIRED"; + } + } + + @PrePersist protected void onCreate() { this.createdAt = this.updatedAt = LocalDateTime.now(); @@ -82,4 +127,5 @@ protected void onCreate() { protected void onUpdate() { this.updatedAt = LocalDateTime.now(); } -} \ No newline at end of file + +} diff --git a/src/main/java/com/joycrew/backend/entity/CompanySubscriptionPayment.java b/src/main/java/com/joycrew/backend/entity/CompanySubscriptionPayment.java new file mode 100644 index 0000000..a0f61e6 --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/CompanySubscriptionPayment.java @@ -0,0 +1,80 @@ +package com.joycrew.backend.entity; + +import com.joycrew.backend.entity.enums.SubscriptionPaymentStatus; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "company_subscription_payment") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class CompanySubscriptionPayment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "company_id", nullable = false) + private Company company; + + /** + * 신청 개월 수 + */ + @Column(nullable = false) + private int months; + + /** + * 결제 금액(원) – months * 50_000 + */ + @Column(nullable = false) + private int amount; + + /** + * NHN KCP 측 주문번호(our side에서 만들어 보내는 값) + */ + @Column(nullable = false, unique = true) + private String orderId; + + /** + * KCP 거래번호 (tid 같은 것) + */ + private String transactionId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private SubscriptionPaymentStatus status; + + private LocalDateTime requestedAt; + private LocalDateTime approvedAt; + private LocalDateTime failedAt; + + @Column(length = 1000) + private String failReason; + + @PrePersist + protected void onCreate() { + if (this.requestedAt == null) { + this.requestedAt = LocalDateTime.now(); + } + if (this.status == null) { + this.status = SubscriptionPaymentStatus.REQUESTED; + } + } + + public void markSuccess(String transactionId) { + this.transactionId = transactionId; + this.status = SubscriptionPaymentStatus.SUCCESS; + this.approvedAt = LocalDateTime.now(); + } + + public void markFail(String reason) { + this.status = SubscriptionPaymentStatus.FAILED; + this.failedAt = LocalDateTime.now(); + this.failReason = reason; + } +} diff --git a/src/main/java/com/joycrew/backend/entity/enums/SubscriptionPaymentStatus.java b/src/main/java/com/joycrew/backend/entity/enums/SubscriptionPaymentStatus.java new file mode 100644 index 0000000..e84b252 --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/enums/SubscriptionPaymentStatus.java @@ -0,0 +1,8 @@ +package com.joycrew.backend.entity.enums; + +public enum SubscriptionPaymentStatus { + REQUESTED, + SUCCESS, + FAILED, + CANCELED +} diff --git a/src/main/java/com/joycrew/backend/payment/kcp/KcpClient.java b/src/main/java/com/joycrew/backend/payment/kcp/KcpClient.java new file mode 100644 index 0000000..6315ace --- /dev/null +++ b/src/main/java/com/joycrew/backend/payment/kcp/KcpClient.java @@ -0,0 +1,33 @@ +package com.joycrew.backend.payment.kcp; + +import com.joycrew.backend.config.KcpProperties; +import com.joycrew.backend.payment.kcp.dto.KcpPaymentRequest; +import com.joycrew.backend.payment.kcp.dto.KcpPaymentResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +@Component +@RequiredArgsConstructor +public class KcpClient { + + private final KcpProperties properties; + private final RestTemplate restTemplate = new RestTemplate(); + + public KcpPaymentResponse requestPayment(KcpPaymentRequest request) { + // TODO: NHN KCP 실제 연동 스펙에 맞게 구현 + // 여기서는 "성공했다고 치자" 형태의 stub + + // 예시 형태: + // ResponseEntity response = + // restTemplate.postForEntity(properties.getPayUrl(), request, KcpPaymentResponse.class); + + // 테스트용 더미 응답 + return new KcpPaymentResponse( + true, + "TID-TEST-" + request.getOrderId(), + "0000", + "SUCCESS" + ); + } +} diff --git a/src/main/java/com/joycrew/backend/payment/kcp/dto/KcpPaymentRequest.java b/src/main/java/com/joycrew/backend/payment/kcp/dto/KcpPaymentRequest.java new file mode 100644 index 0000000..0e74507 --- /dev/null +++ b/src/main/java/com/joycrew/backend/payment/kcp/dto/KcpPaymentRequest.java @@ -0,0 +1,22 @@ +package com.joycrew.backend.payment.kcp.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class KcpPaymentRequest { + + private String siteCd; + private String siteKey; + + private String orderId; + private String goodName; + private int amount; // KRW + private String currency; // "410" (KRW) 등 + + private String buyerName; + private String buyerEmail; + + // 기타 KCP가 요구하는 값들.. +} diff --git a/src/main/java/com/joycrew/backend/payment/kcp/dto/KcpPaymentResponse.java b/src/main/java/com/joycrew/backend/payment/kcp/dto/KcpPaymentResponse.java new file mode 100644 index 0000000..b8e5855 --- /dev/null +++ b/src/main/java/com/joycrew/backend/payment/kcp/dto/KcpPaymentResponse.java @@ -0,0 +1,14 @@ +package com.joycrew.backend.payment.kcp.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class KcpPaymentResponse { + + private boolean success; + private String transactionId; + private String resultCode; + private String resultMsg; +} diff --git a/src/main/java/com/joycrew/backend/repository/CompanySubscriptionPaymentRepository.java b/src/main/java/com/joycrew/backend/repository/CompanySubscriptionPaymentRepository.java new file mode 100644 index 0000000..9043ecf --- /dev/null +++ b/src/main/java/com/joycrew/backend/repository/CompanySubscriptionPaymentRepository.java @@ -0,0 +1,10 @@ +package com.joycrew.backend.repository; + +import com.joycrew.backend.entity.CompanySubscriptionPayment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface CompanySubscriptionPaymentRepository + extends JpaRepository { +} diff --git a/src/main/java/com/joycrew/backend/repository/WalletRepository.java b/src/main/java/com/joycrew/backend/repository/WalletRepository.java index 03d35eb..15ee782 100644 --- a/src/main/java/com/joycrew/backend/repository/WalletRepository.java +++ b/src/main/java/com/joycrew/backend/repository/WalletRepository.java @@ -9,7 +9,6 @@ import java.util.Optional; public interface WalletRepository extends JpaRepository { - // ⭐️ 비관적 락 적용 (동시성 문제 해결) @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select w from Wallet w where w.employee.employeeId = :employeeId") Optional findByEmployee_EmployeeIdForUpdate(Long employeeId); diff --git a/src/main/java/com/joycrew/backend/service/CompanySubscriptionService.java b/src/main/java/com/joycrew/backend/service/CompanySubscriptionService.java new file mode 100644 index 0000000..269af2c --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/CompanySubscriptionService.java @@ -0,0 +1,93 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.entity.CompanySubscriptionPayment; +import com.joycrew.backend.entity.enums.SubscriptionPaymentStatus; +import com.joycrew.backend.payment.kcp.KcpClient; +import com.joycrew.backend.payment.kcp.dto.KcpPaymentRequest; +import com.joycrew.backend.payment.kcp.dto.KcpPaymentResponse; +import com.joycrew.backend.repository.CompanyRepository; +import com.joycrew.backend.repository.CompanySubscriptionPaymentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class CompanySubscriptionService { + + private static final int MONTHLY_PRICE = 50_000; + + private final CompanyRepository companyRepository; + private final CompanySubscriptionPaymentRepository paymentRepository; + private final KcpClient kcpClient; + + @Transactional + public CompanySubscriptionPayment createPaymentRequest(Long companyId, int months) { + if (months <= 0) { + throw new IllegalArgumentException("months must be positive"); + } + + Company company = companyRepository.findById(companyId) + .orElseThrow(() -> new IllegalArgumentException("Company not found: " + companyId)); + + int amount = MONTHLY_PRICE * months; + + String orderId = generateOrderId(company); + + CompanySubscriptionPayment payment = CompanySubscriptionPayment.builder() + .company(company) + .months(months) + .amount(amount) + .orderId(orderId) + .status(SubscriptionPaymentStatus.REQUESTED) + .requestedAt(LocalDateTime.now()) + .build(); + + return paymentRepository.save(payment); + } + + /** + * 실제 결제 요청 (KCP 클라이언트 호출) + */ + @Transactional + public CompanySubscriptionPayment requestAndProcessPayment(Long paymentId) { + CompanySubscriptionPayment payment = paymentRepository.findById(paymentId) + .orElseThrow(() -> new IllegalArgumentException("Payment not found: " + paymentId)); + + Company company = payment.getCompany(); + + // 1) KCP 요청 DTO 만들기 + KcpPaymentRequest request = KcpPaymentRequest.builder() + .siteCd("T1234TEST") // properties에서 받아와도 됨 + .siteKey("0123456789ABCDEF0123456789ABCDEF") + .orderId(payment.getOrderId()) + .goodName("JoyCrew Subscription") + .amount(payment.getAmount()) + .currency("410") // KRW + .buyerName(company.getCompanyName()) + .buyerEmail("admin@" + company.getCompanyName() + ".com") // TODO: 실제 admin 이메일 사용 + .build(); + + // 2) KCP 호출 + KcpPaymentResponse response = kcpClient.requestPayment(request); + + // 3) 결과 반영 + if (response.isSuccess()) { + payment.markSuccess(response.getTransactionId()); + // 회사 구독 만료일 연장 + company.extendSubscription(payment.getMonths()); + } else { + payment.markFail(response.getResultCode() + " : " + response.getResultMsg()); + } + + return payment; + } + + private String generateOrderId(Company company) { + return "JOYCREW-" + company.getCompanyId() + "-" + UUID.randomUUID().toString().substring(0, 8); + } +} diff --git a/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java b/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java index a43e6a3..e4ec50f 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java @@ -27,8 +27,7 @@ public class EmployeeQueryService { private final EmployeeMapper employeeMapper; public PagedEmployeeResponse getEmployees(String keyword, int page, int size, Long currentUserId, AdminLevel requesterRole) { - Long tenant = Tenant.id(); // ✅ 테넌트 - + Long tenant = Tenant.id(); StringBuilder where = new StringBuilder("WHERE c.companyId = :tenant AND e.employeeId != :currentUserId "); boolean hasKeyword = StringUtils.hasText(keyword); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d58ed5b..1cd1e24 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -51,3 +51,9 @@ aws: region: 'ap-northeast-2' s3: bucket-name: 'joycrew-prod-bucket' + +payment: + kcp: + site-key: 0123456789ABCDEF0123456789ABCDEF + pay-url: https://testpay.kcp.co.kr/pay + site-cd: T1234TEST \ No newline at end of file From 434072af2f93b85af683ae9923afbb27220e0e9b Mon Sep 17 00:00:00 2001 From: yeoEun Date: Wed, 24 Dec 2025 11:17:32 +0900 Subject: [PATCH 134/135] feat: subscription --- .../joycrew/backend/config/KcpProperties.java | 38 ------ .../AdminCompanySubscriptionController.java | 43 ------- .../controller/SubscriptionController.java | 59 +++++++++ .../backend/dto/IssueBillingKeyRequest.java | 5 + .../joycrew/backend/dto/PagedResponse.java | 12 ++ .../dto/SubscriptionPaymentHistoryItem.java | 36 ++++++ .../SubscriptionPaymentHistoryResponse.java | 11 ++ .../backend/dto/SubscriptionRequestDto.java | 13 -- .../dto/SubscriptionSummaryResponse.java | 10 ++ .../dto/toss/TossIssueBillingKeyResponse.java | 11 ++ .../com/joycrew/backend/entity/Company.java | 119 ++++++++++-------- .../entity/CompanySubscriptionPayment.java | 80 ------------ .../backend/entity/SubscriptionPayment.java | 113 +++++++++++++++++ .../backend/entity/enums/PaymentStatus.java | 7 ++ .../enums/SubscriptionPaymentStatus.java | 8 -- .../backend/payment/kcp/KcpClient.java | 33 ----- .../payment/kcp/dto/KcpPaymentRequest.java | 22 ---- .../payment/kcp/dto/KcpPaymentResponse.java | 14 --- .../backend/repository/CompanyRepository.java | 14 ++- .../CompanySubscriptionPaymentRepository.java | 10 -- .../SubscriptionPaymentRepository.java | 20 +++ .../service/AdminDashboardService.java | 2 +- .../service/CompanySubscriptionService.java | 93 -------------- .../SubscriptionBillingKeyAppService.java | 32 +++++ .../service/SubscriptionBillingScheduler.java | 38 ++++++ .../service/SubscriptionBillingService.java | 94 ++++++++++++++ .../SubscriptionPaymentQueryService.java | 40 ++++++ .../service/SubscriptionQueryService.java | 27 ++++ .../service/TossBillingChargeService.java | 106 ++++++++++++++++ .../service/TossBillingKeyService.java | 57 +++++++++ src/main/resources/application-dev.yml | 7 +- src/main/resources/application.yml | 14 +-- 32 files changed, 770 insertions(+), 418 deletions(-) delete mode 100644 src/main/java/com/joycrew/backend/config/KcpProperties.java delete mode 100644 src/main/java/com/joycrew/backend/controller/AdminCompanySubscriptionController.java create mode 100644 src/main/java/com/joycrew/backend/controller/SubscriptionController.java create mode 100644 src/main/java/com/joycrew/backend/dto/IssueBillingKeyRequest.java create mode 100644 src/main/java/com/joycrew/backend/dto/PagedResponse.java create mode 100644 src/main/java/com/joycrew/backend/dto/SubscriptionPaymentHistoryItem.java create mode 100644 src/main/java/com/joycrew/backend/dto/SubscriptionPaymentHistoryResponse.java delete mode 100644 src/main/java/com/joycrew/backend/dto/SubscriptionRequestDto.java create mode 100644 src/main/java/com/joycrew/backend/dto/SubscriptionSummaryResponse.java create mode 100644 src/main/java/com/joycrew/backend/dto/toss/TossIssueBillingKeyResponse.java delete mode 100644 src/main/java/com/joycrew/backend/entity/CompanySubscriptionPayment.java create mode 100644 src/main/java/com/joycrew/backend/entity/SubscriptionPayment.java create mode 100644 src/main/java/com/joycrew/backend/entity/enums/PaymentStatus.java delete mode 100644 src/main/java/com/joycrew/backend/entity/enums/SubscriptionPaymentStatus.java delete mode 100644 src/main/java/com/joycrew/backend/payment/kcp/KcpClient.java delete mode 100644 src/main/java/com/joycrew/backend/payment/kcp/dto/KcpPaymentRequest.java delete mode 100644 src/main/java/com/joycrew/backend/payment/kcp/dto/KcpPaymentResponse.java delete mode 100644 src/main/java/com/joycrew/backend/repository/CompanySubscriptionPaymentRepository.java create mode 100644 src/main/java/com/joycrew/backend/repository/SubscriptionPaymentRepository.java delete mode 100644 src/main/java/com/joycrew/backend/service/CompanySubscriptionService.java create mode 100644 src/main/java/com/joycrew/backend/service/SubscriptionBillingKeyAppService.java create mode 100644 src/main/java/com/joycrew/backend/service/SubscriptionBillingScheduler.java create mode 100644 src/main/java/com/joycrew/backend/service/SubscriptionBillingService.java create mode 100644 src/main/java/com/joycrew/backend/service/SubscriptionPaymentQueryService.java create mode 100644 src/main/java/com/joycrew/backend/service/SubscriptionQueryService.java create mode 100644 src/main/java/com/joycrew/backend/service/TossBillingChargeService.java create mode 100644 src/main/java/com/joycrew/backend/service/TossBillingKeyService.java diff --git a/src/main/java/com/joycrew/backend/config/KcpProperties.java b/src/main/java/com/joycrew/backend/config/KcpProperties.java deleted file mode 100644 index 26fd46a..0000000 --- a/src/main/java/com/joycrew/backend/config/KcpProperties.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.joycrew.backend.config; - -import lombok.Getter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -@Getter -@Component -@ConfigurationProperties(prefix = "payment.kcp") -public class KcpProperties { - - /** - * 테스트 상점 코드 (예: T0000XXXX) - */ - private String siteCd = "T1234TEST"; - - /** - * 테스트 사이트 키 (임의) - */ - private String siteKey = "0123456789ABCDEF0123456789ABCDEF"; - - /** - * KCP 결제 요청 URL (테스트용) - */ - private String payUrl = "https://testpay.kcp.co.kr/pay"; - - public void setSiteCd(String siteCd) { - this.siteCd = siteCd; - } - - public void setSiteKey(String siteKey) { - this.siteKey = siteKey; - } - - public void setPayUrl(String payUrl) { - this.payUrl = payUrl; - } -} diff --git a/src/main/java/com/joycrew/backend/controller/AdminCompanySubscriptionController.java b/src/main/java/com/joycrew/backend/controller/AdminCompanySubscriptionController.java deleted file mode 100644 index 827b014..0000000 --- a/src/main/java/com/joycrew/backend/controller/AdminCompanySubscriptionController.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.joycrew.backend.controller; - -import com.joycrew.backend.dto.SubscriptionRequestDto; -import com.joycrew.backend.entity.CompanySubscriptionPayment; -import com.joycrew.backend.service.CompanySubscriptionService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/api/admin/companies") -@RequiredArgsConstructor -public class AdminCompanySubscriptionController { - - private final CompanySubscriptionService subscriptionService; - - /** - * 1단계: admin이 “N개월 연장” 신청 → payment row 생성 - */ - @PostMapping("/{companyId}/subscription/payment") - public ResponseEntity createSubscriptionPayment( - @PathVariable Long companyId, - @RequestBody SubscriptionRequestDto request - ) { - CompanySubscriptionPayment payment = - subscriptionService.createPaymentRequest(companyId, request.getMonths()); - return ResponseEntity.ok(payment); - } - - /** - * 2단계: (예시) - * 특정 paymentId에 대해 실제 KCP 결제 요청 & 처리 - * - 실제 서비스에서는 프런트에서 KCP 결제창으로 리다이렉트/스크립트 처리 후 - * callback을 받는 구조로 바꾸면 됨. - */ - @PostMapping("/subscription/payment/{paymentId}/execute") - public ResponseEntity executeSubscriptionPayment( - @PathVariable Long paymentId - ) { - CompanySubscriptionPayment updated = subscriptionService.requestAndProcessPayment(paymentId); - return ResponseEntity.ok(updated); - } -} diff --git a/src/main/java/com/joycrew/backend/controller/SubscriptionController.java b/src/main/java/com/joycrew/backend/controller/SubscriptionController.java new file mode 100644 index 0000000..5c86cb7 --- /dev/null +++ b/src/main/java/com/joycrew/backend/controller/SubscriptionController.java @@ -0,0 +1,59 @@ +package com.joycrew.backend.controller; + +import com.joycrew.backend.dto.IssueBillingKeyRequest; +import com.joycrew.backend.dto.SubscriptionPaymentHistoryResponse; +import com.joycrew.backend.dto.SubscriptionSummaryResponse; +import com.joycrew.backend.dto.SuccessResponse; +import com.joycrew.backend.entity.enums.PaymentStatus; +import com.joycrew.backend.service.SubscriptionBillingKeyAppService; +import com.joycrew.backend.service.SubscriptionPaymentQueryService; +import com.joycrew.backend.service.SubscriptionQueryService; +import com.joycrew.backend.tenant.Tenant; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/admin/subscription") +@RequiredArgsConstructor +public class SubscriptionController { + + private final SubscriptionBillingKeyAppService billingKeyAppService; + private final SubscriptionPaymentQueryService paymentQueryService; + private final SubscriptionQueryService subscriptionQueryService; + + /** ✅ authKey만 받음 */ + @PostMapping("/billing-key/issue") + public ResponseEntity issueBillingKey(@RequestBody IssueBillingKeyRequest req) { + Long companyId = Tenant.id(); + billingKeyAppService.issueAndSaveBillingKey(companyId, req.authKey()); + return ResponseEntity.ok(new SuccessResponse("BillingKey issued and auto-renew enabled")); + } + + /** 구독 해지(자동결제 OFF) */ + @PostMapping("/auto/disable") + public ResponseEntity disableAutoRenew() { + Long companyId = Tenant.id(); + billingKeyAppService.disableAutoRenew(companyId); + return ResponseEntity.ok(new SuccessResponse("Auto-renew disabled")); + } + + /** ✅ 결제 이력 조회(관리자 페이지) */ + @GetMapping("/payments") + public ResponseEntity getPayments( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) PaymentStatus status + ) { + Long companyId = Tenant.id(); + return ResponseEntity.ok(paymentQueryService.getHistory(companyId, page, size, status)); + } + + @GetMapping("/summary") + public ResponseEntity getSubscriptionSummary() { + Long companyId = Tenant.id(); + return ResponseEntity.ok( + subscriptionQueryService.getSubscriptionSummary(companyId) + ); + } +} diff --git a/src/main/java/com/joycrew/backend/dto/IssueBillingKeyRequest.java b/src/main/java/com/joycrew/backend/dto/IssueBillingKeyRequest.java new file mode 100644 index 0000000..90628c3 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/IssueBillingKeyRequest.java @@ -0,0 +1,5 @@ +package com.joycrew.backend.dto; + +public record IssueBillingKeyRequest( + String authKey +) {} diff --git a/src/main/java/com/joycrew/backend/dto/PagedResponse.java b/src/main/java/com/joycrew/backend/dto/PagedResponse.java new file mode 100644 index 0000000..94799a0 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/PagedResponse.java @@ -0,0 +1,12 @@ +package com.joycrew.backend.dto; + +import java.util.List; + +public record PagedResponse( + List content, + int page, + int size, + long totalElements, + int totalPages, + boolean last +) {} diff --git a/src/main/java/com/joycrew/backend/dto/SubscriptionPaymentHistoryItem.java b/src/main/java/com/joycrew/backend/dto/SubscriptionPaymentHistoryItem.java new file mode 100644 index 0000000..c1e06cb --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/SubscriptionPaymentHistoryItem.java @@ -0,0 +1,36 @@ +package com.joycrew.backend.dto; + +import com.joycrew.backend.entity.SubscriptionPayment; +import com.joycrew.backend.entity.enums.PaymentStatus; + +import java.time.LocalDateTime; + +public record SubscriptionPaymentHistoryItem( + Long id, + String orderId, + Long amount, + PaymentStatus status, + LocalDateTime requestedAt, + LocalDateTime approvedAt, + LocalDateTime periodStartAt, + LocalDateTime periodEndAt, + String tossPaymentKey, + String failCode, + String failMessage +) { + public static SubscriptionPaymentHistoryItem from(SubscriptionPayment p) { + return new SubscriptionPaymentHistoryItem( + p.getId(), + p.getOrderId(), + Long.valueOf(p.getAmount()), + p.getStatus(), + p.getRequestedAt(), + p.getApprovedAt(), + p.getPeriodStartAt(), + p.getPeriodEndAt(), + p.getTossPaymentKey(), + p.getFailCode(), + p.getFailMessage() + ); + } +} diff --git a/src/main/java/com/joycrew/backend/dto/SubscriptionPaymentHistoryResponse.java b/src/main/java/com/joycrew/backend/dto/SubscriptionPaymentHistoryResponse.java new file mode 100644 index 0000000..6dfca89 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/SubscriptionPaymentHistoryResponse.java @@ -0,0 +1,11 @@ +package com.joycrew.backend.dto; + +import java.util.List; + +public record SubscriptionPaymentHistoryResponse( + List items, + int page, + int size, + long totalElements, + int totalPages +) {} diff --git a/src/main/java/com/joycrew/backend/dto/SubscriptionRequestDto.java b/src/main/java/com/joycrew/backend/dto/SubscriptionRequestDto.java deleted file mode 100644 index 1c6e684..0000000 --- a/src/main/java/com/joycrew/backend/dto/SubscriptionRequestDto.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.joycrew.backend.dto; - -import jakarta.validation.constraints.Min; -import lombok.Getter; -import lombok.Setter; - -@Getter -@Setter -public class SubscriptionRequestDto { - - @Min(1) - private int months; -} \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/dto/SubscriptionSummaryResponse.java b/src/main/java/com/joycrew/backend/dto/SubscriptionSummaryResponse.java new file mode 100644 index 0000000..1396ecf --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/SubscriptionSummaryResponse.java @@ -0,0 +1,10 @@ +package com.joycrew.backend.dto; + +import java.time.LocalDateTime; + +public record SubscriptionSummaryResponse( + LocalDateTime subscriptionStartAt, // 가입일 + LocalDateTime nextBillingAt, // 결제 예정일 + boolean autoRenew, + String status +) {} diff --git a/src/main/java/com/joycrew/backend/dto/toss/TossIssueBillingKeyResponse.java b/src/main/java/com/joycrew/backend/dto/toss/TossIssueBillingKeyResponse.java new file mode 100644 index 0000000..6f5b585 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/toss/TossIssueBillingKeyResponse.java @@ -0,0 +1,11 @@ +package com.joycrew.backend.dto.toss; + +public record TossIssueBillingKeyResponse( + String mId, + String customerKey, + String billingKey, + String authenticatedAt, + String cardCompany, + String cardNumber, + String cardType +) {} diff --git a/src/main/java/com/joycrew/backend/entity/Company.java b/src/main/java/com/joycrew/backend/entity/Company.java index e9d38e2..7217248 100644 --- a/src/main/java/com/joycrew/backend/entity/Company.java +++ b/src/main/java/com/joycrew/backend/entity/Company.java @@ -27,13 +27,6 @@ public class Company { @Column(nullable = false) private Double totalCompanyBalance; - /** - * 서비스 이용 가능 기한(마지막 날의 23:59:59 같은 느낌) - * null이면 아직 결제/구독 시작 전 상태로 취급 - */ - @Column(name = "subscription_end_at") - private LocalDateTime subscriptionEndAt; - @Builder.Default @OneToMany(mappedBy = "company") private List employees = new ArrayList<>(); @@ -46,80 +39,98 @@ public class Company { @OneToMany(mappedBy = "company", cascade = CascadeType.ALL, orphanRemoval = true) private List adminAccessList = new ArrayList<>(); + // ========================= + // Subscription fields + // ========================= + + @Column(name = "subscription_end_at") + private LocalDateTime subscriptionEndAt; + + private boolean autoRenew; + + @Column(name = "toss_billing_key") + private String tossBillingKey; + + @Column(name = "toss_customer_key") + private String tossCustomerKey; + private LocalDateTime createdAt; private LocalDateTime updatedAt; - public void changeName(String newCompanyName) { - this.companyName = newCompanyName; - } - - public void changeStatus(String newStatus) { - this.status = newStatus; - } + // ----------------------- + // Budget Logic (기존) + // ----------------------- public void addBudget(double amount) { - if (amount < 0) { - throw new IllegalArgumentException("Budget amount cannot be negative."); - } + if (amount < 0) throw new IllegalArgumentException("Budget amount cannot be negative."); + if (this.totalCompanyBalance == null) this.totalCompanyBalance = 0.0; this.totalCompanyBalance += amount; } public void spendBudget(double amount) { - if (amount < 0) { - throw new IllegalArgumentException("Amount to spend cannot be negative."); - } + if (amount < 0) throw new IllegalArgumentException("Amount to spend cannot be negative."); + if (this.totalCompanyBalance == null) this.totalCompanyBalance = 0.0; if (this.totalCompanyBalance < amount) { throw new InsufficientPointsException("The company does not have enough budget to distribute the points."); } this.totalCompanyBalance -= amount; } - /** - * 구독 기간 연장 - * - subscriptionEndAt이 미래면 그 날짜 기준으로 연장 - * - 아니면 현재 시간을 기준으로 연장 - * - 연장 후 상태도 자동 업데이트 - */ - public void extendSubscription(int months) { - if (months <= 0) { - throw new IllegalArgumentException("months must be positive"); - } - - LocalDateTime base = - (this.subscriptionEndAt != null && this.subscriptionEndAt.isAfter(LocalDateTime.now())) - ? this.subscriptionEndAt - : LocalDateTime.now(); + public Double getTotalCompanyBalance() { + return this.totalCompanyBalance == null ? 0.0 : this.totalCompanyBalance; + } - this.subscriptionEndAt = base.plusMonths(months); + // ----------------------- + // Subscription Logic + // ----------------------- - refreshStatusBySubscription(); + /** billingKey 저장 + autoRenew ON */ + public void registerBillingKeyAndEnableAutoRenew(String billingKey, String customerKey) { + this.autoRenew = true; + this.tossBillingKey = billingKey; + this.tossCustomerKey = customerKey; } - /** - * 구독 만료일에 따라 회사 상태 갱신 - */ - public void refreshStatusBySubscription() { + /** ✅ 최초 카드등록 시점 기준으로 만료일을 now+1개월로 세팅 (이미 있으면 절대 덮어쓰지 않음) */ + public void initializeSubscriptionEndAtIfFirstTime() { if (this.subscriptionEndAt == null) { - // 최초 결제 전이라면 ACTIVE 유지 (원하면 PENDING 등으로 커스터마이징 가능) - return; - } - - if (this.subscriptionEndAt.isAfter(LocalDateTime.now())) { + this.subscriptionEndAt = LocalDateTime.now().plusMonths(1); this.status = "ACTIVE"; - } else { - this.status = "EXPIRED"; } } + /** 자동갱신 해지 */ + public void disableAutoRenew() { + this.autoRenew = false; + // 보안상 권장: 해지하면 키도 제거 (원하면 아래 2줄 삭제) + this.tossBillingKey = null; + this.tossCustomerKey = null; + } + + public boolean canAutoBill() { + return autoRenew && tossBillingKey != null && subscriptionEndAt != null; + } + + /** 결제 성공 시 구독 연장 */ + public void extendSubscription(int months) { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime base = + (subscriptionEndAt != null && subscriptionEndAt.isAfter(now)) + ? subscriptionEndAt + : now; + subscriptionEndAt = base.plusMonths(months); + status = "ACTIVE"; + } + + public void markFailed() { + status = "PAYMENT_FAILED"; + } + @PrePersist protected void onCreate() { this.createdAt = this.updatedAt = LocalDateTime.now(); - if (this.totalCompanyBalance == null) { - this.totalCompanyBalance = 0.0; - } - if (this.status == null) { - this.status = "ACTIVE"; - } + if (this.totalCompanyBalance == null) this.totalCompanyBalance = 0.0; + if (this.status == null) this.status = "ACTIVE"; } @PreUpdate diff --git a/src/main/java/com/joycrew/backend/entity/CompanySubscriptionPayment.java b/src/main/java/com/joycrew/backend/entity/CompanySubscriptionPayment.java deleted file mode 100644 index a0f61e6..0000000 --- a/src/main/java/com/joycrew/backend/entity/CompanySubscriptionPayment.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.joycrew.backend.entity; - -import com.joycrew.backend.entity.enums.SubscriptionPaymentStatus; -import jakarta.persistence.*; -import lombok.*; - -import java.time.LocalDateTime; - -@Entity -@Table(name = "company_subscription_payment") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -@Builder -public class CompanySubscriptionPayment { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "company_id", nullable = false) - private Company company; - - /** - * 신청 개월 수 - */ - @Column(nullable = false) - private int months; - - /** - * 결제 금액(원) – months * 50_000 - */ - @Column(nullable = false) - private int amount; - - /** - * NHN KCP 측 주문번호(our side에서 만들어 보내는 값) - */ - @Column(nullable = false, unique = true) - private String orderId; - - /** - * KCP 거래번호 (tid 같은 것) - */ - private String transactionId; - - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private SubscriptionPaymentStatus status; - - private LocalDateTime requestedAt; - private LocalDateTime approvedAt; - private LocalDateTime failedAt; - - @Column(length = 1000) - private String failReason; - - @PrePersist - protected void onCreate() { - if (this.requestedAt == null) { - this.requestedAt = LocalDateTime.now(); - } - if (this.status == null) { - this.status = SubscriptionPaymentStatus.REQUESTED; - } - } - - public void markSuccess(String transactionId) { - this.transactionId = transactionId; - this.status = SubscriptionPaymentStatus.SUCCESS; - this.approvedAt = LocalDateTime.now(); - } - - public void markFail(String reason) { - this.status = SubscriptionPaymentStatus.FAILED; - this.failedAt = LocalDateTime.now(); - this.failReason = reason; - } -} diff --git a/src/main/java/com/joycrew/backend/entity/SubscriptionPayment.java b/src/main/java/com/joycrew/backend/entity/SubscriptionPayment.java new file mode 100644 index 0000000..f930437 --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/SubscriptionPayment.java @@ -0,0 +1,113 @@ +package com.joycrew.backend.entity; + +import com.joycrew.backend.entity.enums.PaymentStatus; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table( + name = "subscription_payment", + indexes = { + @Index(name = "idx_subpay_company_requested", columnList = "company_id, requested_at"), + @Index(name = "idx_subpay_company_paid", columnList = "company_id, approved_at"), + @Index(name = "idx_subpay_order", columnList = "order_id", unique = true) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SubscriptionPayment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** 결제 대상 회사 */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "company_id", nullable = false) + private Company company; + + /** 우리쪽 주문 ID (멱등성/조회 기준) */ + @Column(name = "order_id", nullable = false, unique = true, length = 80) + private String orderId; + + /** 토스 paymentKey (성공 시 응답에 존재) */ + @Column(name = "toss_payment_key", length = 200) + private String tossPaymentKey; + + /** 금액 */ + @Column(nullable = false) + private long amount; + + /** 이번 결제가 커버하는 구독 기간 */ + @Column(name = "period_start_at", nullable = false) + private LocalDateTime periodStartAt; + + @Column(name = "period_end_at", nullable = false) + private LocalDateTime periodEndAt; + + /** 결제 요청 시각 */ + @Column(name = "requested_at", nullable = false) + private LocalDateTime requestedAt; + + /** 승인 시각 */ + @Column(name = "approved_at") + private LocalDateTime approvedAt; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PaymentStatus status; + + /** 실패 사유 */ + @Column(name = "fail_reason", length = 500) + private String failReason; + + /** 토스 응답 raw */ + @Lob + @Column(name = "raw_response") + private String rawResponse; + + public static SubscriptionPayment pending(Company company, + String orderId, + long amount, + LocalDateTime periodStartAt, + LocalDateTime periodEndAt, + LocalDateTime requestedAt) { + SubscriptionPayment p = new SubscriptionPayment(); + p.company = company; + p.orderId = orderId; + p.amount = amount; + p.periodStartAt = periodStartAt; + p.periodEndAt = periodEndAt; + p.requestedAt = requestedAt; + p.status = PaymentStatus.PENDING; + return p; + } + + public void markSuccess(String tossPaymentKey, LocalDateTime approvedAt, String rawResponse) { + this.status = PaymentStatus.SUCCESS; + this.tossPaymentKey = tossPaymentKey; + this.approvedAt = approvedAt; + this.failReason = null; + this.rawResponse = rawResponse; + } + + public void markFailed(String reason, String rawResponse) { + this.status = PaymentStatus.FAILED; + this.failReason = reason; + this.rawResponse = rawResponse; + } + + // ====================== + // DTO/프론트 호환 getter + // ====================== + + public String getFailCode() { + return null; // 지금은 코드 저장 안 하므로 null (추후 확장) + } + + public String getFailMessage() { + return this.failReason; + } +} diff --git a/src/main/java/com/joycrew/backend/entity/enums/PaymentStatus.java b/src/main/java/com/joycrew/backend/entity/enums/PaymentStatus.java new file mode 100644 index 0000000..2b96ab9 --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/enums/PaymentStatus.java @@ -0,0 +1,7 @@ +package com.joycrew.backend.entity.enums; + +public enum PaymentStatus { + PENDING, + SUCCESS, + FAILED +} diff --git a/src/main/java/com/joycrew/backend/entity/enums/SubscriptionPaymentStatus.java b/src/main/java/com/joycrew/backend/entity/enums/SubscriptionPaymentStatus.java deleted file mode 100644 index e84b252..0000000 --- a/src/main/java/com/joycrew/backend/entity/enums/SubscriptionPaymentStatus.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.joycrew.backend.entity.enums; - -public enum SubscriptionPaymentStatus { - REQUESTED, - SUCCESS, - FAILED, - CANCELED -} diff --git a/src/main/java/com/joycrew/backend/payment/kcp/KcpClient.java b/src/main/java/com/joycrew/backend/payment/kcp/KcpClient.java deleted file mode 100644 index 6315ace..0000000 --- a/src/main/java/com/joycrew/backend/payment/kcp/KcpClient.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.joycrew.backend.payment.kcp; - -import com.joycrew.backend.config.KcpProperties; -import com.joycrew.backend.payment.kcp.dto.KcpPaymentRequest; -import com.joycrew.backend.payment.kcp.dto.KcpPaymentResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; - -@Component -@RequiredArgsConstructor -public class KcpClient { - - private final KcpProperties properties; - private final RestTemplate restTemplate = new RestTemplate(); - - public KcpPaymentResponse requestPayment(KcpPaymentRequest request) { - // TODO: NHN KCP 실제 연동 스펙에 맞게 구현 - // 여기서는 "성공했다고 치자" 형태의 stub - - // 예시 형태: - // ResponseEntity response = - // restTemplate.postForEntity(properties.getPayUrl(), request, KcpPaymentResponse.class); - - // 테스트용 더미 응답 - return new KcpPaymentResponse( - true, - "TID-TEST-" + request.getOrderId(), - "0000", - "SUCCESS" - ); - } -} diff --git a/src/main/java/com/joycrew/backend/payment/kcp/dto/KcpPaymentRequest.java b/src/main/java/com/joycrew/backend/payment/kcp/dto/KcpPaymentRequest.java deleted file mode 100644 index 0e74507..0000000 --- a/src/main/java/com/joycrew/backend/payment/kcp/dto/KcpPaymentRequest.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.joycrew.backend.payment.kcp.dto; - -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -public class KcpPaymentRequest { - - private String siteCd; - private String siteKey; - - private String orderId; - private String goodName; - private int amount; // KRW - private String currency; // "410" (KRW) 등 - - private String buyerName; - private String buyerEmail; - - // 기타 KCP가 요구하는 값들.. -} diff --git a/src/main/java/com/joycrew/backend/payment/kcp/dto/KcpPaymentResponse.java b/src/main/java/com/joycrew/backend/payment/kcp/dto/KcpPaymentResponse.java deleted file mode 100644 index b8e5855..0000000 --- a/src/main/java/com/joycrew/backend/payment/kcp/dto/KcpPaymentResponse.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.joycrew.backend.payment.kcp.dto; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public class KcpPaymentResponse { - - private boolean success; - private String transactionId; - private String resultCode; - private String resultMsg; -} diff --git a/src/main/java/com/joycrew/backend/repository/CompanyRepository.java b/src/main/java/com/joycrew/backend/repository/CompanyRepository.java index e00083e..fa2e088 100644 --- a/src/main/java/com/joycrew/backend/repository/CompanyRepository.java +++ b/src/main/java/com/joycrew/backend/repository/CompanyRepository.java @@ -2,9 +2,21 @@ import com.joycrew.backend.entity.Company; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; public interface CompanyRepository extends JpaRepository { + Optional findByCompanyName(String name); -} \ No newline at end of file + @Query(""" + SELECT c FROM Company c + WHERE c.autoRenew = true + AND c.tossBillingKey IS NOT NULL + AND c.subscriptionEndAt IS NOT NULL + AND c.subscriptionEndAt <= :now + """) + List findAutoBillingTargets(LocalDateTime now); +} diff --git a/src/main/java/com/joycrew/backend/repository/CompanySubscriptionPaymentRepository.java b/src/main/java/com/joycrew/backend/repository/CompanySubscriptionPaymentRepository.java deleted file mode 100644 index 9043ecf..0000000 --- a/src/main/java/com/joycrew/backend/repository/CompanySubscriptionPaymentRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.joycrew.backend.repository; - -import com.joycrew.backend.entity.CompanySubscriptionPayment; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -@Repository -public interface CompanySubscriptionPaymentRepository - extends JpaRepository { -} diff --git a/src/main/java/com/joycrew/backend/repository/SubscriptionPaymentRepository.java b/src/main/java/com/joycrew/backend/repository/SubscriptionPaymentRepository.java new file mode 100644 index 0000000..cf874f9 --- /dev/null +++ b/src/main/java/com/joycrew/backend/repository/SubscriptionPaymentRepository.java @@ -0,0 +1,20 @@ +package com.joycrew.backend.repository; + +import com.joycrew.backend.entity.SubscriptionPayment; +import com.joycrew.backend.entity.enums.PaymentStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface SubscriptionPaymentRepository extends JpaRepository { + + Optional findByOrderId(String orderId); + + boolean existsByOrderId(String orderId); + + Page findByCompany_CompanyIdOrderByRequestedAtDesc(Long companyId, Pageable pageable); + + Page findByCompany_CompanyIdAndStatusOrderByRequestedAtDesc(Long companyId, PaymentStatus status, Pageable pageable); +} diff --git a/src/main/java/com/joycrew/backend/service/AdminDashboardService.java b/src/main/java/com/joycrew/backend/service/AdminDashboardService.java index 0ce0a50..5ac29fc 100644 --- a/src/main/java/com/joycrew/backend/service/AdminDashboardService.java +++ b/src/main/java/com/joycrew/backend/service/AdminDashboardService.java @@ -40,7 +40,7 @@ public AdminPointBudgetResponse getAdminAndCompanyBalance(String adminEmail) { // 3. Create and return the combined response DTO return new AdminPointBudgetResponse( - company.getTotalCompanyBalance(), + (double)company.getTotalCompanyBalance(), adminWallet.getBalance(), adminWallet.getGiftablePoint() ); diff --git a/src/main/java/com/joycrew/backend/service/CompanySubscriptionService.java b/src/main/java/com/joycrew/backend/service/CompanySubscriptionService.java deleted file mode 100644 index 269af2c..0000000 --- a/src/main/java/com/joycrew/backend/service/CompanySubscriptionService.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.joycrew.backend.service; - -import com.joycrew.backend.entity.Company; -import com.joycrew.backend.entity.CompanySubscriptionPayment; -import com.joycrew.backend.entity.enums.SubscriptionPaymentStatus; -import com.joycrew.backend.payment.kcp.KcpClient; -import com.joycrew.backend.payment.kcp.dto.KcpPaymentRequest; -import com.joycrew.backend.payment.kcp.dto.KcpPaymentResponse; -import com.joycrew.backend.repository.CompanyRepository; -import com.joycrew.backend.repository.CompanySubscriptionPaymentRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.UUID; - -@Service -@RequiredArgsConstructor -public class CompanySubscriptionService { - - private static final int MONTHLY_PRICE = 50_000; - - private final CompanyRepository companyRepository; - private final CompanySubscriptionPaymentRepository paymentRepository; - private final KcpClient kcpClient; - - @Transactional - public CompanySubscriptionPayment createPaymentRequest(Long companyId, int months) { - if (months <= 0) { - throw new IllegalArgumentException("months must be positive"); - } - - Company company = companyRepository.findById(companyId) - .orElseThrow(() -> new IllegalArgumentException("Company not found: " + companyId)); - - int amount = MONTHLY_PRICE * months; - - String orderId = generateOrderId(company); - - CompanySubscriptionPayment payment = CompanySubscriptionPayment.builder() - .company(company) - .months(months) - .amount(amount) - .orderId(orderId) - .status(SubscriptionPaymentStatus.REQUESTED) - .requestedAt(LocalDateTime.now()) - .build(); - - return paymentRepository.save(payment); - } - - /** - * 실제 결제 요청 (KCP 클라이언트 호출) - */ - @Transactional - public CompanySubscriptionPayment requestAndProcessPayment(Long paymentId) { - CompanySubscriptionPayment payment = paymentRepository.findById(paymentId) - .orElseThrow(() -> new IllegalArgumentException("Payment not found: " + paymentId)); - - Company company = payment.getCompany(); - - // 1) KCP 요청 DTO 만들기 - KcpPaymentRequest request = KcpPaymentRequest.builder() - .siteCd("T1234TEST") // properties에서 받아와도 됨 - .siteKey("0123456789ABCDEF0123456789ABCDEF") - .orderId(payment.getOrderId()) - .goodName("JoyCrew Subscription") - .amount(payment.getAmount()) - .currency("410") // KRW - .buyerName(company.getCompanyName()) - .buyerEmail("admin@" + company.getCompanyName() + ".com") // TODO: 실제 admin 이메일 사용 - .build(); - - // 2) KCP 호출 - KcpPaymentResponse response = kcpClient.requestPayment(request); - - // 3) 결과 반영 - if (response.isSuccess()) { - payment.markSuccess(response.getTransactionId()); - // 회사 구독 만료일 연장 - company.extendSubscription(payment.getMonths()); - } else { - payment.markFail(response.getResultCode() + " : " + response.getResultMsg()); - } - - return payment; - } - - private String generateOrderId(Company company) { - return "JOYCREW-" + company.getCompanyId() + "-" + UUID.randomUUID().toString().substring(0, 8); - } -} diff --git a/src/main/java/com/joycrew/backend/service/SubscriptionBillingKeyAppService.java b/src/main/java/com/joycrew/backend/service/SubscriptionBillingKeyAppService.java new file mode 100644 index 0000000..d6ea608 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/SubscriptionBillingKeyAppService.java @@ -0,0 +1,32 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.repository.CompanyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class SubscriptionBillingKeyAppService { + + private final CompanyRepository companyRepository; + private final TossBillingKeyService tossBillingKeyService; + + @Transactional + public void issueAndSaveBillingKey(Long companyId, String authKey) { + Company company = companyRepository.findById(companyId).orElseThrow(); + + String customerKey = "company_" + companyId; + String billingKey = tossBillingKeyService.issueBillingKey(authKey, customerKey); + + company.registerBillingKeyAndEnableAutoRenew(billingKey, customerKey); + company.initializeSubscriptionEndAtIfFirstTime(); // ✅ now + 1개월 + } + + @Transactional + public void disableAutoRenew(Long companyId) { + Company company = companyRepository.findById(companyId).orElseThrow(); + company.disableAutoRenew(); + } +} diff --git a/src/main/java/com/joycrew/backend/service/SubscriptionBillingScheduler.java b/src/main/java/com/joycrew/backend/service/SubscriptionBillingScheduler.java new file mode 100644 index 0000000..45fb6b6 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/SubscriptionBillingScheduler.java @@ -0,0 +1,38 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.repository.CompanyRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SubscriptionBillingScheduler { + + private final CompanyRepository companyRepository; + private final SubscriptionBillingService subscriptionBillingService; + + @Scheduled(cron = "0 0 3 * * *") + public void autoBillingJob() { + LocalDateTime now = LocalDateTime.now(); + List targets = companyRepository.findAutoBillingTargets(now); + + log.info("[AUTO-BILLING] started at {}, targets={}", now, targets.size()); + + for (Company c : targets) { + try { + subscriptionBillingService.billCompany(c.getCompanyId()); + } catch (Exception e) { + log.error("[AUTO-BILL-ERROR] companyId={} error={}", c.getCompanyId(), e.getMessage(), e); + } + } + + log.info("[AUTO-BILLING] finished at {}", LocalDateTime.now()); + } +} diff --git a/src/main/java/com/joycrew/backend/service/SubscriptionBillingService.java b/src/main/java/com/joycrew/backend/service/SubscriptionBillingService.java new file mode 100644 index 0000000..d5e185f --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/SubscriptionBillingService.java @@ -0,0 +1,94 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.entity.SubscriptionPayment; +import com.joycrew.backend.repository.CompanyRepository; +import com.joycrew.backend.repository.SubscriptionPaymentRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SubscriptionBillingService { + + private final CompanyRepository companyRepository; + private final SubscriptionPaymentRepository paymentRepository; + private final TossBillingChargeService tossBillingChargeService; + + @Value("${subscription.monthly-price}") + private long monthlyPrice; // ✅ 정확한 amount 저장용 + + private static final DateTimeFormatter ORDER_FMT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + @Transactional + public void billCompany(Long companyId) { + Company company = companyRepository.findById(companyId).orElseThrow(); + + if (!company.canAutoBill()) { + log.warn("[AUTO-BILL-SKIP] companyId={} cannot auto bill", companyId); + return; + } + + LocalDateTime now = LocalDateTime.now(); + + // 이번 결제가 커버할 구독 기간: (현재 subscriptionEndAt) ~ +1month + LocalDateTime periodStart = (company.getSubscriptionEndAt() != null) ? company.getSubscriptionEndAt() : now; + LocalDateTime periodEnd = periodStart.plusMonths(1); + + // ✅ 멱등성 orderId: company + periodStart 기준 고정 + String orderId = generateOrderId(companyId, periodStart); + + // 이미 성공 이력이 있으면 재결제 방지 + paymentRepository.findByOrderId(orderId).ifPresent(existing -> { + if (existing.getStatus().name().equals("SUCCESS")) { + log.info("[AUTO-BILL-SKIP] already success orderId={}", orderId); + return; + } + }); + + // ✅ PENDING 생성(없으면) + amount 정확히 저장 + SubscriptionPayment payment = paymentRepository.findByOrderId(orderId) + .orElseGet(() -> paymentRepository.save( + SubscriptionPayment.pending( + company, + orderId, + monthlyPrice, // ✅ 여기! + periodStart, + periodEnd, + now + ) + )); + + // Toss 결제 호출 + TossBillingChargeService.TossChargeResult result = + tossBillingChargeService.charge(company, orderId); + + if (result.success()) { + payment.markSuccess(result.paymentKey(), result.approvedAt(), result.rawResponse()); + company.extendSubscription(1); // ✅ 성공 시 구독 연장 + + log.info("[AUTO-BILL-SUCCESS] companyId={}, orderId={}, newEndAt={}", + companyId, orderId, company.getSubscriptionEndAt()); + } else { + payment.markFailed( + (result.failCode() != null ? result.failCode() : "FAILED"), + result.rawResponse() + ); + company.markFailed(); + + log.error("[AUTO-BILL-FAILED] companyId={}, orderId={}, code={}, msg={}", + companyId, orderId, result.failCode(), result.failMessage()); + } + } + + private String generateOrderId(Long companyId, LocalDateTime periodStart) { + return "SUB_" + companyId + "_" + periodStart.format(ORDER_FMT); + } +} diff --git a/src/main/java/com/joycrew/backend/service/SubscriptionPaymentQueryService.java b/src/main/java/com/joycrew/backend/service/SubscriptionPaymentQueryService.java new file mode 100644 index 0000000..e9385d6 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/SubscriptionPaymentQueryService.java @@ -0,0 +1,40 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.SubscriptionPaymentHistoryItem; +import com.joycrew.backend.dto.SubscriptionPaymentHistoryResponse; +import com.joycrew.backend.entity.SubscriptionPayment; +import com.joycrew.backend.entity.enums.PaymentStatus; +import com.joycrew.backend.repository.SubscriptionPaymentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SubscriptionPaymentQueryService { + + private final SubscriptionPaymentRepository subscriptionPaymentRepository; + + public SubscriptionPaymentHistoryResponse getHistory(Long companyId, int page, int size, PaymentStatus status) { + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "requestedAt")); + + Page result = (status == null) + ? subscriptionPaymentRepository.findByCompany_CompanyIdOrderByRequestedAtDesc(companyId, pageable) + : subscriptionPaymentRepository.findByCompany_CompanyIdAndStatusOrderByRequestedAtDesc(companyId, status, pageable); + + List items = + result.getContent().stream().map(SubscriptionPaymentHistoryItem::from).toList(); + + return new SubscriptionPaymentHistoryResponse( + items, + result.getNumber(), + result.getSize(), + result.getTotalElements(), + result.getTotalPages() + ); + } +} diff --git a/src/main/java/com/joycrew/backend/service/SubscriptionQueryService.java b/src/main/java/com/joycrew/backend/service/SubscriptionQueryService.java new file mode 100644 index 0000000..82cbc09 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/SubscriptionQueryService.java @@ -0,0 +1,27 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.SubscriptionSummaryResponse; +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.repository.CompanyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SubscriptionQueryService { + + private final CompanyRepository companyRepository; + + public SubscriptionSummaryResponse getSubscriptionSummary(Long companyId) { + Company company = companyRepository.findById(companyId).orElseThrow(); + + return new SubscriptionSummaryResponse( + company.getCreatedAt(), // 가입일 + company.getSubscriptionEndAt(),// 다음 결제 예정일 + company.isAutoRenew(), + company.getStatus() + ); + } +} diff --git a/src/main/java/com/joycrew/backend/service/TossBillingChargeService.java b/src/main/java/com/joycrew/backend/service/TossBillingChargeService.java new file mode 100644 index 0000000..5863ff8 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/TossBillingChargeService.java @@ -0,0 +1,106 @@ +package com.joycrew.backend.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.joycrew.backend.entity.Company; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TossBillingChargeService { + + @Value("${toss.secret-key}") + private String secretKey; + + @Value("${subscription.monthly-price}") + private long monthlyPrice; + + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final String BILLING_URL = + "https://api.tosspayments.com/v1/billing/{billingKey}"; + + public TossChargeResult charge(Company company, String orderId) { + String billingKey = company.getTossBillingKey(); + String customerKey = company.getTossCustomerKey(); + + if (billingKey == null || customerKey == null) { + throw new IllegalStateException("Company has no billingKey/customerKey"); + } + + String auth = Base64.getEncoder().encodeToString((secretKey + ":") + .getBytes(StandardCharsets.UTF_8)); + + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Basic " + auth); + headers.setContentType(MediaType.APPLICATION_JSON); + + Map body = new HashMap<>(); + body.put("customerKey", customerKey); + body.put("orderId", orderId); + body.put("amount", monthlyPrice); + body.put("orderName", "JoyCrew 월 구독"); + + HttpEntity> entity = new HttpEntity<>(body, headers); + + ResponseEntity response = + restTemplate.postForEntity(BILLING_URL, entity, String.class, billingKey); + + String raw = response.getBody(); + + if (!response.getStatusCode().is2xxSuccessful()) { + return TossChargeResult.failed( + "HTTP_" + response.getStatusCode().value(), + "Billing failed", + raw + ); + } + + String paymentKey = null; + LocalDateTime approvedAt = null; + + try { + if (raw != null) { + JsonNode node = objectMapper.readTree(raw); + if (node.hasNonNull("paymentKey")) paymentKey = node.get("paymentKey").asText(); + if (node.hasNonNull("approvedAt")) { + approvedAt = LocalDateTime.parse(node.get("approvedAt").asText().replace("Z", "")); + } + } + } catch (Exception e) { + log.warn("[TOSS-PARSE-WARN] cannot parse billing response. raw={}", raw); + } + + return TossChargeResult.success(paymentKey, approvedAt, raw); + } + + public record TossChargeResult( + boolean success, + String paymentKey, + LocalDateTime approvedAt, + String failCode, + String failMessage, + String rawResponse + ) { + public static TossChargeResult success(String paymentKey, LocalDateTime approvedAt, String raw) { + return new TossChargeResult(true, paymentKey, approvedAt, null, null, raw); + } + + public static TossChargeResult failed(String failCode, String failMessage, String raw) { + return new TossChargeResult(false, null, null, failCode, failMessage, raw); + } + } +} diff --git a/src/main/java/com/joycrew/backend/service/TossBillingKeyService.java b/src/main/java/com/joycrew/backend/service/TossBillingKeyService.java new file mode 100644 index 0000000..9d4d81d --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/TossBillingKeyService.java @@ -0,0 +1,57 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.toss.TossIssueBillingKeyResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TossBillingKeyService { + + @Value("${toss.secret-key}") + private String secretKey; + + private final RestTemplate restTemplate = new RestTemplate(); + + private static final String ISSUE_URL = + "https://api.tosspayments.com/v1/billing/authorizations/issue"; // 공식 엔드포인트 :contentReference[oaicite:3]{index=3} + + public String issueBillingKey(String authKey, String customerKey) { + String auth = Base64.getEncoder().encodeToString((secretKey + ":") + .getBytes(StandardCharsets.UTF_8)); + + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Basic " + auth); + headers.setContentType(MediaType.APPLICATION_JSON); + + Map body = new HashMap<>(); + body.put("authKey", authKey); + body.put("customerKey", customerKey); + + HttpEntity> entity = new HttpEntity<>(body, headers); + + ResponseEntity response = + restTemplate.postForEntity(ISSUE_URL, entity, TossIssueBillingKeyResponse.class); + + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + throw new IllegalStateException("Issue billingKey failed"); + } + + String billingKey = response.getBody().billingKey(); + if (billingKey == null || billingKey.isBlank()) { + throw new IllegalStateException("billingKey is empty"); + } + + return billingKey; + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 6a3feec..88b9281 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -71,4 +71,9 @@ kakao: joycrew: points: - krw_per_point: ${JOYCREW_POINTS_KRW_PER_POINT:40} \ No newline at end of file + krw_per_point: ${JOYCREW_POINTS_KRW_PER_POINT:40} + +management: + health: + mail: + enabled: false \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5ca0bd2..5b3dbed 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -52,12 +52,6 @@ aws: s3: bucket-name: 'joycrew-prod-bucket' -payment: - kcp: - site-key: 0123456789ABCDEF0123456789ABCDEF - pay-url: https://testpay.kcp.co.kr/pay - site-cd: T1234TEST - management: endpoints: web: @@ -65,4 +59,10 @@ management: include: health, info, prometheus metrics: tags: - application: joycrew-backend \ No newline at end of file + application: joycrew-backend + +toss: + secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 + +subscription: + monthly-price: 50000 From ddfeeb84865679f20a78a96f2d97af5cd8b63695 Mon Sep 17 00:00:00 2001 From: yeoEun Date: Wed, 24 Dec 2025 12:23:52 +0900 Subject: [PATCH 135/135] feat: billingRequired --- .../joycrew/backend/dto/LoginResponse.java | 21 ++- .../com/joycrew/backend/entity/Company.java | 5 + .../exception/BillingRequiredException.java | 17 +++ .../exception/GlobalExceptionHandler.java | 139 +++++++++++++----- .../repository/DepartmentRepository.java | 4 +- .../repository/EmployeeRepository.java | 23 +++ .../service/AdminDashboardService.java | 26 ++-- .../backend/service/AdminPointService.java | 30 ++-- .../joycrew/backend/service/AuthService.java | 7 +- .../joycrew/backend/service/BillingGate.java | 23 +++ .../service/EmployeeManagementService.java | 81 ++++++++-- .../backend/service/GiftPurchaseService.java | 46 +++--- 12 files changed, 322 insertions(+), 100 deletions(-) create mode 100644 src/main/java/com/joycrew/backend/exception/BillingRequiredException.java create mode 100644 src/main/java/com/joycrew/backend/service/BillingGate.java diff --git a/src/main/java/com/joycrew/backend/dto/LoginResponse.java b/src/main/java/com/joycrew/backend/dto/LoginResponse.java index a943b53..7e605ef 100644 --- a/src/main/java/com/joycrew/backend/dto/LoginResponse.java +++ b/src/main/java/com/joycrew/backend/dto/LoginResponse.java @@ -22,9 +22,22 @@ public record LoginResponse( @Schema(description = "URL of the profile image") String profileImageUrl, @Schema(description = "Tenant subdomain (e.g., 'alko', 'BDL')") - String subdomain + String subdomain, + @Schema(description = "Whether billing method registration is required after login") + boolean billingRequired ) { - public static LoginResponse fail(String message) { - return new LoginResponse(null, message, null, null, null, null, null, null, null); - } + public static LoginResponse fail(String message) { + return new LoginResponse( + null, // accessToken + message, // message + null, // userId + null, // name + null, // email + null, // role + null, // totalPoint + null, // profileImageUrl + null, // subdomain + false // billingRequired (로그인 실패면 의미 없으니 false) + ); + } } diff --git a/src/main/java/com/joycrew/backend/entity/Company.java b/src/main/java/com/joycrew/backend/entity/Company.java index 7217248..59418d2 100644 --- a/src/main/java/com/joycrew/backend/entity/Company.java +++ b/src/main/java/com/joycrew/backend/entity/Company.java @@ -137,4 +137,9 @@ protected void onCreate() { protected void onUpdate() { this.updatedAt = LocalDateTime.now(); } + + public boolean isBillingReady() { + // 카드 등록이 끝났다는 의미: billingKey + customerKey 존재 + return this.tossBillingKey != null && this.tossCustomerKey != null && !this.tossBillingKey.isBlank(); + } } diff --git a/src/main/java/com/joycrew/backend/exception/BillingRequiredException.java b/src/main/java/com/joycrew/backend/exception/BillingRequiredException.java new file mode 100644 index 0000000..f150604 --- /dev/null +++ b/src/main/java/com/joycrew/backend/exception/BillingRequiredException.java @@ -0,0 +1,17 @@ +package com.joycrew.backend.exception; + +import org.springframework.http.HttpStatus; + +public class BillingRequiredException extends RuntimeException { + public BillingRequiredException() { + super("Billing method registration required."); + } + + public HttpStatus status() { + return HttpStatus.FORBIDDEN; + } + + public String code() { + return "BILLING_REQUIRED"; + } +} diff --git a/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java b/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java index d8c7465..ea2c8d0 100644 --- a/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java @@ -1,58 +1,127 @@ -package com.joycrew.backend.exception; +package com.joycrew.backend.web; import com.joycrew.backend.dto.ErrorResponse; +import com.joycrew.backend.exception.BillingRequiredException; +import com.joycrew.backend.exception.InsufficientPointsException; +import com.joycrew.backend.exception.UserNotFoundException; import jakarta.servlet.http.HttpServletRequest; -import org.springframework.http.HttpStatus; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.security.core.userdetails.UsernameNotFoundException; // import 추가 -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; import java.time.LocalDateTime; -import java.util.NoSuchElementException; +@Slf4j @RestControllerAdvice public class GlobalExceptionHandler { - @ExceptionHandler(InsufficientPointsException.class) - @ResponseStatus(HttpStatus.BAD_REQUEST) - public ErrorResponse handleInsufficientPoints(InsufficientPointsException ex, HttpServletRequest req) { - return new ErrorResponse("INSUFFICIENT_POINTS", ex.getMessage(), LocalDateTime.now(), req.getRequestURI()); + // ------------------------- + // 400 - Validation + // ------------------------- + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation( + MethodArgumentNotValidException e, + HttpServletRequest req + ) { + String msg = e.getBindingResult().getAllErrors().isEmpty() + ? "Validation failed" + : e.getBindingResult().getAllErrors().get(0).getDefaultMessage(); + + return ResponseEntity.badRequest().body(error( + "VALIDATION_ERROR", + msg, + req + )); } - @ExceptionHandler(NoSuchElementException.class) - @ResponseStatus(HttpStatus.NOT_FOUND) - public ErrorResponse handleNoSuchElement(NoSuchElementException ex, HttpServletRequest req) { - return new ErrorResponse("NOT_FOUND", ex.getMessage(), LocalDateTime.now(), req.getRequestURI()); + // ------------------------- + // 401 - Auth + // ------------------------- + @ExceptionHandler(BadCredentialsException.class) + public ResponseEntity handleBadCredentials( + BadCredentialsException e, + HttpServletRequest req + ) { + return ResponseEntity.status(401).body(error( + "AUTH_FAILED", + "Invalid email or password.", + req + )); } - @ExceptionHandler(IllegalStateException.class) - @ResponseStatus(HttpStatus.BAD_REQUEST) - public ErrorResponse handleIllegalState(IllegalStateException ex, HttpServletRequest req) { - return new ErrorResponse("ORDER_CANNOT_CANCEL", ex.getMessage(), LocalDateTime.now(), req.getRequestURI()); + // ------------------------- + // 403 - Billing required + // ------------------------- + @ExceptionHandler(BillingRequiredException.class) + public ResponseEntity handleBillingRequired( + BillingRequiredException e, + HttpServletRequest req + ) { + return ResponseEntity.status(403).body(error( + "BILLING_REQUIRED", + e.getMessage(), + req + )); } - // '가입되지 않은 이메일' 처리 - @ExceptionHandler(UsernameNotFoundException.class) - public ResponseEntity handleUsernameNotFound(UsernameNotFoundException ex, HttpServletRequest req) { - ErrorResponse errorResponse = new ErrorResponse( - "AUTH_002", // 가입되지 않은 이메일 - "이메일 또는 비밀번호가 잘못되었습니다.", // 보안을 위해 메시지는 동일하게 유지 - LocalDateTime.now(), - req.getRequestURI() - ); - return new ResponseEntity<>(errorResponse, HttpStatus.UNAUTHORIZED); + // ------------------------- + // 404 - Not Found + // ------------------------- + @ExceptionHandler(UserNotFoundException.class) + public ResponseEntity handleUserNotFound( + UserNotFoundException e, + HttpServletRequest req + ) { + return ResponseEntity.status(404).body(error( + "USER_NOT_FOUND", + e.getMessage(), + req + )); } - // '비밀번호 불일치' 처리 - @ExceptionHandler(BadCredentialsException.class) - public ResponseEntity handleAuthenticationFailed(BadCredentialsException ex, HttpServletRequest req) { - ErrorResponse errorResponse = new ErrorResponse( - "AUTH_003", // 비밀번호 불일치 - "이메일 또는 비밀번호가 잘못되었습니다.", // 보안을 위해 메시지는 동일하게 유지 + // ------------------------- + // 409 / 400 - Business rule + // ------------------------- + @ExceptionHandler(InsufficientPointsException.class) + public ResponseEntity handleInsufficientPoints( + InsufficientPointsException e, + HttpServletRequest req + ) { + // 포인트 부족은 보통 409(충돌)로 주기도 하고 400으로 주기도 함. + // 정책 확정 전이면 409 추천. + return ResponseEntity.status(409).body(error( + "INSUFFICIENT_POINTS", + e.getMessage(), + req + )); + } + + // ------------------------- + // 500 - Fallback + // ------------------------- + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnknown( + Exception e, + HttpServletRequest req + ) { + log.error("[UNHANDLED] path={}, msg={}", req.getRequestURI(), e.getMessage(), e); + + return ResponseEntity.status(500).body(error( + "INTERNAL_SERVER_ERROR", + "An unexpected error occurred.", + req + )); + } + + private ErrorResponse error(String code, String message, HttpServletRequest req) { + return new ErrorResponse( + code, + message, LocalDateTime.now(), req.getRequestURI() ); - return new ResponseEntity<>(errorResponse, HttpStatus.UNAUTHORIZED); } -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java b/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java index 9196339..3e00fac 100644 --- a/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java +++ b/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java @@ -10,7 +10,9 @@ import java.util.Optional; public interface DepartmentRepository extends JpaRepository { + List findAllByCompanyCompanyId(Long companyId); + Optional findByCompanyAndName(Company company, String name); Page findByCompanyCompanyId(Long companyId, Pageable pageable); @@ -20,4 +22,4 @@ public interface DepartmentRepository extends JpaRepository { Optional findByCompanyCompanyIdAndName(Long companyId, String name); boolean existsByCompanyCompanyIdAndName(Long companyId, String name); -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java b/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java index 914a310..ba5694d 100644 --- a/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java +++ b/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java @@ -31,6 +31,19 @@ public interface EmployeeRepository extends JpaRepository { """) Optional findByIdWithCompany(@Param("id") Long id); + @Query(""" + SELECT e + FROM Employee e + JOIN FETCH e.company c + WHERE e.employeeId = :employeeId + AND c.companyId = :companyId + """) + Optional findByIdWithCompanyAndCompanyId(Long employeeId, Long companyId); + + List findAllByCompanyCompanyIdAndEmployeeIdIn(Long companyId, List employeeIds); + + List findAllByCompanyCompanyId(Long companyId); + List findByPhoneNumber(String phoneNumber); Page findByCompanyCompanyIdAndStatus(Long companyId, String status, Pageable pageable); @@ -40,4 +53,14 @@ public interface EmployeeRepository extends JpaRepository { Optional findByCompanyCompanyIdAndEmployeeId(Long companyId, Long employeeId); boolean existsByCompanyCompanyIdAndEmail(Long companyId, String email); + + @Query(""" + SELECT e + FROM Employee e + JOIN FETCH e.company c + WHERE c.companyId = :companyId + AND e.employeeId = :employeeId + """) + Optional findByCompanyCompanyIdAndEmployeeIdWithCompany(Long companyId, Long employeeId); + } diff --git a/src/main/java/com/joycrew/backend/service/AdminDashboardService.java b/src/main/java/com/joycrew/backend/service/AdminDashboardService.java index 5ac29fc..055c03d 100644 --- a/src/main/java/com/joycrew/backend/service/AdminDashboardService.java +++ b/src/main/java/com/joycrew/backend/service/AdminDashboardService.java @@ -7,6 +7,7 @@ import com.joycrew.backend.exception.UserNotFoundException; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.WalletRepository; +import com.joycrew.backend.tenant.Tenant; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,30 +20,27 @@ public class AdminDashboardService { private final EmployeeRepository employeeRepository; private final WalletRepository walletRepository; - /** - * Fetches both the company's total point budget and the admin's personal wallet balance. - * @param adminEmail The email of the currently logged-in administrator. - * @return A DTO containing both company and personal point balances. - */ public AdminPointBudgetResponse getAdminAndCompanyBalance(String adminEmail) { - // 1. Fetch the admin employee and their associated company - Employee admin = employeeRepository.findByEmail(adminEmail) - .orElseThrow(() -> new UserNotFoundException("Admin user not found.")); + + Long companyId = Tenant.id(); + + // tenant 범위에서 admin 조회 + Employee admin = employeeRepository.findByCompanyCompanyIdAndEmail(companyId, adminEmail) + .orElseThrow(() -> new UserNotFoundException("Admin user not found.")); Company company = admin.getCompany(); if (company == null) { throw new IllegalStateException("Admin is not associated with any company."); } - // 2. Fetch the admin's personal wallet Wallet adminWallet = walletRepository.findByEmployee_EmployeeId(admin.getEmployeeId()) - .orElse(new Wallet(admin)); // If no wallet, create a new one with 0 points + .orElse(new Wallet(admin)); - // 3. Create and return the combined response DTO + // Double 그대로 반환 return new AdminPointBudgetResponse( - (double)company.getTotalCompanyBalance(), - adminWallet.getBalance(), - adminWallet.getGiftablePoint() + company.getTotalCompanyBalance(), + adminWallet.getBalance(), + adminWallet.getGiftablePoint() ); } } diff --git a/src/main/java/com/joycrew/backend/service/AdminPointService.java b/src/main/java/com/joycrew/backend/service/AdminPointService.java index 66ad44c..7d8245c 100644 --- a/src/main/java/com/joycrew/backend/service/AdminPointService.java +++ b/src/main/java/com/joycrew/backend/service/AdminPointService.java @@ -7,17 +7,18 @@ import com.joycrew.backend.entity.RewardPointTransaction; import com.joycrew.backend.entity.Wallet; import com.joycrew.backend.entity.enums.TransactionType; +import com.joycrew.backend.exception.BillingRequiredException; import com.joycrew.backend.exception.UserNotFoundException; import com.joycrew.backend.repository.CompanyRepository; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.RewardPointTransactionRepository; import com.joycrew.backend.repository.WalletRepository; +import com.joycrew.backend.tenant.Tenant; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; @@ -32,23 +33,32 @@ public class AdminPointService { private final CompanyRepository companyRepository; public void distributePoints(AdminPointDistributionRequest request, Employee admin) { - // Admin 다시 조회 (Company까지 join fetch) - Employee managedAdmin = employeeRepository.findByIdWithCompany(admin.getEmployeeId()) + + Long companyId = Tenant.id(); + + // ✅ Admin 다시 조회 (tenant + company join fetch) + Employee managedAdmin = employeeRepository.findByIdWithCompanyAndCompanyId(admin.getEmployeeId(), companyId) .orElseThrow(() -> new UserNotFoundException("Admin not found")); Company company = managedAdmin.getCompany(); + // ✅ 카드등록 필수 게이트 (포인트 지급/차감 전에) + if (!company.isBillingReady()) { + throw new BillingRequiredException(); + } + // 총 변화량 계산 int netPointsChange = request.distributions().stream() .mapToInt(PointDistributionDetail::points) .sum(); - // 회사 예산 반영 + // 회사 예산 반영 (Double 기반) if (netPointsChange > 0) { company.spendBudget(netPointsChange); } else if (netPointsChange < 0) { company.addBudget(Math.abs(netPointsChange)); } + // company는 영속 상태라 save 생략 가능하지만, 명시적으로 두려면 유지 companyRepository.save(company); // 지급 대상 직원 ID 목록 @@ -56,18 +66,19 @@ public void distributePoints(AdminPointDistributionRequest request, Employee adm .map(PointDistributionDetail::employeeId) .toList(); - // 직원 목록 조회 - Map employeeMap = employeeRepository.findAllById(employeeIds).stream() + // ✅ tenant(회사) 범위에서만 직원 조회 + Map employeeMap = employeeRepository + .findAllByCompanyCompanyIdAndEmployeeIdIn(companyId, employeeIds) + .stream() .collect(Collectors.toMap(Employee::getEmployeeId, Function.identity())); - // 일부 직원이 없으면 예외 if (employeeMap.size() != employeeIds.size()) { throw new UserNotFoundException("Could not find some of the requested employees. Please verify the IDs."); } - // 각 직원별 지급/차감 실행 for (PointDistributionDetail detail : request.distributions()) { Employee employee = employeeMap.get(detail.employeeId()); + Wallet wallet = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) .orElseThrow(() -> new IllegalStateException("Wallet not found for employee: " + employee.getEmployeeName())); @@ -79,7 +90,6 @@ public void distributePoints(AdminPointDistributionRequest request, Employee adm wallet.revokePoints(Math.abs(pointsToProcess)); } - // 트랜잭션 기록 저장 if (pointsToProcess != 0) { RewardPointTransaction transaction = RewardPointTransaction.builder() .sender(managedAdmin) diff --git a/src/main/java/com/joycrew/backend/service/AuthService.java b/src/main/java/com/joycrew/backend/service/AuthService.java index 143244b..39641df 100644 --- a/src/main/java/com/joycrew/backend/service/AuthService.java +++ b/src/main/java/com/joycrew/backend/service/AuthService.java @@ -4,6 +4,7 @@ import com.joycrew.backend.dto.LoginResponse; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.AdminLevel; import com.joycrew.backend.exception.UserNotFoundException; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.WalletRepository; @@ -76,6 +77,9 @@ public LoginResponse login(LoginRequest request) { .map(cd -> cd.getDomain().toLowerCase()) .orElse(null); // 등록이 안 되어 있다면 null + boolean isAdmin = employee.getRole() == AdminLevel.HR_ADMIN || employee.getRole() == AdminLevel.SUPER_ADMIN; + boolean billingRequired = isAdmin && !employee.getCompany().isBillingReady(); + return new LoginResponse( accessToken, "Login successful", @@ -85,7 +89,8 @@ public LoginResponse login(LoginRequest request) { employee.getRole(), totalPoint, employee.getProfileImageUrl(), - subdomain + subdomain, + billingRequired ); } catch (UsernameNotFoundException | BadCredentialsException e) { diff --git a/src/main/java/com/joycrew/backend/service/BillingGate.java b/src/main/java/com/joycrew/backend/service/BillingGate.java new file mode 100644 index 0000000..e94d284 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/BillingGate.java @@ -0,0 +1,23 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.exception.BillingRequiredException; +import com.joycrew.backend.repository.CompanyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class BillingGate { + + private final CompanyRepository companyRepository; + + @Transactional(readOnly = true) + public void requireBillingReady(Long companyId) { + Company company = companyRepository.findById(companyId).orElseThrow(); + if (!company.isBillingReady()) { + throw new BillingRequiredException(); + } + } +} diff --git a/src/main/java/com/joycrew/backend/service/EmployeeManagementService.java b/src/main/java/com/joycrew/backend/service/EmployeeManagementService.java index 7d14502..f7a951f 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeManagementService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeManagementService.java @@ -3,12 +3,16 @@ import com.joycrew.backend.dto.AdminEmployeeQueryResponse; import com.joycrew.backend.dto.AdminEmployeeUpdateRequest; import com.joycrew.backend.dto.AdminPagedEmployeeResponse; +import com.joycrew.backend.entity.Company; import com.joycrew.backend.entity.Department; import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.exception.BillingRequiredException; import com.joycrew.backend.exception.UserNotFoundException; +import com.joycrew.backend.repository.CompanyRepository; import com.joycrew.backend.repository.DepartmentRepository; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.service.mapper.EmployeeMapper; +import com.joycrew.backend.tenant.Tenant; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import jakarta.persistence.TypedQuery; @@ -25,41 +29,72 @@ public class EmployeeManagementService { private final EmployeeRepository employeeRepository; private final DepartmentRepository departmentRepository; + private final CompanyRepository companyRepository; private final EmployeeMapper employeeMapper; + @PersistenceContext private final EntityManager em; + /** 카드 등록 게이트(직원 관리/등록/조회 모두 차단) */ + private Company requireBillingReady() { + Long companyId = Tenant.id(); + Company company = companyRepository.findById(companyId) + .orElseThrow(() -> new IllegalStateException("Company not found (tenant=" + companyId + ")")); + + if (!company.isBillingReady()) { + throw new BillingRequiredException(); + } + return company; + } + @Transactional(readOnly = true) public AdminPagedEmployeeResponse searchEmployees(String keyword, int page, int size) { - StringBuilder whereClause = new StringBuilder("WHERE 1=1 "); + requireBillingReady(); + + Long companyId = Tenant.id(); + + StringBuilder whereClause = new StringBuilder("WHERE c.companyId = :companyId "); if (keyword != null && !keyword.isBlank()) { whereClause.append("AND (LOWER(e.employeeName) LIKE :keyword ") .append("OR LOWER(e.email) LIKE :keyword ") .append("OR LOWER(d.name) LIKE :keyword) "); } - String countJpql = "SELECT COUNT(e) FROM Employee e LEFT JOIN e.department d " + whereClause; + String countJpql = + "SELECT COUNT(e) " + + "FROM Employee e " + + "JOIN e.company c " + + "LEFT JOIN e.department d " + + whereClause; + TypedQuery countQuery = em.createQuery(countJpql, Long.class); + countQuery.setParameter("companyId", companyId); if (keyword != null && !keyword.isBlank()) { countQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); } + long total = countQuery.getSingleResult(); int totalPages = (int) Math.ceil((double) total / size); - String dataJpql = "SELECT e FROM Employee e " + - "LEFT JOIN FETCH e.department d " + - "LEFT JOIN FETCH e.company c " + - whereClause + - "ORDER BY e.employeeName ASC"; + String dataJpql = + "SELECT e " + + "FROM Employee e " + + "JOIN FETCH e.company c " + + "LEFT JOIN FETCH e.department d " + + whereClause + + "ORDER BY e.employeeName ASC"; + TypedQuery dataQuery = em.createQuery(dataJpql, Employee.class) .setFirstResult(page * size) .setMaxResults(size); + + dataQuery.setParameter("companyId", companyId); if (keyword != null && !keyword.isBlank()) { dataQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); } List employees = dataQuery.getResultList().stream() - .map(employeeMapper::toAdminEmployeeQueryResponse) // Use Mapper + .map(employeeMapper::toAdminEmployeeQueryResponse) .toList(); return new AdminPagedEmployeeResponse( @@ -71,17 +106,23 @@ public AdminPagedEmployeeResponse searchEmployees(String keyword, int page, int } public Employee updateEmployee(Long employeeId, AdminEmployeeUpdateRequest request) { - Employee employee = employeeRepository.findById(employeeId) + requireBillingReady(); + + Long companyId = Tenant.id(); + + Employee employee = employeeRepository.findByCompanyCompanyIdAndEmployeeId(companyId, employeeId) .orElseThrow(() -> new UserNotFoundException("Employee not found with ID: " + employeeId)); if (request.name() != null) { employee.updateName(request.name()); } + if (request.departmentId() != null) { - Department department = departmentRepository.findById(request.departmentId()) + Department department = departmentRepository.findByCompanyCompanyIdAndDepartmentId(companyId, request.departmentId()) .orElseThrow(() -> new IllegalArgumentException("Department not found with ID: " + request.departmentId())); employee.assignToDepartment(department); } + if (request.position() != null) { employee.updatePosition(request.position()); } @@ -91,19 +132,29 @@ public Employee updateEmployee(Long employeeId, AdminEmployeeUpdateRequest reque if (request.status() != null) { employee.updateStatus(request.status()); } - return employee; // @Transactional will handle the save + + return employee; // dirty checking } public void deactivateEmployee(Long employeeId) { - Employee employee = employeeRepository.findById(employeeId) + requireBillingReady(); + + Long companyId = Tenant.id(); + + Employee employee = employeeRepository.findByCompanyCompanyIdAndEmployeeId(companyId, employeeId) .orElseThrow(() -> new UserNotFoundException("Employee not found with ID: " + employeeId)); + employee.updateStatus("INACTIVE"); } @Transactional(readOnly = true) public List getAllEmployees() { - return employeeRepository.findAll().stream() - .map(employeeMapper::toAdminEmployeeQueryResponse) // Use Mapper + requireBillingReady(); + + Long companyId = Tenant.id(); + + return employeeRepository.findAllByCompanyCompanyId(companyId).stream() + .map(employeeMapper::toAdminEmployeeQueryResponse) .toList(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/service/GiftPurchaseService.java b/src/main/java/com/joycrew/backend/service/GiftPurchaseService.java index 36657f1..065c2c3 100644 --- a/src/main/java/com/joycrew/backend/service/GiftPurchaseService.java +++ b/src/main/java/com/joycrew/backend/service/GiftPurchaseService.java @@ -1,9 +1,9 @@ -// src/main/java/com/joycrew/backend/service/GiftPurchaseService.java package com.joycrew.backend.service; import com.joycrew.backend.dto.CreateOrderRequest; import com.joycrew.backend.dto.OrderResponse; import com.joycrew.backend.dto.kakao.KakaoTemplateOrderRequest; +import com.joycrew.backend.entity.Company; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.Order; import com.joycrew.backend.entity.RewardPointTransaction; @@ -11,13 +11,18 @@ import com.joycrew.backend.entity.enums.OrderStatus; import com.joycrew.backend.entity.enums.TransactionType; import com.joycrew.backend.entity.kakao.KakaoTemplate; +import com.joycrew.backend.exception.BillingRequiredException; import com.joycrew.backend.exception.UserNotFoundException; import com.joycrew.backend.kakao.KakaoGiftBizClient; -import com.joycrew.backend.repository.*; +import com.joycrew.backend.repository.KakaoTemplateRepository; +import com.joycrew.backend.repository.OrderRepository; +import com.joycrew.backend.repository.RewardPointTransactionRepository; +import com.joycrew.backend.repository.WalletRepository; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.tenant.Tenant; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; @@ -56,23 +61,33 @@ public class GiftPurchaseService { */ @Transactional public OrderResponse purchaseWithPoints(Long employeeId, CreateOrderRequest req) { - // 1) 사용자/지갑/템플릿 - Employee employee = employeeRepository.findById(employeeId) + + Long companyId = Tenant.id(); + + // ✅ tenant 범위에서 employee 로드 + 회사 join fetch 권장(없으면 repository 메서드로) + Employee employee = employeeRepository.findByCompanyCompanyIdAndEmployeeIdWithCompany(companyId, employeeId) .orElseThrow(() -> new UserNotFoundException("Employee not found")); + + Company company = employee.getCompany(); + + // ✅ 카드등록 전이면 주문 차단 + if (!company.isBillingReady()) { + throw new BillingRequiredException(); + } + Wallet wallet = walletRepository.findByEmployee_EmployeeId(employeeId) .orElseThrow(() -> new IllegalStateException("Wallet not found")); KakaoTemplate template = templateRepo.findById(req.externalProductId()) .orElseThrow(() -> new IllegalArgumentException("Template not found: " + req.externalProductId())); - // 2) 금액/포인트 계산 (옵션 제거 버전) int qty = (req.quantity() == null || req.quantity() <= 0) ? 1 : req.quantity(); int unitKrw = template.getBasePriceKrw(); long totalKrw = (long) unitKrw * qty; int totalPoint = (int) Math.ceil(totalKrw / (double) krwPerPoint); - // 3) 주문 레코드 PENDING 으로 선저장 (external_order_id 생성) String externalOrderId = buildExternalOrderId(employeeId, template.getTemplateId()); + Order order = Order.builder() .employee(employee) .productId(stableHashToLong(template.getTemplateId())) @@ -82,14 +97,12 @@ public OrderResponse purchaseWithPoints(Long employeeId, CreateOrderRequest req) .totalPrice(totalPoint) .status(OrderStatus.PENDING) .orderedAt(LocalDateTime.now()) - .externalOrderId(externalOrderId) // 필요시 Order 엔티티에 필드 추가 + .externalOrderId(externalOrderId) .build(); order = orderRepository.save(order); - // 4) 포인트 선차감 (구매는 balance만 차감) wallet.purchaseWithPoints(totalPoint); - // 5) Kakao 요청 바디 구성 (receiver_id = 직원 휴대폰 번호) String receiverPhone = Optional.ofNullable(employee.getPhoneNumber()) .map(this::normalizePhone) .filter(s -> s != null && !s.isBlank()) @@ -112,7 +125,6 @@ public OrderResponse purchaseWithPoints(Long employeeId, CreateOrderRequest req) externalOrderId ); - // 6) Kakao 호출 (dry-run 지원) try { if (!dryRun) { String kakaoBody = kakao.sendTemplateOrder(kakaoReq); @@ -121,7 +133,6 @@ public OrderResponse purchaseWithPoints(Long employeeId, CreateOrderRequest req) log.warn("[DRY-RUN] Skipping Kakao call. Would send: {}", kakaoReq); } - // 7) 거래 이력 저장(성공시에만) RewardPointTransaction tx = RewardPointTransaction.builder() .sender(employee) .receiver(null) @@ -131,21 +142,18 @@ public OrderResponse purchaseWithPoints(Long employeeId, CreateOrderRequest req) .build(); transactionRepository.save(tx); - // 8) 주문 상태 갱신 -> PLACED order.setStatus(OrderStatus.PLACED); order = orderRepository.save(order); return OrderResponse.from(order, template.getThumbnailUrl()); } catch (ResponseStatusException ex) { - // Kakao 4xx/네트워크 오류 등 → FAILED 처리 + 포인트 환불 refundWalletSilently(wallet, totalPoint); order.setStatus(OrderStatus.FAILED); orderRepository.save(order); - throw ex; // 400/502 그대로 클라이언트에게 + throw ex; } catch (RuntimeException ex) { - // 기타 예외 → FAILED 처리 + 포인트 환불 refundWalletSilently(wallet, totalPoint); order.setStatus(OrderStatus.FAILED); orderRepository.save(order); @@ -158,7 +166,6 @@ private void refundWalletSilently(Wallet wallet, int totalPoint) { wallet.refundPoints(totalPoint); } catch (Exception e) { log.error("Failed to refund points on error (amount={}): {}", totalPoint, e.getMessage(), e); - // 환불 실패시에도 본 예외를 삼키지 않는다. } } @@ -168,17 +175,16 @@ private String emptyToNull(String s) { private String normalizePhone(String raw) { if (raw == null) return null; - return raw.replaceAll("\\D", ""); // 숫자만 남김 (010-xxxx-xxxx → 010xxxxxxxx) + return raw.replaceAll("\\D", ""); } private String buildExternalOrderId(Long employeeId, String templateId) { - // 길이 ≤ 70: "JC-" + employeeId + "-" + 12자리 랜덤 String rand = UUID.randomUUID().toString().replace("-", "").substring(0, 12); return "JC-" + employeeId + "-" + rand; } private long stableHashToLong(String s) { - long h = 1469598103934665603L; // FNV-1a 64-bit + long h = 1469598103934665603L; for (byte b : s.getBytes()) { h ^= b; h *= 1099511628211L; } return h & Long.MAX_VALUE; }