From 0e662969e65f99c2d9f2461cb691095e3a5053ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Sun, 8 Jun 2025 23:13:00 +0900 Subject: [PATCH 01/22] =?UTF-8?q?feat=20(=20#10=20)=20:=20=EB=A0=88?= =?UTF-8?q?=EA=B1=B0=EC=8B=9C=20=EC=BD=94=EB=93=9C=20=EA=B0=80=EC=A0=B8?= =?UTF-8?q?=EC=98=A4=EA=B8=B0=20&=20=EB=A3=A8=ED=8A=B8=EC=9D=98=20src?= =?UTF-8?q?=EB=A5=BC=20=EB=AA=A8=EB=93=88=EB=A1=9C=20=EB=94=B0=EB=A1=9C=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 --- .../kr/entrydsm/user/CasperUserApplication.kt | 0 .../user/domain/admin/domain/Admin.kt | 15 ++ .../domain/repository/AdminRepository.kt | 9 ++ .../admin/exception/AdminNotFoundException.kt | 8 + .../exception/AdminUnauthorizedException.kt | 8 + .../user/domain/admin/facade/AdminFacade.kt | 21 +++ .../admin/presentation/AdminController.kt | 47 ++++++ .../dto/request/AdminLoginRequest.kt | 10 ++ .../dto/response/InternalAdminResponse.kt | 7 + .../domain/admin/service/AdminLoginService.kt | 43 +++++ .../admin/service/AdminTokenRefreshService.kt | 14 ++ .../admin/service/DeleteAllTableService.kt | 11 ++ .../admin/service/QueryAdminByUUIDService.kt | 22 +++ .../user/domain/auth/domain/PassInfo.kt | 16 ++ .../domain/repository/PassInfoRepository.kt | 11 ++ .../InvalidOkCertConnectException.kt | 8 + .../auth/exception/InvalidPassException.kt | 8 + .../auth/exception/InvalidUrlException.kt | 8 + .../exception/PassInfoNotFoundException.kt | 8 + .../auth/presentation/PassInfoController.kt | 30 ++++ .../dto/request/PassPopupRequest.kt | 8 + .../dto/resopnse/QueryPassInfoResponse.kt | 6 + .../domain/auth/service/PassPopupService.kt | 106 +++++++++++++ .../auth/service/QueryPassInfoService.kt | 43 +++++ .../refreshtoken/domain/RefreshToken.kt | 21 +++ .../repository/RefreshTokenRepository.kt | 8 + .../entrydsm/user/domain/user/domain/User.kt | 46 ++++++ .../user/domain/user/domain/UserCache.kt | 19 +++ .../user/domain/user/domain/UserInfo.kt | 18 +++ .../user/domain/user/domain/UserRole.kt | 7 + .../domain/repository/UserCacheRepository.kt | 7 + .../domain/repository/UserInfoRepository.kt | 6 + .../user/domain/repository/UserRepository.kt | 13 ++ .../exception/PasswordNotValidException.kt | 8 + .../exception/UserAlreadyExistsException.kt | 8 + .../user/exception/UserNotFoundException.kt | 8 + .../user/domain/user/facade/UserFacade.kt | 26 +++ .../user/presentation/SampleController.kt | 20 +++ .../user/presentation/UserController.kt | 78 +++++++++ .../dto/request/ChangePasswordRequest.kt | 16 ++ .../dto/request/UserLoginRequest.kt | 16 ++ .../dto/request/UserSignupRequest.kt | 19 +++ .../dto/response/InternalUserResponse.kt | 13 ++ .../presentation/dto/response/UserResponse.kt | 7 + .../user/service/ChangePasswordService.kt | 27 ++++ .../user/service/ChangeReceiptCodeService.kt | 22 +++ .../user/service/QueryUserByUUIDService.kt | 32 ++++ .../user/service/QueryUserInfoService.kt | 24 +++ .../domain/user/service/UserLoginService.kt | 42 +++++ .../domain/user/service/UserSignupService.kt | 52 ++++++ .../user/service/UserTokenRefreshService.kt | 32 ++++ .../user/service/UserWithdrawalService.kt | 26 +++ .../user/global/config/LicenseConfig.kt | 31 ++++ .../user/global/config/RedisConfig.kt | 44 ++++++ .../config/StaticRoutingConfiguration.kt | 13 ++ .../user/global/error/ErrorResponse.kt | 6 + .../global/error/GlobalExceptionFilter.kt | 46 ++++++ .../global/error/GlobalExceptionHandler.kt | 32 ++++ .../global/error/exception/EquusException.kt | 7 + .../user/global/error/exception/ErrorCode.kt | 26 +++ .../global/exception/ExpiredTokenException.kt | 8 + .../exception/InternalServerErrorException.kt | 8 + .../global/exception/InvalidTokenException.kt | 8 + .../user/global/security/FilterConfig.kt | 23 +++ .../user/global/security/SecurityConfig.kt | 50 ++++++ .../security/auth/AdminDetailsService.kt | 18 +++ .../user/global/security/auth/AuthDetails.kt | 41 +++++ .../security/auth/AuthDetailsService.kt | 17 ++ .../global/security/jwt/JwtTokenProvider.kt | 149 ++++++++++++++++++ .../user/global/utils/pass/PassUtil.kt | 41 +++++ .../global/utils/pass/RedirectUrlChecker.kt | 17 ++ .../global/utils/token/dto/TokenResponse.kt | 6 + .../main/resources/application.properties | 0 .../WEB-INF/lib/OkCert3-java1.5-2.3.1.jar | Bin 0 -> 198908 bytes .../user/CasperUserApplicationTests.kt | 11 -- 75 files changed, 1679 insertions(+), 11 deletions(-) rename {src => casper-user/src}/main/kotlin/hs/kr/entrydsm/user/CasperUserApplication.kt (100%) create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/domain/Admin.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/domain/repository/AdminRepository.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/exception/AdminNotFoundException.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/exception/AdminUnauthorizedException.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/facade/AdminFacade.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/presentation/AdminController.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/presentation/dto/request/AdminLoginRequest.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/presentation/dto/response/InternalAdminResponse.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/AdminLoginService.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/AdminTokenRefreshService.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/DeleteAllTableService.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/QueryAdminByUUIDService.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/domain/PassInfo.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/domain/repository/PassInfoRepository.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidOkCertConnectException.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidPassException.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidUrlException.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/PassInfoNotFoundException.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/presentation/PassInfoController.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/presentation/dto/request/PassPopupRequest.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/presentation/dto/resopnse/QueryPassInfoResponse.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/service/PassPopupService.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/service/QueryPassInfoService.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/refreshtoken/domain/RefreshToken.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/refreshtoken/domain/repository/RefreshTokenRepository.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/User.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/UserCache.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/UserInfo.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/UserRole.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/repository/UserCacheRepository.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/repository/UserInfoRepository.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/repository/UserRepository.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/PasswordNotValidException.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/UserAlreadyExistsException.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/UserNotFoundException.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/facade/UserFacade.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/SampleController.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/UserController.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/request/ChangePasswordRequest.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/request/UserLoginRequest.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/request/UserSignupRequest.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/response/InternalUserResponse.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/response/UserResponse.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/ChangePasswordService.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/ChangeReceiptCodeService.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/QueryUserByUUIDService.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/QueryUserInfoService.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserLoginService.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserSignupService.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserTokenRefreshService.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserWithdrawalService.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/LicenseConfig.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/RedisConfig.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/StaticRoutingConfiguration.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/ErrorResponse.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/GlobalExceptionFilter.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/GlobalExceptionHandler.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/EquusException.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/ErrorCode.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/ExpiredTokenException.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/InternalServerErrorException.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/InvalidTokenException.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/FilterConfig.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/SecurityConfig.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AdminDetailsService.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AuthDetails.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AuthDetailsService.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/jwt/JwtTokenProvider.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/pass/PassUtil.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/pass/RedirectUrlChecker.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/token/dto/TokenResponse.kt rename {src => casper-user/src}/main/resources/application.properties (100%) create mode 100644 casper-user/src/main/webapp/WEB-INF/lib/OkCert3-java1.5-2.3.1.jar delete mode 100644 src/test/kotlin/hs/kr/entrydsm/user/CasperUserApplicationTests.kt diff --git a/src/main/kotlin/hs/kr/entrydsm/user/CasperUserApplication.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/CasperUserApplication.kt similarity index 100% rename from src/main/kotlin/hs/kr/entrydsm/user/CasperUserApplication.kt rename to casper-user/src/main/kotlin/hs/kr/entrydsm/user/CasperUserApplication.kt diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/domain/Admin.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/domain/Admin.kt new file mode 100644 index 0000000..d2968df --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/domain/Admin.kt @@ -0,0 +1,15 @@ +package hs.kr.entrydsm.user.domain.admin.domain + +import hs.kr.entrydsm.user.global.entity.BaseUUIDEntity +import java.util.UUID +import jakarta.persistence.Column +import jakarta.persistence.Entity + +@Entity(name = "tbl_admin") +class Admin( + id: UUID?, + @Column(name = "admin_id", length = 15, nullable = false) + val adminId: String, + @Column(name = "password", length = 60, nullable = false) + val password: String, +) : BaseUUIDEntity(id) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/domain/repository/AdminRepository.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/domain/repository/AdminRepository.kt new file mode 100644 index 0000000..21fe133 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/domain/repository/AdminRepository.kt @@ -0,0 +1,9 @@ +package hs.kr.entrydsm.user.domain.admin.domain.repository + +import hs.kr.entrydsm.user.domain.admin.domain.Admin +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface AdminRepository : JpaRepository { + fun findByAdminId(adminId: String): Admin? +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/exception/AdminNotFoundException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/exception/AdminNotFoundException.kt new file mode 100644 index 0000000..54da109 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/exception/AdminNotFoundException.kt @@ -0,0 +1,8 @@ +package hs.kr.entrydsm.user.domain.admin.exception + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode + +object AdminNotFoundException : EquusException( + ErrorCode.ADMIN_NOT_FOUND, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/exception/AdminUnauthorizedException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/exception/AdminUnauthorizedException.kt new file mode 100644 index 0000000..4133a64 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/exception/AdminUnauthorizedException.kt @@ -0,0 +1,8 @@ +package hs.kr.entrydsm.user.domain.admin.exception + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode + +object AdminUnauthorizedException : EquusException( + ErrorCode.ADMIN_UNAUTHORIZED, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/facade/AdminFacade.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/facade/AdminFacade.kt new file mode 100644 index 0000000..8ace64f --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/facade/AdminFacade.kt @@ -0,0 +1,21 @@ +package hs.kr.entrydsm.user.domain.admin.facade + +import hs.kr.entrydsm.user.domain.admin.domain.Admin +import hs.kr.entrydsm.user.domain.admin.domain.repository.AdminRepository +import hs.kr.entrydsm.user.domain.admin.exception.AdminNotFoundException +import org.springframework.data.repository.findByIdOrNull +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Component +import java.util.UUID + +@Component +class AdminFacade( + private val adminRepository: AdminRepository, +) { + fun getCurrentUser(): Admin { + val adminId = SecurityContextHolder.getContext().authentication.name + return adminRepository.findByIdOrNull(UUID.fromString(adminId)) ?: throw AdminNotFoundException + } + + fun getUserById(adminId: String): Admin = adminRepository.findByIdOrNull(UUID.fromString(adminId)) ?: throw AdminNotFoundException +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/presentation/AdminController.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/presentation/AdminController.kt new file mode 100644 index 0000000..00ddebe --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/presentation/AdminController.kt @@ -0,0 +1,47 @@ +package hs.kr.entrydsm.user.domain.admin.presentation + +import hs.kr.entrydsm.user.domain.admin.presentation.dto.request.AdminLoginRequest +import hs.kr.entrydsm.user.domain.admin.service.AdminLoginService +import hs.kr.entrydsm.user.domain.admin.service.AdminTokenRefreshService +import hs.kr.entrydsm.user.domain.admin.service.DeleteAllTableService +import hs.kr.entrydsm.user.domain.admin.service.QueryAdminByUUIDService +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.UUID +import jakarta.validation.Valid + +@RestController +@RequestMapping("/admin") +class AdminController( + private val adminLoginService: AdminLoginService, + private val adminTokenRefreshService: AdminTokenRefreshService, + private val deleteAllTableService: DeleteAllTableService, + private val queryAdminByUUIDService: QueryAdminByUUIDService, +) { + @PostMapping("/auth") + fun login( + @RequestBody @Valid + adminLoginRequest: AdminLoginRequest, + ): TokenResponse = adminLoginService.execute(adminLoginRequest) + + @PutMapping("/auth") + fun tokenRefresh( + @RequestHeader("X-Refresh-Token") refreshToken: String, + ): TokenResponse = adminTokenRefreshService.execute(refreshToken) + + @DeleteMapping("/auth") + fun deleteAllTable() = deleteAllTableService.execute() + + @GetMapping("/{adminId}") + fun findAdminById( + @PathVariable adminId: UUID, + ) = queryAdminByUUIDService.execute(adminId) +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/presentation/dto/request/AdminLoginRequest.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/presentation/dto/request/AdminLoginRequest.kt new file mode 100644 index 0000000..ab6645a --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/presentation/dto/request/AdminLoginRequest.kt @@ -0,0 +1,10 @@ +package hs.kr.entrydsm.user.domain.admin.presentation.dto.request + +import jakarta.validation.constraints.NotBlank + +data class AdminLoginRequest( + @NotBlank + val adminId: String, + @NotBlank + val password: String, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/presentation/dto/response/InternalAdminResponse.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/presentation/dto/response/InternalAdminResponse.kt new file mode 100644 index 0000000..6e38dfc --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/presentation/dto/response/InternalAdminResponse.kt @@ -0,0 +1,7 @@ +package hs.kr.entrydsm.user.domain.admin.presentation.dto.response + +import java.util.UUID + +data class InternalAdminResponse( + val id: UUID, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/AdminLoginService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/AdminLoginService.kt new file mode 100644 index 0000000..0e9abaf --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/AdminLoginService.kt @@ -0,0 +1,43 @@ +package hs.kr.entrydsm.user.domain.admin.service + +import hs.kr.entrydsm.user.domain.admin.domain.repository.AdminRepository +import hs.kr.entrydsm.user.domain.admin.exception.AdminNotFoundException +import hs.kr.entrydsm.user.domain.admin.presentation.dto.request.AdminLoginRequest +import hs.kr.entrydsm.user.domain.user.domain.UserInfo +import hs.kr.entrydsm.user.domain.user.domain.UserRole +import hs.kr.entrydsm.user.domain.user.domain.repository.UserInfoRepository +import hs.kr.entrydsm.user.domain.user.exception.PasswordNotValidException +import hs.kr.entrydsm.user.global.security.jwt.JwtProperties +import hs.kr.entrydsm.user.global.security.jwt.JwtTokenProvider +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AdminLoginService( + private val adminRepository: AdminRepository, + private val passwordEncoder: PasswordEncoder, + private val jwtTokenProvider: JwtTokenProvider, + private val userInfoRepository: UserInfoRepository, + private val jwtProperties: JwtProperties, +) { + @Transactional + fun execute(adminLoginRequest: AdminLoginRequest): TokenResponse { + val admin = adminRepository.findByAdminId(adminLoginRequest.adminId) ?: throw AdminNotFoundException + + if (!passwordEncoder.matches(adminLoginRequest.password, admin.password)) { + throw PasswordNotValidException + } + val tokenResponse = jwtTokenProvider.generateToken(admin.id.toString(), UserRole.ADMIN.toString()) + val userInfo = + UserInfo( + token = tokenResponse.accessToken, + userId = jwtTokenProvider.getSubjectWithExpiredCheck(tokenResponse.accessToken), + userRole = jwtTokenProvider.getRole(tokenResponse.accessToken), + ttl = jwtProperties.accessExp, + ) + userInfoRepository.save(userInfo) + return tokenResponse + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/AdminTokenRefreshService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/AdminTokenRefreshService.kt new file mode 100644 index 0000000..a49d04c --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/AdminTokenRefreshService.kt @@ -0,0 +1,14 @@ +package hs.kr.entrydsm.user.domain.admin.service + +import hs.kr.entrydsm.user.global.security.jwt.JwtTokenProvider +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AdminTokenRefreshService( + private val jwtTokenProvider: JwtTokenProvider, +) { + @Transactional + fun execute(refreshToken: String): TokenResponse = jwtTokenProvider.reIssue(refreshToken) +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/DeleteAllTableService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/DeleteAllTableService.kt new file mode 100644 index 0000000..d17adf3 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/DeleteAllTableService.kt @@ -0,0 +1,11 @@ +package hs.kr.entrydsm.user.domain.admin.service + +import hs.kr.entrydsm.user.infrastructure.kafka.producer.DeleteAllTableProducer +import org.springframework.stereotype.Service + +@Service +class DeleteAllTableService( + private val deleteAllTableProducer: DeleteAllTableProducer, +) { + fun execute() = deleteAllTableProducer.send() +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/QueryAdminByUUIDService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/QueryAdminByUUIDService.kt new file mode 100644 index 0000000..67b323c --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/service/QueryAdminByUUIDService.kt @@ -0,0 +1,22 @@ +package hs.kr.entrydsm.user.domain.admin.service + +import hs.kr.entrydsm.user.domain.admin.domain.repository.AdminRepository +import hs.kr.entrydsm.user.domain.admin.exception.AdminNotFoundException +import hs.kr.entrydsm.user.domain.admin.presentation.dto.response.InternalAdminResponse +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@Service +class QueryAdminByUUIDService( + private val adminRepository: AdminRepository, +) { + @Transactional(readOnly = true) + fun execute(adminId: UUID): InternalAdminResponse { + val admin = adminRepository.findByIdOrNull(adminId) ?: throw AdminNotFoundException + return InternalAdminResponse( + id = admin.id!!, + ) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/domain/PassInfo.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/domain/PassInfo.kt new file mode 100644 index 0000000..0318c78 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/domain/PassInfo.kt @@ -0,0 +1,16 @@ +package hs.kr.entrydsm.user.domain.auth.domain + +import org.springframework.data.annotation.Id +import org.springframework.data.redis.core.RedisHash +import org.springframework.data.redis.core.TimeToLive +import org.springframework.data.redis.core.index.Indexed + +@RedisHash +class PassInfo( + @Id + val name: String, + @Indexed + val phoneNumber: String, + @TimeToLive + val ttl: Long, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/domain/repository/PassInfoRepository.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/domain/repository/PassInfoRepository.kt new file mode 100644 index 0000000..3da504f --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/domain/repository/PassInfoRepository.kt @@ -0,0 +1,11 @@ +package hs.kr.entrydsm.user.domain.auth.domain.repository + +import hs.kr.entrydsm.user.domain.auth.domain.PassInfo +import org.springframework.data.repository.CrudRepository +import java.util.Optional + +interface PassInfoRepository : CrudRepository { + fun findByPhoneNumber(phoneNumber: String): Optional + + fun existsByPhoneNumber(phoneNumber: String): Boolean +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidOkCertConnectException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidOkCertConnectException.kt new file mode 100644 index 0000000..103418b --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidOkCertConnectException.kt @@ -0,0 +1,8 @@ +package hs.kr.entrydsm.user.domain.auth.exception + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode + +object InvalidOkCertConnectException : EquusException( + ErrorCode.INVALID_OKCERT_CONNECTION, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidPassException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidPassException.kt new file mode 100644 index 0000000..7c602f9 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidPassException.kt @@ -0,0 +1,8 @@ +package hs.kr.entrydsm.user.domain.auth.exception + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode + +object InvalidPassException : EquusException( + ErrorCode.INVALID_PASS, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidUrlException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidUrlException.kt new file mode 100644 index 0000000..895c274 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidUrlException.kt @@ -0,0 +1,8 @@ +package hs.kr.entrydsm.user.domain.auth.exception + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode + +object InvalidUrlException : EquusException( + ErrorCode.INVALID_URL, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/PassInfoNotFoundException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/PassInfoNotFoundException.kt new file mode 100644 index 0000000..998f9e8 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/PassInfoNotFoundException.kt @@ -0,0 +1,8 @@ +package hs.kr.entrydsm.user.domain.auth.exception + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode + +object PassInfoNotFoundException : EquusException( + ErrorCode.PASS_INFO_NOT_FOUND, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/presentation/PassInfoController.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/presentation/PassInfoController.kt new file mode 100644 index 0000000..4822fc8 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/presentation/PassInfoController.kt @@ -0,0 +1,30 @@ +package hs.kr.entrydsm.user.domain.auth.presentation + +import hs.kr.entrydsm.user.domain.auth.presentation.dto.request.PassPopupRequest +import hs.kr.entrydsm.user.domain.auth.presentation.dto.resopnse.QueryPassInfoResponse +import hs.kr.entrydsm.user.domain.auth.service.PassPopupService +import hs.kr.entrydsm.user.domain.auth.service.QueryPassInfoService +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import jakarta.validation.Valid + +@RestController +@RequestMapping("/user/verify") +class PassInfoController( + private val passPopupService: PassPopupService, + private val queryPassInfoService: QueryPassInfoService, +) { + @GetMapping("/info") + fun getPassInfo( + @RequestParam("mdl_tkn") token: String, + ): QueryPassInfoResponse = queryPassInfoService.execute(token) + + @PostMapping("/popup") + fun popupPass( + @RequestBody request: @Valid PassPopupRequest, + ): String = passPopupService.execute(request) +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/presentation/dto/request/PassPopupRequest.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/presentation/dto/request/PassPopupRequest.kt new file mode 100644 index 0000000..a697d67 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/presentation/dto/request/PassPopupRequest.kt @@ -0,0 +1,8 @@ +package hs.kr.entrydsm.user.domain.auth.presentation.dto.request + +import jakarta.validation.constraints.NotBlank + +data class PassPopupRequest( + @NotBlank(message = "redirect_url은 Null 또는 공백 또는 띄어쓰기를 허용하지 않습니다.") + val redirectUrl: String, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/presentation/dto/resopnse/QueryPassInfoResponse.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/presentation/dto/resopnse/QueryPassInfoResponse.kt new file mode 100644 index 0000000..9bb1087 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/presentation/dto/resopnse/QueryPassInfoResponse.kt @@ -0,0 +1,6 @@ +package hs.kr.entrydsm.user.domain.auth.presentation.dto.resopnse + +data class QueryPassInfoResponse( + val phoneNumber: String, + val name: String, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/service/PassPopupService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/service/PassPopupService.kt new file mode 100644 index 0000000..6b777bd --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/service/PassPopupService.kt @@ -0,0 +1,106 @@ +package hs.kr.entrydsm.user.domain.auth.service + +import hs.kr.entrydsm.user.domain.auth.presentation.dto.request.PassPopupRequest +import hs.kr.entrydsm.user.global.exception.InternalServerErrorException +import hs.kr.entrydsm.user.global.utils.pass.RedirectUrlChecker +import kcb.module.v3.OkCert +import org.json.JSONObject +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class PassPopupService( + private val redirectUrlChecker: RedirectUrlChecker, +) { + companion object { + private const val TARGET = "PROD" + } + + @Value("\${pass.site-name}") + private lateinit var siteName: String + + @Value("\${pass.site-url}") + private lateinit var siteUrl: String + + @Value("\${pass.popup-url}") + private lateinit var popupUrl: String + + @Value("\${pass.cp-cd}") + private lateinit var cpCd: String + + @Value("\${pass.license}") + private lateinit var license: String + + private val svcName = "IDS_HS_POPUP_START" + + private val rqstCausCd = "00" + + @Transactional + fun execute(passPopupRequest: PassPopupRequest): String { + redirectUrlChecker.checkRedirectUrl(passPopupRequest.redirectUrl) + try { + val reqJson = JSONObject() + reqJson.put("RETURN_URL", passPopupRequest.redirectUrl) + reqJson.put("SITE_NAME", siteName) + reqJson.put("SITE_URL", siteUrl) + reqJson.put("RQST_CAUS_CD", rqstCausCd) + + val reqStr: String = reqJson.toString() + + val okcert = OkCert() + + val resultStr: String = okcert.callOkCert(TARGET, cpCd, svcName, license, reqStr) + + val resJson = JSONObject(resultStr) + + val RSLT_CD: String = resJson.getString("RSLT_CD") + val RSLT_MSG: String = resJson.getString("RSLT_MSG") + var MDL_TKN = "" + + var succ = false + + if ("B000" == RSLT_CD && resJson.has("MDL_TKN")) { + MDL_TKN = resJson.getString("MDL_TKN") + succ = true + } + + val htmlBuilder = StringBuilder() + htmlBuilder.append("") + htmlBuilder.append("KCB ?��???본인?�인 ?�비???�플 2") + htmlBuilder.append("") + htmlBuilder.append("") + htmlBuilder.append("") + htmlBuilder.append("") + htmlBuilder.append("") + htmlBuilder.append("
") + htmlBuilder.append( + "", + ) + htmlBuilder.append("") + htmlBuilder.append("") + htmlBuilder.append("") + htmlBuilder.append("
") + htmlBuilder.append("") + htmlBuilder.append("") + htmlBuilder.append("") + + return htmlBuilder.toString() + } catch (e: Exception) { + println(e.message) + throw InternalServerErrorException + } + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/service/QueryPassInfoService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/service/QueryPassInfoService.kt new file mode 100644 index 0000000..fae2b0f --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/service/QueryPassInfoService.kt @@ -0,0 +1,43 @@ +package hs.kr.entrydsm.user.domain.auth.service + +import hs.kr.entrydsm.user.domain.auth.domain.PassInfo +import hs.kr.entrydsm.user.domain.auth.domain.repository.PassInfoRepository +import hs.kr.entrydsm.user.domain.auth.exception.InvalidPassException +import hs.kr.entrydsm.user.domain.auth.presentation.dto.resopnse.QueryPassInfoResponse +import hs.kr.entrydsm.user.global.utils.pass.PassUtil +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class QueryPassInfoService( + private val passInfoRepository: PassInfoRepository, + private val passUtil: PassUtil, +) { + companion object { + private val RESULT_CODE = "RSLT_CD" + + private val RESULT_NAME = "RSLT_NAME" + + private val TEL_NO = "TEL_NO" + + private val RESULT_CODE_OK = "B000" + } + + @Value("\${pass.exp}") + private var exp: Long = 0L + + @Transactional + fun execute(token: String?): QueryPassInfoResponse { + val resJson = passUtil.getResponseJson(token) + val resultCode = resJson!!.getString(RESULT_CODE) + if (RESULT_CODE_OK != resultCode) { + throw InvalidPassException + } + val name = resJson.getString(RESULT_NAME) + val phoneNumber = resJson.getString(TEL_NO) + val passInfo: PassInfo = PassInfo(name, phoneNumber, exp) + passInfoRepository.save(passInfo) + return QueryPassInfoResponse(phoneNumber, name) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/refreshtoken/domain/RefreshToken.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/refreshtoken/domain/RefreshToken.kt new file mode 100644 index 0000000..b622083 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/refreshtoken/domain/RefreshToken.kt @@ -0,0 +1,21 @@ +package hs.kr.entrydsm.user.domain.refreshtoken.domain + +import org.springframework.data.annotation.Id +import org.springframework.data.redis.core.RedisHash +import org.springframework.data.redis.core.TimeToLive +import org.springframework.data.redis.core.index.Indexed + +@RedisHash +class RefreshToken( + @Id + val id: String, + @Indexed + var token: String, + @TimeToLive + var ttl: Long, +) { + fun update(token: String?, ttl: Long,) { + this.token = token!! + this.ttl = ttl + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/refreshtoken/domain/repository/RefreshTokenRepository.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/refreshtoken/domain/repository/RefreshTokenRepository.kt new file mode 100644 index 0000000..b2c7bc6 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/refreshtoken/domain/repository/RefreshTokenRepository.kt @@ -0,0 +1,8 @@ +package hs.kr.entrydsm.user.domain.refreshtoken.domain.repository + +import hs.kr.entrydsm.user.domain.refreshtoken.domain.RefreshToken +import org.springframework.data.repository.CrudRepository + +interface RefreshTokenRepository : CrudRepository { + fun findByToken(token: String): RefreshToken? +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/User.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/User.kt new file mode 100644 index 0000000..48ac4fc --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/User.kt @@ -0,0 +1,46 @@ +package hs.kr.entrydsm.user.domain.user.domain + +import hs.kr.entrydsm.user.global.entity.BaseUUIDEntity +import java.util.UUID +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated + +@Entity(name = "tbl_user") +class User( + id: UUID?, + @Column(columnDefinition = "char(11)", nullable = false, unique = true) + val phoneNumber: String, + @Column(columnDefinition = "char(60)", nullable = false) + var password: String, + @Column(columnDefinition = "char(5)", nullable = false) + val name: String, + @Column(columnDefinition = "bit(1) default 1", nullable = false) + val isParent: Boolean, + @Column(name = "receipt_code", nullable = true) + var receiptCode: Long?, + @Enumerated(EnumType.STRING) + @Column(name = "role", nullable = false) + val role: UserRole, +) : BaseUUIDEntity(id) { + fun changePassword(password: String) { + this.password = password + } + + fun changeReceiptCode(receiptCode: Long) { + this.receiptCode = receiptCode + } + + fun toUserCache(): UserCache { + return UserCache( + id = id, + phoneNumber = phoneNumber, + name = name, + isParent = isParent, + receiptCode = receiptCode, + role = role, + ttl = 60 * 10, + ) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/UserCache.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/UserCache.kt new file mode 100644 index 0000000..dc3e53e --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/UserCache.kt @@ -0,0 +1,19 @@ +package hs.kr.entrydsm.user.domain.user.domain + +import org.springframework.data.annotation.Id +import org.springframework.data.redis.core.RedisHash +import org.springframework.data.redis.core.TimeToLive +import java.util.UUID + +@RedisHash(value = "user_cache") +data class UserCache( + @Id + val id: UUID?, + val phoneNumber: String, + val name: String, + val isParent: Boolean, + val receiptCode: Long?, + val role: UserRole, + @TimeToLive + val ttl: Long, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/UserInfo.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/UserInfo.kt new file mode 100644 index 0000000..3856c40 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/UserInfo.kt @@ -0,0 +1,18 @@ +package hs.kr.entrydsm.user.domain.user.domain + +import org.springframework.data.annotation.Id +import org.springframework.data.redis.core.RedisHash +import org.springframework.data.redis.core.TimeToLive +import org.springframework.data.redis.core.index.Indexed + +@RedisHash +class UserInfo( + @Id + val token: String, + @Indexed + val userId: String, + @Indexed + val userRole: String, + @TimeToLive + val ttl: Long, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/UserRole.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/UserRole.kt new file mode 100644 index 0000000..69added --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/UserRole.kt @@ -0,0 +1,7 @@ +package hs.kr.entrydsm.user.domain.user.domain + +enum class UserRole { + ROOT, + USER, + ADMIN, +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/repository/UserCacheRepository.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/repository/UserCacheRepository.kt new file mode 100644 index 0000000..616eefc --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/repository/UserCacheRepository.kt @@ -0,0 +1,7 @@ +package hs.kr.entrydsm.user.domain.user.domain.repository + +import hs.kr.entrydsm.user.domain.user.domain.UserCache +import org.springframework.data.repository.CrudRepository +import java.util.UUID + +interface UserCacheRepository : CrudRepository diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/repository/UserInfoRepository.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/repository/UserInfoRepository.kt new file mode 100644 index 0000000..af59bec --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/repository/UserInfoRepository.kt @@ -0,0 +1,6 @@ +package hs.kr.entrydsm.user.domain.user.domain.repository + +import hs.kr.entrydsm.user.domain.user.domain.UserInfo +import org.springframework.data.repository.CrudRepository + +interface UserInfoRepository : CrudRepository diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/repository/UserRepository.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/repository/UserRepository.kt new file mode 100644 index 0000000..1bcecd3 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/repository/UserRepository.kt @@ -0,0 +1,13 @@ +package hs.kr.entrydsm.user.domain.user.domain.repository + +import hs.kr.entrydsm.user.domain.user.domain.User +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface UserRepository : JpaRepository { + fun findByPhoneNumber(phoneNumber: String): User? + + fun existsByPhoneNumber(phoneNumber: String): Boolean + + fun deleteById(id: UUID?) +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/PasswordNotValidException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/PasswordNotValidException.kt new file mode 100644 index 0000000..ac99637 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/PasswordNotValidException.kt @@ -0,0 +1,8 @@ +package hs.kr.entrydsm.user.domain.user.exception + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode + +object PasswordNotValidException : EquusException( + ErrorCode.INVALID_USER_PASSWORD, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/UserAlreadyExistsException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/UserAlreadyExistsException.kt new file mode 100644 index 0000000..47d2268 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/UserAlreadyExistsException.kt @@ -0,0 +1,8 @@ +package hs.kr.entrydsm.user.domain.user.exception + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode + +object UserAlreadyExistsException : EquusException( + ErrorCode.USER_ALREADY_EXISTS, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/UserNotFoundException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/UserNotFoundException.kt new file mode 100644 index 0000000..eef5aea --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/UserNotFoundException.kt @@ -0,0 +1,8 @@ +package hs.kr.entrydsm.user.domain.user.exception + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode + +object UserNotFoundException : EquusException( + ErrorCode.USER_NOT_FOUND, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/facade/UserFacade.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/facade/UserFacade.kt new file mode 100644 index 0000000..9410fed --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/facade/UserFacade.kt @@ -0,0 +1,26 @@ +package hs.kr.entrydsm.user.domain.user.facade + +import hs.kr.entrydsm.user.domain.user.domain.User +import hs.kr.entrydsm.user.domain.user.domain.repository.UserRepository +import hs.kr.entrydsm.user.domain.user.exception.UserNotFoundException +import hs.kr.entrydsm.user.global.exception.InvalidTokenException +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Component +import java.lang.IllegalArgumentException +import java.util.UUID + +@Component +class UserFacade( + private val userRepository: UserRepository, +) { + fun getCurrentUser(): User { + val userId = SecurityContextHolder.getContext().authentication.name + try { + return userRepository.findById(UUID.fromString(userId)).orElseThrow { UserNotFoundException } + } catch (e: IllegalArgumentException) { + throw InvalidTokenException + } + } + + fun getUserByPhoneNumber(phoneNumber: String): User = userRepository.findByPhoneNumber(phoneNumber) ?: throw UserNotFoundException +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/SampleController.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/SampleController.kt new file mode 100644 index 0000000..8b28003 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/SampleController.kt @@ -0,0 +1,20 @@ +package hs.kr.entrydsm.user.domain.user.presentation + +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RequestMapping("/api/v1/samples") +@RestController +class SampleController { + @GetMapping("{sampleId}") + fun getSampleById( + @PathVariable sampleId: String, + ): SampleResponse = SampleResponse(sampleId, "sample-$sampleId") +} + +data class SampleResponse( + val sampleId: String, + val name: String, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/UserController.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/UserController.kt new file mode 100644 index 0000000..9ca2a82 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/UserController.kt @@ -0,0 +1,78 @@ +package hs.kr.entrydsm.user.domain.user.presentation + +import hs.kr.entrydsm.user.domain.user.presentation.dto.request.ChangePasswordRequest +import hs.kr.entrydsm.user.domain.user.presentation.dto.request.UserLoginRequest +import hs.kr.entrydsm.user.domain.user.presentation.dto.request.UserSignupRequest +import hs.kr.entrydsm.user.domain.user.presentation.dto.response.InternalUserResponse +import hs.kr.entrydsm.user.domain.user.presentation.dto.response.UserResponse +import hs.kr.entrydsm.user.domain.user.service.ChangePasswordService +import hs.kr.entrydsm.user.domain.user.service.QueryUserByUUIDService +import hs.kr.entrydsm.user.domain.user.service.QueryUserInfoService +import hs.kr.entrydsm.user.domain.user.service.UserLoginService +import hs.kr.entrydsm.user.domain.user.service.UserSignupService +import hs.kr.entrydsm.user.domain.user.service.UserTokenRefreshService +import hs.kr.entrydsm.user.domain.user.service.UserWithdrawalService +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import java.util.UUID +import jakarta.validation.Valid + +@RequestMapping("/user") +@RestController +class UserController( + private val userSignupService: UserSignupService, + private val userLoginService: UserLoginService, + private val changePasswordService: ChangePasswordService, + private val userTokenRefreshService: UserTokenRefreshService, + private val userWithdrawalService: UserWithdrawalService, + private val queryUserByUUIDService: QueryUserByUUIDService, + private val queryUserInfoService: QueryUserInfoService, +) { + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + fun signup( + @RequestBody @Valid + userSignupRequest: UserSignupRequest, + ): TokenResponse = userSignupService.execute(userSignupRequest) + + @PostMapping("/auth") + fun login( + @RequestBody @Valid + userLoginRequest: UserLoginRequest, + ): TokenResponse = userLoginService.execute(userLoginRequest) + + @PatchMapping("/password") + fun changePassword( + @RequestBody @Valid + changePasswordRequest: ChangePasswordRequest, + ) = changePasswordService.execute(changePasswordRequest) + + @PutMapping("/auth") + fun tokenRefresh( + @RequestHeader("X-Refresh-Token") refreshToken: String, + ): TokenResponse = userTokenRefreshService.execute(refreshToken) + + @DeleteMapping + fun withdrawal() = userWithdrawalService.execute() + + @GetMapping("/{userId}") + fun findUserByUUID( + @PathVariable userId: UUID, + ): InternalUserResponse { + return queryUserByUUIDService.execute(userId) + } + + @GetMapping("/info") + fun getUserInfo(): UserResponse = queryUserInfoService.execute() +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/request/ChangePasswordRequest.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/request/ChangePasswordRequest.kt new file mode 100644 index 0000000..b13f442 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/request/ChangePasswordRequest.kt @@ -0,0 +1,16 @@ +package hs.kr.entrydsm.user.domain.user.presentation.dto.request + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Pattern + +data class ChangePasswordRequest( + @NotBlank(message = "phone_number은 Null 또는 공백 또는 띄어쓰기를 허용하지 않습니다.") + val phoneNumber: String, + @NotBlank(message = "password는 Null 또는 공백 또는 띄어쓰기를 허용하지 않습니다.") + @Pattern( + regexp = + "(?=.*[a-z])(?=.*[0-9])(?=.*[!#$%&'()*+,./:;<=>?@�?_`{|}~])[a-zA-Z0-9!#$%&'()*+,./:;<=>?@�?_`{|}~]{8,32}$", + message = "password는 소문자, 숫자, 특수문자가 포함되어야 합니다.", + ) + val newPassword: String, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/request/UserLoginRequest.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/request/UserLoginRequest.kt new file mode 100644 index 0000000..7186d08 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/request/UserLoginRequest.kt @@ -0,0 +1,16 @@ +package hs.kr.entrydsm.user.domain.user.presentation.dto.request + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Pattern + +data class UserLoginRequest( + @NotBlank(message = "phone_number은 Null 또는 공백 또는 띄어쓰기를 허용하지 않습니다.") + val phoneNumber: String, + @NotBlank(message = "password는 Null 또는 공백 또는 띄어쓰기를 허용하지 않습니다.") + @Pattern( + regexp = + "(?=.*[a-z])(?=.*[0-9])(?=.*[!#$%&'()*+,./:;<=>?@�?_`{|}~])[a-zA-Z0-9!#$%&'()*+,./:;<=>?@�?_`{|}~]{8,32}$", + message = "password는 소문자, 숫자, 특수문자가 포함되어야 합니다.", + ) + val password: String, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/request/UserSignupRequest.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/request/UserSignupRequest.kt new file mode 100644 index 0000000..5030974 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/request/UserSignupRequest.kt @@ -0,0 +1,19 @@ +package hs.kr.entrydsm.user.domain.user.presentation.dto.request + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern + +data class UserSignupRequest( + @NotBlank(message = "phone_number은 Null 또는 공백 또는 띄어쓰기를 허용하지 않습니다.") + val phoneNumber: String, + @NotBlank(message = "password는 Null 또는 공백 또는 띄어쓰기를 허용하지 않습니다.") + @Pattern( + regexp = + "(?=.*[a-z])(?=.*[0-9])(?=.*[!#$%&'()*+,./:;<=>?@�?_`{|}~])[a-zA-Z0-9!#$%&'()*+,./:;<=>?@�?_`{|}~]{8,32}$", + message = "password는 소문자, 숫자, 특수문자가 포함되어야 합니다.", + ) + val password: String, + @NotNull + val isParent: Boolean, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/response/InternalUserResponse.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/response/InternalUserResponse.kt new file mode 100644 index 0000000..01141e2 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/response/InternalUserResponse.kt @@ -0,0 +1,13 @@ +package hs.kr.entrydsm.user.domain.user.presentation.dto.response + +import hs.kr.entrydsm.user.domain.user.domain.UserRole +import java.util.UUID + +data class InternalUserResponse( + val id: UUID, + val phoneNumber: String, + val name: String, + val isParent: Boolean, + val receiptCode: Long?, + val role: UserRole, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/response/UserResponse.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/response/UserResponse.kt new file mode 100644 index 0000000..c49daac --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/response/UserResponse.kt @@ -0,0 +1,7 @@ +package hs.kr.entrydsm.user.domain.user.presentation.dto.response + +data class UserResponse( + val name: String, + val phoneNumber: String, + val isParent: Boolean, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/ChangePasswordService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/ChangePasswordService.kt new file mode 100644 index 0000000..6ef15a0 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/ChangePasswordService.kt @@ -0,0 +1,27 @@ +package hs.kr.entrydsm.user.domain.user.service + +import hs.kr.entrydsm.user.domain.auth.domain.repository.PassInfoRepository +import hs.kr.entrydsm.user.domain.auth.exception.PassInfoNotFoundException +import hs.kr.entrydsm.user.domain.user.domain.repository.UserRepository +import hs.kr.entrydsm.user.domain.user.exception.UserNotFoundException +import hs.kr.entrydsm.user.domain.user.presentation.dto.request.ChangePasswordRequest +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ChangePasswordService( + private val userRepository: UserRepository, + private val passwordEncoder: PasswordEncoder, + private val passInfoRepository: PassInfoRepository, +) { + @Transactional + fun execute(changePasswordRequest: ChangePasswordRequest) { + changePasswordRequest.phoneNumber.takeIf { passInfoRepository.existsByPhoneNumber(it) } + ?.let { phoneNumber -> + userRepository.findByPhoneNumber(phoneNumber) + ?.changePassword(passwordEncoder.encode(changePasswordRequest.newPassword)) + ?: throw UserNotFoundException + } ?: throw PassInfoNotFoundException + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/ChangeReceiptCodeService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/ChangeReceiptCodeService.kt new file mode 100644 index 0000000..7927178 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/ChangeReceiptCodeService.kt @@ -0,0 +1,22 @@ +package hs.kr.entrydsm.user.domain.user.service + +import hs.kr.entrydsm.user.domain.user.domain.repository.UserRepository +import hs.kr.entrydsm.user.domain.user.exception.UserNotFoundException +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@Transactional +@Service +class ChangeReceiptCodeService( + private val userRepository: UserRepository, +) { + fun execute( + userId: UUID, + receiptCode: Long, + ) { + val user = userRepository.findByIdOrNull(userId) ?: throw UserNotFoundException + user.changeReceiptCode(receiptCode) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/QueryUserByUUIDService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/QueryUserByUUIDService.kt new file mode 100644 index 0000000..8f75f0f --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/QueryUserByUUIDService.kt @@ -0,0 +1,32 @@ +package hs.kr.entrydsm.user.domain.user.service + +import hs.kr.entrydsm.user.domain.user.domain.repository.UserCacheRepository +import hs.kr.entrydsm.user.domain.user.domain.repository.UserRepository +import hs.kr.entrydsm.user.domain.user.exception.UserNotFoundException +import hs.kr.entrydsm.user.domain.user.presentation.dto.response.InternalUserResponse +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@Service +class QueryUserByUUIDService( + private val userRepository: UserRepository, + private val userCacheRepository: UserCacheRepository, +) { + @Transactional(readOnly = true) + fun execute(userId: UUID): InternalUserResponse { + val user = userRepository.findByIdOrNull(userId) ?: throw UserNotFoundException + if (!userCacheRepository.existsById(userId)) { + userCacheRepository.save(user.toUserCache()) + } + return InternalUserResponse( + id = user.id!!, + phoneNumber = user.phoneNumber, + name = user.name, + isParent = user.isParent, + receiptCode = user.receiptCode, + role = user.role, + ) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/QueryUserInfoService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/QueryUserInfoService.kt new file mode 100644 index 0000000..94caf6d --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/QueryUserInfoService.kt @@ -0,0 +1,24 @@ +package hs.kr.entrydsm.user.domain.user.service + +import hs.kr.entrydsm.user.domain.user.facade.UserFacade +import hs.kr.entrydsm.user.domain.user.presentation.dto.response.UserResponse +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Transactional(readOnly = true) +@Service +class QueryUserInfoService( + private val userFacade: UserFacade, +) { + fun execute(): UserResponse { + val user = userFacade.getCurrentUser() + + return user.run { + UserResponse( + name = name, + phoneNumber = phoneNumber, + isParent = isParent, + ) + } + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserLoginService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserLoginService.kt new file mode 100644 index 0000000..041cdca --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserLoginService.kt @@ -0,0 +1,42 @@ +package hs.kr.entrydsm.user.domain.user.service + +import hs.kr.entrydsm.user.domain.user.domain.UserInfo +import hs.kr.entrydsm.user.domain.user.domain.repository.UserInfoRepository +import hs.kr.entrydsm.user.domain.user.domain.repository.UserRepository +import hs.kr.entrydsm.user.domain.user.exception.PasswordNotValidException +import hs.kr.entrydsm.user.domain.user.exception.UserNotFoundException +import hs.kr.entrydsm.user.domain.user.presentation.dto.request.UserLoginRequest +import hs.kr.entrydsm.user.global.security.jwt.JwtProperties +import hs.kr.entrydsm.user.global.security.jwt.JwtTokenProvider +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class UserLoginService( + private val jwtTokenProvider: JwtTokenProvider, + private val passwordEncoder: PasswordEncoder, + private val userRepository: UserRepository, + private val jwtProperties: JwtProperties, + private val userInfoRepository: UserInfoRepository, +) { + @Transactional + fun execute(userLoginRequest: UserLoginRequest): TokenResponse { + val user = userRepository.findByPhoneNumber(userLoginRequest.phoneNumber) ?: throw UserNotFoundException + + if (!passwordEncoder.matches(userLoginRequest.password, user.password)) { + throw PasswordNotValidException + } + val tokenResponse = jwtTokenProvider.generateToken(user.id.toString(), user.role.toString()) + val userInfo = + UserInfo( + token = tokenResponse.accessToken, + userId = jwtTokenProvider.getSubjectWithExpiredCheck(tokenResponse.accessToken), + userRole = jwtTokenProvider.getRole(tokenResponse.accessToken), + ttl = jwtProperties.accessExp, + ) + userInfoRepository.save(userInfo) + return tokenResponse + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserSignupService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserSignupService.kt new file mode 100644 index 0000000..a782be2 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserSignupService.kt @@ -0,0 +1,52 @@ +package hs.kr.entrydsm.user.domain.user.service + +import hs.kr.entrydsm.user.domain.auth.domain.repository.PassInfoRepository +import hs.kr.entrydsm.user.domain.auth.exception.PassInfoNotFoundException +import hs.kr.entrydsm.user.domain.user.domain.User +import hs.kr.entrydsm.user.domain.user.domain.UserRole +import hs.kr.entrydsm.user.domain.user.domain.repository.UserRepository +import hs.kr.entrydsm.user.domain.user.exception.UserAlreadyExistsException +import hs.kr.entrydsm.user.domain.user.presentation.dto.request.UserSignupRequest +import hs.kr.entrydsm.user.global.security.jwt.JwtTokenProvider +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class UserSignupService( + private val userRepository: UserRepository, + private val passInfoRepository: PassInfoRepository, + private val passwordEncoder: PasswordEncoder, + private val tokenProvider: JwtTokenProvider, +) { + @Transactional + fun execute(userSignupRequest: UserSignupRequest): TokenResponse { + val phoneNumber = userSignupRequest.phoneNumber + val password = passwordEncoder.encode(userSignupRequest.password) + + if (userRepository.existsByPhoneNumber(phoneNumber)) { + throw UserAlreadyExistsException + } + + val passInfo = passInfoRepository.findByPhoneNumber(phoneNumber).orElseThrow { PassInfoNotFoundException } + + val user = + User( + id = null, + phoneNumber = passInfo.phoneNumber, + password = password, + name = passInfo.name, + isParent = userSignupRequest.isParent, + receiptCode = null, + role = UserRole.USER, + ) + + userRepository.save(user) + + return tokenProvider.generateToken( + user.id.toString(), + user.role.toString(), + ) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserTokenRefreshService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserTokenRefreshService.kt new file mode 100644 index 0000000..e084610 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserTokenRefreshService.kt @@ -0,0 +1,32 @@ +package hs.kr.entrydsm.user.domain.user.service + +import hs.kr.entrydsm.user.domain.user.domain.UserInfo +import hs.kr.entrydsm.user.domain.user.domain.repository.UserInfoRepository +import hs.kr.entrydsm.user.global.security.jwt.JwtProperties +import hs.kr.entrydsm.user.global.security.jwt.JwtTokenProvider +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Transactional +@Service +class UserTokenRefreshService( + private val jwtTokenProvider: JwtTokenProvider, + private val jwtProperties: JwtProperties, + private val userInfoRepository: UserInfoRepository, +) { + fun execute(refreshToken: String): TokenResponse { + val tokenResponse = jwtTokenProvider.reIssue(refreshToken) + + val userInfo = + UserInfo( + token = tokenResponse.accessToken, + userId = jwtTokenProvider.getSubjectWithExpiredCheck(tokenResponse.accessToken), + userRole = jwtTokenProvider.getRole(tokenResponse.accessToken), + ttl = jwtProperties.accessExp, + ) + + userInfoRepository.save(userInfo) + return tokenResponse + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserWithdrawalService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserWithdrawalService.kt new file mode 100644 index 0000000..bdd84ed --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserWithdrawalService.kt @@ -0,0 +1,26 @@ +package hs.kr.entrydsm.user.domain.user.service + +import hs.kr.entrydsm.user.domain.refreshtoken.domain.repository.RefreshTokenRepository +import hs.kr.entrydsm.user.domain.user.domain.repository.UserRepository +import hs.kr.entrydsm.user.domain.user.facade.UserFacade +import hs.kr.entrydsm.user.infrastructure.kafka.producer.DeleteUserProducer +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class UserWithdrawalService( + private val deleteUserProducer: DeleteUserProducer, + private val userFacade: UserFacade, + private val userRepository: UserRepository, + private val refreshTokenRepository: RefreshTokenRepository, +) { + @Transactional + fun execute() { + val user = userFacade.getCurrentUser() + userRepository.deleteById(user.id) + refreshTokenRepository.deleteById(user.id.toString()) + user.receiptCode?.let { + deleteUserProducer.send(it) + } + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/LicenseConfig.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/LicenseConfig.kt new file mode 100644 index 0000000..60e5c32 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/LicenseConfig.kt @@ -0,0 +1,31 @@ +package hs.kr.entrydsm.user.global.config + +import jakarta.annotation.PostConstruct +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Configuration +import java.io.IOException +import java.net.URL +import java.nio.file.Files +import java.nio.file.Paths + +@Configuration +class LicenseConfig( + @Value("\${pass.license_file_url}") + val licenseFileURl: String, +) { + @PostConstruct + fun initialize() { + try { + URL(licenseFileURl).openStream() + .use { inputStream -> + Files.copy(inputStream, Paths.get(PATH)) + } + } catch (e: IOException) { + e.printStackTrace() + } + } + + companion object { + private const val PATH = "./V61290000000_IDS_01_PROD_AES_license.dat" + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/RedisConfig.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/RedisConfig.kt new file mode 100644 index 0000000..c58c350 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/RedisConfig.kt @@ -0,0 +1,44 @@ +package hs.kr.entrydsm.user.global.config + +import com.fasterxml.jackson.annotation.JsonTypeInfo +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator +import com.fasterxml.jackson.module.kotlin.KotlinModule +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer +import org.springframework.data.redis.serializer.StringRedisSerializer + +@Configuration +class RedisConfig { + @Bean + fun redisTemplate( + connectionFactory: RedisConnectionFactory, + objectMapper: ObjectMapper, + ): RedisTemplate { + val template = RedisTemplate() + template.setConnectionFactory(connectionFactory) + template.keySerializer = StringRedisSerializer() + template.valueSerializer = + Jackson2JsonRedisSerializer(Any::class.java).apply { + setObjectMapper(objectMapper) + } + return template + } + + @Bean + fun objectMapper(): ObjectMapper { + val mapper = ObjectMapper() + mapper.registerModule(KotlinModule()) + mapper.activateDefaultTyping( + LaissezFaireSubTypeValidator(), + ObjectMapper.DefaultTyping.NON_FINAL, + JsonTypeInfo.As.PROPERTY, + ) + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + return mapper + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/StaticRoutingConfiguration.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/StaticRoutingConfiguration.kt new file mode 100644 index 0000000..d83df10 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/StaticRoutingConfiguration.kt @@ -0,0 +1,13 @@ +package hs.kr.entrydsm.user.global.config + +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class StaticRoutingConfiguration : WebMvcConfigurer { + override fun addResourceHandlers(registry: ResourceHandlerRegistry) { + registry.addResourceHandler("*/dist/**").addResourceLocations("classpath:/static/") + registry.addResourceHandler("swagger-ui.html").addResourceLocations("classpath:/static/swagger-ui/") + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/ErrorResponse.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/ErrorResponse.kt new file mode 100644 index 0000000..fba51be --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/ErrorResponse.kt @@ -0,0 +1,6 @@ +package hs.kr.entrydsm.user.global.error + +data class ErrorResponse( + val status: Int, + val message: String?, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/GlobalExceptionFilter.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/GlobalExceptionFilter.kt new file mode 100644 index 0000000..9383529 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/GlobalExceptionFilter.kt @@ -0,0 +1,46 @@ +package hs.kr.entrydsm.user.global.error + +import com.fasterxml.jackson.databind.ObjectMapper +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode +import io.sentry.Sentry +import org.springframework.http.MediaType +import org.springframework.web.filter.OncePerRequestFilter +import java.io.IOException +import java.nio.charset.StandardCharsets +import javax.servlet.FilterChain +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +class GlobalExceptionFilter( + private val objectMapper: ObjectMapper, +) : OncePerRequestFilter() { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + try { + filterChain.doFilter(request, response) + } catch (e: EquusException) { + Sentry.captureException(e) + writerErrorCode(response, e.errorCode) + } catch (e: Exception) { + e.printStackTrace() + Sentry.captureException(e) + writerErrorCode(response, ErrorCode.INTERNAL_SERVER_ERROR) + } + } + + @Throws(IOException::class) + private fun writerErrorCode( + response: HttpServletResponse, + errorCode: ErrorCode, + ) { + val errorResponse = ErrorResponse(errorCode.status, errorCode.message) + response.status = errorCode.status + response.characterEncoding = StandardCharsets.UTF_8.name() + response.contentType = MediaType.APPLICATION_JSON_VALUE + response.writer.write(objectMapper.writeValueAsString(errorResponse)) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/GlobalExceptionHandler.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/GlobalExceptionHandler.kt new file mode 100644 index 0000000..801f864 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/GlobalExceptionHandler.kt @@ -0,0 +1,32 @@ +package hs.kr.entrydsm.user.global.error + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import kotlin.collections.get + +@RestControllerAdvice +class GlobalExceptionHandler { + @ExceptionHandler(EquusException::class) + fun handlingEquusException(e: EquusException): ResponseEntity { + val code = e.errorCode + return ResponseEntity( + ErrorResponse(code.status, code.message), + HttpStatus.valueOf(code.status), + ) + } + + @ExceptionHandler(MethodArgumentNotValidException::class) + fun validatorExceptionHandler(e: MethodArgumentNotValidException): ResponseEntity { + return ResponseEntity( + ErrorResponse( + 400, + e.bindingResult.allErrors[0].defaultMessage, + ), + HttpStatus.BAD_REQUEST, + ) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/EquusException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/EquusException.kt new file mode 100644 index 0000000..11faa93 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/EquusException.kt @@ -0,0 +1,7 @@ +package hs.kr.entrydsm.user.global.error.exception + +import java.lang.RuntimeException + +abstract class EquusException( + val errorCode: ErrorCode, +) : RuntimeException() diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/ErrorCode.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/ErrorCode.kt new file mode 100644 index 0000000..56b8acc --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/ErrorCode.kt @@ -0,0 +1,26 @@ +package hs.kr.entrydsm.user.global.error.exception + +enum class ErrorCode( + val status: Int, + val message: String, +) { + // UnAuthorization + INVALID_TOKEN(401, "Invalid Token"), + EXPIRED_TOKEN(401, "Expired Token"), + INVALID_URL(401, "Invalid Url"), + INVALID_PASS(401, "Invalid Pass"), + INVALID_USER_PASSWORD(401, "Invalid User Password"), + ADMIN_UNAUTHORIZED(401, "Admin UnAuthorized"), + + // Internal Server Error + INTERNAL_SERVER_ERROR(500, "Internal Server Error"), + INVALID_OKCERT_CONNECTION(500, "Invalid OkCert Connection"), + + // Not Found + USER_NOT_FOUND(404, "User Not Found"), + PASS_INFO_NOT_FOUND(404, "Pass Info Not Found"), + ADMIN_NOT_FOUND(404, "Admin Not Found"), + + // Conflict + USER_ALREADY_EXISTS(409, "User Already Exists"), +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/ExpiredTokenException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/ExpiredTokenException.kt new file mode 100644 index 0000000..f5a5777 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/ExpiredTokenException.kt @@ -0,0 +1,8 @@ +package hs.kr.entrydsm.user.global.exception + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode + +object ExpiredTokenException : EquusException( + ErrorCode.EXPIRED_TOKEN, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/InternalServerErrorException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/InternalServerErrorException.kt new file mode 100644 index 0000000..d3e07c6 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/InternalServerErrorException.kt @@ -0,0 +1,8 @@ +package hs.kr.entrydsm.user.global.exception + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode + +object InternalServerErrorException : EquusException( + ErrorCode.INTERNAL_SERVER_ERROR, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/InvalidTokenException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/InvalidTokenException.kt new file mode 100644 index 0000000..474fc5a --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/InvalidTokenException.kt @@ -0,0 +1,8 @@ +package hs.kr.entrydsm.user.global.exception + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode + +object InvalidTokenException : EquusException( + ErrorCode.INVALID_TOKEN, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/FilterConfig.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/FilterConfig.kt new file mode 100644 index 0000000..a4855c7 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/FilterConfig.kt @@ -0,0 +1,23 @@ +package hs.kr.entrydsm.user.global.security + +import com.fasterxml.jackson.databind.ObjectMapper +import hs.kr.entrydsm.user.global.error.GlobalExceptionFilter +import hs.kr.entrydsm.user.global.security.jwt.JwtFilter +import org.springframework.security.config.annotation.SecurityConfigurerAdapter +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.web.DefaultSecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter +import org.springframework.stereotype.Component + +@Component +class FilterConfig( + private val objectMapper: ObjectMapper +) : SecurityConfigurerAdapter() { + override fun configure(http: HttpSecurity) { + val jwtFilter = JwtFilter() + val globalExceptionFilter = GlobalExceptionFilter(objectMapper) + + http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java) + .addFilterBefore(globalExceptionFilter, JwtFilter::class.java) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/SecurityConfig.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/SecurityConfig.kt new file mode 100644 index 0000000..0f48215 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/SecurityConfig.kt @@ -0,0 +1,50 @@ +package hs.kr.entrydsm.user.global.security + +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpMethod +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.web.SecurityFilterChain + +@Configuration +class SecurityConfig( + private val objectMapper: ObjectMapper +) { + + @Bean + protected fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http + .csrf { it.disable() } + .cors { } // 필요 시 CORS 설정 추가 + .formLogin { it.disable() } + .sessionManagement { + it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + } + .authorizeHttpRequests { + it + .requestMatchers("/").permitAll() + .requestMatchers(HttpMethod.PATCH, "/user/password").permitAll() + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers(HttpMethod.POST, "/user").permitAll() + .requestMatchers("/user/verify/popup").permitAll() + .requestMatchers(HttpMethod.GET, "/user/verify/info").permitAll() + .requestMatchers(HttpMethod.POST, "/user/auth").permitAll() + .requestMatchers(HttpMethod.PUT, "/user/auth").permitAll() + .requestMatchers(HttpMethod.POST, "/admin/auth").permitAll() + .requestMatchers(HttpMethod.DELETE, "/admin/auth").hasRole("ADMIN") + .requestMatchers(HttpMethod.GET, "/user").hasRole("ROOT") + .requestMatchers(HttpMethod.GET, "/admin/").hasRole("ROOT") + .anyRequest().authenticated() + } + + .with(FilterConfig(objectMapper)) { } + + return http.build() + } + + @Bean + fun passwordEncoder() = BCryptPasswordEncoder() +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AdminDetailsService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AdminDetailsService.kt new file mode 100644 index 0000000..5e52172 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AdminDetailsService.kt @@ -0,0 +1,18 @@ +package hs.kr.entrydsm.user.global.security.auth + +import hs.kr.entrydsm.user.domain.admin.domain.Admin +import hs.kr.entrydsm.user.domain.admin.exception.AdminUnauthorizedException +import hs.kr.entrydsm.user.domain.admin.facade.AdminFacade +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.stereotype.Service + +@Service +class AdminDetailsService( + private val adminFacade: AdminFacade, +) : UserDetailsService { + override fun loadUserByUsername(adminId: String?): UserDetails { + val admin: Admin = adminId?.let { adminFacade.getUserById(it) } ?: throw AdminUnauthorizedException + return AuthDetails(admin.adminId) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AuthDetails.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AuthDetails.kt new file mode 100644 index 0000000..38f4a5d --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AuthDetails.kt @@ -0,0 +1,41 @@ +package hs.kr.entrydsm.user.global.security.auth + +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.UserDetails + +class AuthDetails( + private val userId: String, +) : UserDetails { + companion object { + private val ROLE_USER = "ROLE_USER" + } + + override fun getAuthorities(): Collection { + return listOf(SimpleGrantedAuthority(ROLE_USER)) + } + + override fun getPassword(): String? { + return null + } + + override fun getUsername(): String? { + return userId + } + + override fun isAccountNonExpired(): Boolean { + return true + } + + override fun isAccountNonLocked(): Boolean { + return true + } + + override fun isCredentialsNonExpired(): Boolean { + return true + } + + override fun isEnabled(): Boolean { + return true + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AuthDetailsService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AuthDetailsService.kt new file mode 100644 index 0000000..3a577d2 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AuthDetailsService.kt @@ -0,0 +1,17 @@ +package hs.kr.entrydsm.user.global.security.auth + +import hs.kr.entrydsm.user.domain.user.domain.User +import hs.kr.entrydsm.user.domain.user.facade.UserFacade +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.stereotype.Service + +@Service +class AuthDetailsService( + private val userFacade: UserFacade, +) : UserDetailsService { + override fun loadUserByUsername(phoneNumber: String?): UserDetails { + val user: User? = phoneNumber?.let { userFacade.getUserByPhoneNumber(it) } + return AuthDetails(user!!.phoneNumber) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/jwt/JwtTokenProvider.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/jwt/JwtTokenProvider.kt new file mode 100644 index 0000000..45cf5c3 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/jwt/JwtTokenProvider.kt @@ -0,0 +1,149 @@ +package hs.kr.entrydsm.user.global.security.jwt + +import hs.kr.entrydsm.user.domain.refreshtoken.domain.RefreshToken +import hs.kr.entrydsm.user.domain.refreshtoken.domain.repository.RefreshTokenRepository +import hs.kr.entrydsm.user.domain.user.domain.UserRole +import hs.kr.entrydsm.user.domain.user.domain.repository.UserRepository +import hs.kr.entrydsm.user.global.exception.ExpiredTokenException +import hs.kr.entrydsm.user.global.exception.InvalidTokenException +import hs.kr.entrydsm.user.global.security.auth.AdminDetailsService +import hs.kr.entrydsm.user.global.security.auth.AuthDetailsService +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse +import io.jsonwebtoken.Claims +import io.jsonwebtoken.ExpiredJwtException +import io.jsonwebtoken.Jws +import io.jsonwebtoken.JwtException +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.SignatureAlgorithm +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.stereotype.Component +import java.util.Date +import javax.servlet.http.HttpServletRequest +import kotlin.text.get + +@Component +class JwtTokenProvider( + private val jwtProperties: JwtProperties, + private val authDetailsService: AuthDetailsService, + private val refreshTokenRepository: RefreshTokenRepository, + private val userRepository: UserRepository, + private val adminDetailsService: AdminDetailsService, +) { + companion object { + private const val ACCESS_KEY = "access_token" + private const val REFRESH_KEY = "refresh_token" + } + + private fun getBody(token: String): Claims { + return try { + Jwts.parser().setSigningKey(jwtProperties.secretKey).parseClaimsJws(token).body + } catch (e: JwtException) { + throw InvalidTokenException + } + } + + fun getSubjectWithExpiredCheck(token: String): String { + val body = getBody(token) + return if (body.expiration.before(Date())) { + throw ExpiredTokenException + } else { + body.subject + } + } + + fun reIssue(refreshToken: String): TokenResponse { + if (!isRefreshToken(refreshToken)) { + throw InvalidTokenException + } + + refreshTokenRepository.findByToken(refreshToken) + ?.let { token -> + val id = token.id + val role = getRole(token.token) + + val tokenResponse = generateToken(id, role) + token.update(tokenResponse.refreshToken, jwtProperties.refreshExp) + return TokenResponse(tokenResponse.accessToken, tokenResponse.refreshToken) + } ?: throw InvalidTokenException + } + + fun generateToken( + userId: String, + role: String, + ): TokenResponse { + val accessToken = generateAccessToken(userId, role, ACCESS_KEY, jwtProperties.accessExp) + val refreshToken = generateRefreshToken(role, REFRESH_KEY, jwtProperties.refreshExp) + refreshTokenRepository.save( + RefreshToken(userId, refreshToken, jwtProperties.refreshExp), + ) + return TokenResponse(accessToken, refreshToken) + } + + private fun generateAccessToken( + id: String, + role: String, + type: String, + exp: Long, + ): String = + Jwts.builder() + .setSubject(id) + .setHeaderParam("typ", type) + .claim("role", role) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secretKey) + .setExpiration(Date(System.currentTimeMillis() + exp * 1000)) + .setIssuedAt(Date()) + .compact() + + private fun generateRefreshToken( + role: String, + type: String, + exp: Long, + ): String = + Jwts.builder() + .setHeaderParam("typ", type) + .claim("role", role) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secretKey) + .setExpiration(Date(System.currentTimeMillis() + exp * 1000)) + .setIssuedAt(Date()) + .compact() + + fun resolveToken(request: HttpServletRequest): String? = + request.getHeader(jwtProperties.header)?.also { + if (it.startsWith(jwtProperties.prefix)) { + return it.substring(jwtProperties.prefix.length) + } + } + + fun authentication(token: String): Authentication? { + val body: Claims = getJws(token).body + if (!isRefreshToken(token)) throw InvalidTokenException + val userDetails: UserDetails = getDetails(body) + return UsernamePasswordAuthenticationToken(userDetails, "", userDetails.authorities) + } + + private fun getJws(token: String): Jws { + return try { + Jwts.parser().setSigningKey(jwtProperties.secretKey).parseClaimsJws(token) + } catch (e: ExpiredJwtException) { + throw ExpiredTokenException + } catch (e: Exception) { + throw InvalidTokenException + } + } + + private fun isRefreshToken(token: String?): Boolean { + return REFRESH_KEY == getJws(token!!).header["typ"].toString() + } + + fun getRole(token: String) = getJws(token).body["role"].toString() + + private fun getDetails(body: Claims): UserDetails { + return if (UserRole.USER.toString() == body["role"].toString()) { + authDetailsService.loadUserByUsername(body.subject) + } else { + adminDetailsService.loadUserByUsername(body.subject) + } + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/pass/PassUtil.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/pass/PassUtil.kt new file mode 100644 index 0000000..bc04ac5 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/pass/PassUtil.kt @@ -0,0 +1,41 @@ +package hs.kr.entrydsm.user.global.utils.pass + +import hs.kr.entrydsm.user.domain.auth.exception.InvalidOkCertConnectException +import kcb.module.v3.OkCert +import kcb.module.v3.exception.OkCertException +import kcb.org.json.JSONObject +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component + +@Component +class PassUtil { + companion object { + private val okCert = OkCert() + + private val TARGET = "PROD" + + private val SVC_NAME = "IDS_HS_POPUP_RESULT" + + private val MODEL_TOKEN = "MDL_TKN" + } + + @Value("\${pass.cp-cd}") + private lateinit var CP_CD: String + + @Value("\${pass.license}") + private lateinit var LICENSE: String + + fun getResponseJson(token: String?): JSONObject? { + val reqJson = JSONObject() + reqJson.put(MODEL_TOKEN, token) + val reqStr = reqJson.toString() + val resultStr: String? = + try { + okCert.callOkCert(TARGET, CP_CD, SVC_NAME, LICENSE, reqStr) + } catch (e: OkCertException) { + throw InvalidOkCertConnectException + } + assert(resultStr != null) + return JSONObject(resultStr) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/pass/RedirectUrlChecker.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/pass/RedirectUrlChecker.kt new file mode 100644 index 0000000..a297663 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/pass/RedirectUrlChecker.kt @@ -0,0 +1,17 @@ +package hs.kr.entrydsm.user.global.utils.pass + +import hs.kr.entrydsm.user.domain.auth.exception.InvalidUrlException +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component + +@Component +class RedirectUrlChecker { + @Value("\${pass.base-url}") + private lateinit var BASE_URL: String + + fun checkRedirectUrl(redirectUrl: String) { + if (!redirectUrl.startsWith(BASE_URL)) { + throw InvalidUrlException + } + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/token/dto/TokenResponse.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/token/dto/TokenResponse.kt new file mode 100644 index 0000000..ddd32dd --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/token/dto/TokenResponse.kt @@ -0,0 +1,6 @@ +package hs.kr.entrydsm.user.global.utils.token.dto + +data class TokenResponse( + val accessToken: String, + val refreshToken: String, +) diff --git a/src/main/resources/application.properties b/casper-user/src/main/resources/application.properties similarity index 100% rename from src/main/resources/application.properties rename to casper-user/src/main/resources/application.properties diff --git a/casper-user/src/main/webapp/WEB-INF/lib/OkCert3-java1.5-2.3.1.jar b/casper-user/src/main/webapp/WEB-INF/lib/OkCert3-java1.5-2.3.1.jar new file mode 100644 index 0000000000000000000000000000000000000000..5b6f595f585860b750ca862c1242fff4fa0c1e3f GIT binary patch literal 198908 zcmb4qb981~wr`S(QL$~?b}F`Q+qP}nwpp?5RBWSSzns2(Z};hb=iPpLjQx%M$6Rad z{f+g*oNF#wiEqG=007|N0ETj+3IJah&>ufv7sS^sC9KF#Eha5O`wc+$Pk}NW)#MIe z0X|?@|P9*PW2VxkI$sU_!Ah`KEXF zoD`M#p{L5(dj_rDVTng+JcN(pX+EYVXEbgnF(ZlLynbY#OQd?NtrL;c4~F53ypf4$fLd=2o|vxR~FpFU*i zUt*yC9%E%==wxa1r_e5W;XnO3-~Sxy%=oA1&add9eejUjZvX(i-~a&F{}wH1W9{Im zXYJ@fV_>Q0;1HE$;qXfcDR}y@yx7bdMhd71mBvaww8jp+v2o=05X$VJsZl^1#43E5 z#&Wh@ruQN3O&B`Q^|vQE=&LdN&P8bAy);JeDYxnDh94D+Tc2;wsPG3T&jifLrpf6M zchm)gB#(h79FZ)Qs5@tLzo1@C)aWssUrl1rH|Rj$Z{=2Snfo_|3uj#-l`UK5YF%TA z%op?>H&7PK_B=vQv(Gw$EkL|0rfxCbV;_*2UgBm?GkDm1#GxWE-X}~OjL$^y%-!=G z(GoRyV5y>u)7T@VHWIW>`i``1`!pB`x-25CJp^DO+X4!5FVm#3)flv`u}IUOr9?Co zqzWw4g6n}jewt=ripZuYEfU8gfXp=^af?B=qe9I zWA}1T$ZD1JRgJ1MMxsm@=Od=c^fnm z&F@saFuO7x*NjS`!6ceE-VTs~F;}b1j2X@_*k#P7SEI+Qn4l z2Z8B>MO%|*JDpE2R4KLx`wk0lI03^#x5S6CT1x(?`CT||DAmuzi;*-O5p z)71Vx(&oAt9qhewDg3)0-LX%b5zCpFbgFIk_L5z?<-UG?sv9Fy`!4_dOyAiaBx{dH z9U&k>kCfw5{C&)bh_HsH)ABQ}RT5>Dt$QA7%Bm-ngAglApg483fP~=@jFxlOR&hP0 z-m|zdXTaEAIMwreFbo7kiOsD{`W$1F$Zvkc)xh)#BfJkc{f$ux$l#+T&Dcp zbB*9jNZp{?@=qMDYCD+Q$U!Q-7LYXotVrDun}sek&nT`EJ66}xeOLaQ3QsIgUO`vB zR}`MGp1e()U}f&O6mRfPdx;-@efB&ZkXL@Vl7HJE2g@a&! z7A1br6@5zX5MCdRb@l-8DnCIyNd)Ei=M-gMsX(4>*Y|<8=F@@UQfh)MBeC@AP!7hnr#bT@#X{g4<7kfjzIo5C>z+j z**e<%8~(rI{)c?<{%Vk(gPWC=k)yqt!T)nS(SI=B!pQC4<{+wN8>|BX0I-1p0ATzN z=MWYYlrVDpVPySZ%#@^Trh=$~<_!vjI8+9{3#1%{89Zs%OJQCGBqztWwhg3e$AI21 zI#Spl7{2l7wdL)vbDxR9b)Wgt)l=k?tK>fyb>O!3y7_#z#rpwl z3*JM1JaB+Vg47wlY$#+TIo6t=D_5zFyvGc(Oiye)4~aoXxEBo2LxaPLJh7{c_`r|a zkeZ6lunW1SOVJu71m~dNXt*9nk1Hh44`%GIDjVyMEu)KnVvFX1c6=Pa!0JC!4wr~&^Ep%B7MqorDMp>#fvW+IF-wd)d0+gj)arP&qTL|u@lF3{Rs#yLX~C_Cq(<#tT45PA z_AIms`^8#%cWY;@ZhN$m>Fr@X3OYvq=&iB1YSyDMf`C#rJbj+VccbEhj%LA1A;BGr zl~(LYGzXIeh>RKssJM>cG-qm!((i?BI(;ZUgTQ3MOj;@i>Q-Z?>Rtr;97)F@9|vt=h7XD=AYIGcTNd-j8S;fLA`McHsN`bKBAA`|&Shr< zwCgd@~jrrw6S8b+IWZdLsh?x%O#fd=~l^LuV8uZ*z^wR~y zG>tOyLibjrOKBAk{K8wVns(g^c7HBn5?hij&K#^@D>PL=v% zcTOPLHK|DsbK9u|{TNVp*a0|1-@Z_t*AaIaB+&t6PT|0KwW-soI|zdcG;XeT<`-qF zZN5vSNxTP1ff6xK?{ry$MD1&6H+n6f%=%%k)T7)11LUs*kx{X6bBPp>H+&Js?o~&% zg&b>^Q##%sl^$%+PT7FOJ z4?n_*Qa0zIQFKJ#O9o}qV53XT(lro;zL}?QC?q>ggh|e9DolFHk6RP5p-UD`A$8U? zvLJ^(GoP^EQHX6-1`P9LLJuns2ZXcLKGFSHbMuC-`KajKv{!;n$j7W@Iij3Wch;nWc_lO>M>L@M zO9sf~3B+|%krolP#75a;;#UcX8`!4fV>?$tPOJ7lO}%Z1i3wX(9aO!4j0cqW>%Zb8 zOQ7AacGkJya&Ju<;F!Ph{zJO{aG@>7U5de9-dEwv)9Na&_S)7;r{alk4*F@LFRA--UW9y5Sen z{f&i;DxnTw?6{O_!%Xn+cH46G{nz4e1o*+4kv$_)_|HRMlOnayw?J;X{Bd^nz|eWc zW$UAz3Of$d(^6&-Ho&VD1TF1iq+F*5(* z!rVV~mPK%oU|O!9||RY*I+H(~Py zmUGpq6%A2hEAPvq6d&e^N{iLLCSC~-2(2h=1%7f1#CDA7j>F{1aMz^MsB@a{-i7KP z%k4W$D%pL=kd{A=BNxD78;bL)jHB-olXCSVa~53(1?w>AQ*ymJA|mmjiJ+P6Z|%)J z-PsMUqr|I^cM=Lutd;KvCq=a&7qjvp@5LR~OP8y~Rl`=AFR-vO@`V9zmFA71g+Iw#TNf5x1sc%yQ-Xu z0CkTYSGe!^6T<~Ug>w3VsoN$TqW%7+F2y!TtNoPQuXiegipICRB&W*i7ia}xu1SM2 z3I71m#9!LdL2d6#V@mY|8-cz_Fz?v9=NB(7+83Yaj7-kMk zz(a54f#&8XjGnN2Of}@rCHZMo%AY|xtn*3t%d4_{(@E5QMT(p72in8_;4@WZ5oHMK{U7F2}%g zoHA88CO-|D^?XazW%+;uI@xWR9^8gZt_#8*?K`f%rRUqsw0MUfW+?zWOXIbG#lT}c z0|YJ8S7-8ZV_HM)qEYt`n`+@=un#Rhnj*TqdT4qa1wr5mm4Lv#7xLc$?tZ5S3qltP z)iSsi6F&mKf@cTXCjtZoHwN#&BlM1n)J^mTuLkGp3r8wUkhF}{_CzDyl8G)I1@5LQ zzGrot?9|E1H!yhu&N?ILkzw83eu(Vh=%q1Uy5LROA!}&+P%gbsT(n*&-{3MRgj1iL z5_Pe|oj;~ITo1vUCKta_;E{+j`v6VPTlyw{yU%s^+Y2b|H_2~OH|JV$0v+B1|8xT3 zx4{XJd*n;{VYqC!S%_sG$8)v~i zH<`1YoFM4LGbjMS*;gJ;{vR0IFG~A+qW#}?R=_wkk3t-o)!zcZz}CjjVkI#= z4nqj=7l&jp9YVZgsw_M!Qp@G*Z>Xm^*b8>*6?^ID6^ECCFFUITuI2~puza+U{o4IF z)${p&&+1$7wAz0d?4N;SDd0n#PLF3GJ4B6s<$)Jb1uOKK2iB4oJ{t>WZOV`j>4@Gh zKtp066r8sm4W^MBRwRdRhi(9gfMbbzUFe?}8H1FHa~@o*s~KE`cfsG4Dn<6R63~%~ zG|4?H8Ftw14+e=d=1R0z=WhlJB(REzE<@bPOvs#{fneT#Rf>4Jk!vhnTxnQ4sm&w! zQ;kqh^@up}74wCtbuC^_t8d7fFfU}@K(Kwa;`?Bm{6^Nof@_XBhW;g$_}g3a?sjW+ zkyL*=bZ|mkBMU9N!23pW(xD1FmY;-F%O}!_q)K-45)0d&W%l=HNhP2 z){ghu;hK;j(OF!`kS#iGD{hUX3!9tB-N%E_*=}~w!^BvcI_lzV=@~{Zzo+aukP=pn zDPt+Yd0`iivTTn#{5%?T=#0IYi%@wQh>*ES z1HX4LKHvxs+^Mo2hQAhDkuSXLdIHiBFV$guCk9^DEs1FPQ7zPTJy9kwOiA0_w>%YB zsl`oHmpB_u+@v=1Eocd-+jFt7w?&!#cOO}?IfxkX@G@S%os6PI^5!InJ9d1gy|ZN% zf^&y9)4AqzzTPr&|G|LbptwH%hORsUGrFeahiym*%mc6)(E z=O)X5V@1S42?^7t3d~edniFxR8QJ}Qd0Aq1s5p2c5`}$}>`cm;#TvtIuO!tn`<~fc zMQD+EL07T*_VQSfhH20#GG!Z6a_yoHlAOYX-pw-J2fi!iOULg^j~i2PUo0tS42Y!f z?e9~sja36}HR2MpsKbPnkL)$e)pIo|wgXwxbsT1e0#CC?ci-N6%@1X8evTncgZ-e1 ze!k0vE@693xMg@y!92oeR!->>dB(M!Bv|MIaQOt<|8OH)Ometp>KX!dcDSA@FsC>1 zFz9`-Q9Knu_5r0l=LuzPwNDRz5YW?@Ui8v@M6AQju=5QKaJ+|$s0Xxx-w|YAe%!Wd zoY^t2jJ7~tcUyeo)T}OMx6B^gyasAE?A#D%{ie3L zL2iu5Lh5 zh96ALtM5|hj%4gZ+;pAdpod1SO3;lE`1%}eTVq))`M>CWzf0~CXm7_$%`(fY86hI( zMa}=$`?X!vbHw_DT92yc$3=_kVM^d3bnEi z%|ftLLLyvhA*s7ZKi~A+e4suAdO!tQ;y-}5)cqwhL7fjOI*kV}I55y!6BkJ~%i$7? z-YDlkG+r^omXv1)xzVaW|MgHe5r@pw&R)?X2F+-aB6?o%J@HkJM zKfY%PIaRQ~EL;t`rL|$aLQZyPs*Dy;6~CJtSJvPg5ctwnHb#IypYHP_{SWDUHUVJbOf1^RR!^h*gX?XH#5fcUGfpG3_5qTE9o`2 z7%COn;;CvA)}m9(?u|sm0`MP(#QhhNB*G?G)`O+XhtBC+@rNgohX;*L^TPlK)ehRh z-Jkzd4f?}Uf62}N6m~HFN_$ML^!~l1v&HU21o2f~Z~iI^|7A(%ZzDy&hX0pf1<7mK zAn+q`D>PGC&JsZ0S0ueaEI3J$wmD37G4_1EJwfOq(o(68kk*>-;NzsZTK4`#M3g~vHO8-=_YWfw zd`q%ddP>JvCSyU1WYyMSpjt|#+=6kZKDmlW78ET$TZh>8G-hXRGKD z5&2AU{vZuD5If)goF=y4;@>oS^76SbS`Ax|k&gQ^W#QVJ-vZ}7c zKO6kw5}Mb`JC{l3*$fZMooaOzGgFlU6?S@u^l^nwIj%6JlJ}eBbmlJe%U&bn+L44o ztxe*@4#%J?aJp(?YC2r(5KMWo$B>kOwN%U)LVW)mQka4lN{iU}_e=R|;s^Kw=+>Mx zO0OU1Q1Zic-AL6uTIZ3R|iDVs4NB* z9ItCZmYln!#~>vlfDi&v@r>zx2YVNRd#aA2TA%Fs=<>y0mz-`)GkC^ToR4;w>>gJR z(_PPJIe(P$z}#R|=$3=VQQMIT) zh>MIhUd|mVjXU=nBTeW}iF#BkOw6Km9wA|0b^qkM)4F})$ihvmcf6G15cX)QZ!qex zI8^S1RjHroYd_(z*VaL@yyxp^RCiJf zwmxEXeR14FE{Sc@%0>+nrZQe~piEY=Cyb~bx1q8R=Mx$RWuRbj@{G80^o%)@A0wN- z${ZNA;ytFT8>GtdkOvxKe@RxWC*^i80#!qC`prRQ2=cOTe=%|jirgHHIs!2;1tfOO zrZ9CGeWLb^66#KMWJTv!@u}7Vv^>>J`u4hpp5$`Adm=$;i*MpF&V|rmTIW2prZwfP z!fl#7?i@E}VB+3b2FM{I%~0hk-D?`|GnU53N?yc0zozXVC1&1WaWvdI zn}55VuWYLY?%D!AP3^A`Z{N$kEwnNyuj3!Sv)1%u%-s)o)dCYVyZ7)VIi=r(GD$XH z8%07{e`Xmx{WM_>ARF@XX5!j z>F%p?I{@Dy*?1grP&M26yMd-}$p2kx%5FWl*VXS0bfNLa(^M6rHoc;1 zqM7pjctr%$3PT<`tWa$IYg9*EJySw1Pv1C$1C`x--PlOX7ZLbf*51V*nliKXeG1~g zZCsEeTq8|fch4lR->Mg!#xzaRIT)p~(%Plj(+^4$ zo9yq~5%O9xh+lFzPqR^9=?n6fZw^0T&1@MO!%UKkCRWbVXy(r))sk?r?OZY?jrmOe z0p(Q}M)YzS!?3v_0OA(smFjV5eA#xC-O=Oi^9iVn$qe>=OnBYN7WUCf5ldCI;U+iK z2`mW7dc}1_)|JG-ESqt^LOY|^J+IwgFAv|~#(Cumd}B8&l%XgX(skibz4&py(8!%Z zZ^hgboWonkDJ1Uths--4E^OrQR{}9tvUUcfu$wKSopI>Jvg+Xkocn5=OZSitXoozy zS37-IEI5mbHMU*C%O70d--~A%zMrKqZYpJl%_$i9a$mWp)6sMOy zX;z;1eSk7;ij()?mpFvxL@)7CdX0;vWxH!2MgO7CNWOpLlEF&%lX|Ap!{Z)&jG;>i zlZBc4dl^aaVp;eY`xb?VI_2c`K1DMf)rZrr){!VMssf_J24!ch5r-ZOMp~H0GDnidq_>awhM?ml0!7I9a`}V3I)PwJ_yC{XBd6hE zEu}K^inrSnBzCYq9C%1ca$6dcBTg|^K2|wa4!++NE5SZXo*qU^-9@R9fO6+?-$JC< z3j`Keyv`;aV(PtnCs7sG(Dqyv=cZ z@t~zx>DFpl@1azu`HVq-d40CRn!Z%^Wi!&+{gruZv0_p^La)kQ&j>$qr6y=}8ksUT zTQ-4Fo#oW%A!U84sM+BzBG|^3Y)%=KRIOXTS zfjaz1_T|qPr~DcY50OC{ZKQdZ<`7L?mGETJnH(MW$$Al~=3NiJla9^f`lFkw9dcvj z2{YGVH<;-gf#i)UJwSGt2vz;b6wKnE`^3J_?qy}jexmZHde^w_2&S}Z9~2fWSP2D_ z+J$AG5ObxAR3T$oFCN#=arCx#j;ww*Dt8HbJ^7?Fy{C}fdY9I&!IiZ4CgEG{Tsc8^ zc3W0!KpUe_@O8N&QE|;m=FtV=$T1}dQu6+-qdx4txS6L(yZMU(8MrYPuw5>p1bs>} zxO|0-A*RofBOZ|lF*EH>fh$XZiPf&(tP(;}Xx;Mu5JS2dJLM z-Tt7H0ZnraeH;XTab*}|f>_7P&w-Z|UB`n{7aV}f_M1Kb()AkxD%&8_FquML{CKlm z!D<-IXSikp#)=&`(9VKB$u6lk2p@kzG{TIZRdk0BGJjs||IDqpf3?{Edat-c!945r z3nva=`@CfT`*40?h5usHE@ce`EMcS%4Ck~_Loov2ZzO$z0BYcLfC4nJaB4JmM*NNt z;iitMRw)qu6C4xzp^v`|C_V@JP-_dtx4i0pp8e1E^v@dlv_#6^PEl@<^f>h$NJ8gW(d3lxU15p&h>i!3~*4V(6Y7)RKQyRPaKHEcy_CUS*_X_5MMM}l@*&@~R7M@~A3E=?+#L2Jmm+}crCqsBCs zXbZiezNDpacN>6jv$3v!EN+Qru~wWgrmp!5XBOIpbdP;)Y3h*VQFDHV#3X(X8lmMK z9hA5o1M#z~f*#bEu8|1m^wf0BZ+aaKoTU|d2eZ;6#1;aU1-bn`Y*!_Z4%l5=TC zH~a0#Hu|)qrN2wc+(h6^EK$X*wn?}t)pj$+%q?a-){Cwsqw7NEfVU*v!}U9oyio-*@} z2F3mKN*f8DcO-)rge|6?_mizlnuwCBvZ9ToD2WL!^1I5OPnAqGm+ck-ns|ezhCTm>UvE5BIbNqk{$zl%0xCLb;vy z=Bjf9C&rp<|5^hJiB@gGp4>l<$ldVM9xokjQ9mP6Ui}F#U&RL4cH?=oFQdJr- z&1p}M2FxLy9&LcKKc~%{G0#Ppfozzx3hk&KUp}=4Dsws>a%07~_#<&06i3unM%vcZQvOWqZ{i-#*agvjCh?{_a`?ZHtdKqYdDg_-%oK z&xF|58JnN><(P7feJs(GI`j4yDce;G@2*nMd%#@IxCOMy@h|a$quEHPR-sUy7rT|1 z@A`7|GNMpfwPuG9?bk>f19Jgdnk30&+3*=N`}Fio2`!kdVq>9;Ep#GFoi`&TlO2B7#8bET4mQH?Rq z0$UYwuqnUq;i|P@wVF=zI+VHZ7Rp1$dwop-XEeSaQ-0Gd1AZHJM=PAgpL0l6#Kljx zC_E;s(B6r@5d+}rro$ier*}S{QReO#y@I0 zbPoxh7heQw{zagFxu5V4{^ge!`+HLTPYkM8PXzA|h^Pkyhyo<(8w(XHl++G}M_3T8u2@p{Yy6ncHQ0Wy$@qN#9|SsBKekbRIlrOfCL#o(*l2>Rw68Lf9Ry)BiH>%M8-xKT%vk^l3IRt#HSxOJzrmGk z@0%Pg`4ilD--{?ecDCq~>JTXcPg3rZdS6vghG^`b)WH-qOCjeY`DbyAg>)=52dL!qxg@&H@gXS-Qkb2+x&ht#OfT^SmXsdeHPz!pwP;U8zRIu&ZoK!~M(qn*3#B8xZJ}|51|@Jxtz@Dk8w6ccOoDB?ms5}X2X`Y~ou zmDB)9qbFK=}F^Sj+6H2iia}ciVAqouo@yy>|WL<7XPd3 zuIo=Q@oWVml00cuqKGf<{QR|VG(#~!Af3YJO)wwHr!rPuWZcAc<4RXbT8E%Gd8+aJ zHX}yaQZg#|!ewf5T`#=e*s6Xal>DoYUXgn&>0FO8gYt(x*k1H%Bj19Ucq!>5TcrK! zHurXKFQpz7xHeub9>Yb6E4g?kB|mXA6-Vt|`~(v2n%qrdmY5x_wT)}L)y(1&HVj{q zn`7U=32*SOnPI5#o-k`qt9nHHTSbK;v|YgSrl^?7Sff)!^`_~=M#b#BnKdc;%h(Hw zEn=LHxt=RSwwW1w7mh+iUNlS2wWEPm@1jJr&s%3=wZL8d5!Ml5zr4wmqKN8G$SLq+=NZymDD9wRyJ3Ix>@;ly`8&ZtGL8pzjAjzwA1ogZP;9CUDm zlOT0InZE|fcNMb~NWj)N&0VzKMIk3N-WtNLH}55n_AbXW;S=nyJ?mfA1OCC8|F`}`T@)Ouldl}F{HsUt zFY5t+p5n{9{;4@dQBC11)BB*&0>bxAltRhN)eonR8wBA;s?{yJ>m^`}l)>+sA1W3j zl|p}s@LWvs-#zZ&9lKdiB&Y~*Y#nFca+!9S>LUHB2D}35f}*2y%WA+U&>?n1 zk#1m+65=gknr(wVGbv%98$^teBOh`kslMx4w25l$wPqiIpzCO(lua6b?vOK@zlC%( zqhpa&hDmu5_oelj2Z|bj+3-vi_6GCd#{k+Lq;ZfQhZfUAV|+rzm*32phi*}Rz^am` zOlmz!4_4l1wjxg3*l&WdR+w4hYH6$Ra1;`i8sw>rkIug`M3eSec)Sld#D|Fn;H05P zms7;eQ)-oSlBy{-%CuZ0t5kIhX%3SA{d3!B+EFOCcJQK81y4Jip%4z58+8TA*Y`Jh z#P7HU=|$$Jc5?f3E@U3+P)PULA0}~1eewRNxoc9Jl5qVK&jL5aKO|OP+l>yZccGs` zzPM_+UQvp(N1eF}%LV;KOeirvoMjwcs=a!T!Zl0YIz|6E%=YSGwHQX{Lp}Q1?YhJ&vP=t1JJ`kk+BA|r z{ljz%!qT{r^${AvvV090C49jVW0`w#QI__I#~Qt3Y;$D^=Ae}d>Z!ho;G36UN`?Kj@Bh}w)bO^ckcJxyZC1Q zEg%XV*`n`*Yy@`~@IKVK`4Fb(C0Qbj1?)^t;17c?urp{6QIOWJ1+YKyf=!E2hQafI zpE>>SzG(}qN5ieW1cpt}3v>*FU*QOZH!jU4v-wLZU|)pDdJl!f%<^|nakTF`t&b6P z=V$CR;NnNJa8$wjAR$Ec8)6clfg7&-h0{+EhJ#2xwI`2aiuiIB48)sZ5!&z_v(Hi} z?*$gee5c1~1>tz6xrD#uc@Efzn*A)yc@FrOwk}ugtHkp!;M9Hr=P%p3{slN+p5@QT z(NLJ@L;65j1D(T@=i4VhQk0BC034Knr&g_%f8mQmA1aBxY^*bz6H^HOAkeul%AMH$ z13H%CaTNiRjy6ZH51vQo+3)wS!}PvthDLV+7krXKNH`Ly$#5xjgoH%O%(J|45Uivk zw+#NS6gz2=G^PG^cEs2C^?_g2b;RwCT-75>@qr?kyh-%e(?qxaQ%b1yEX`wBuaOAmcRY?zD9iz5$JuP(g}Lu`@4kR}_QS zzt;Rl?^XP=1#47kV`8d`*BFUiqZ#D-soXgfoAst<9x^QxZ%UbfJ`NHsQ0mdY%}|pq zij$6L)B>Byf{?9e5xH`w%b$ry zR8=Lm7gX@A=XuLlUz_eL3`pW_({JRPS z3NpoM(p}lf#J$N0VSg289ZmIntUJC!piA3w=R0?bxPQ2BpTsI?7g%DqueflyozU?+ zd=Cqq9O%MkUYZDZ0S}uKz{4aftSshlROI!04P0M-XgL|BXm|mL-}FFtfa7t^f{iww zy+RM*y;+2T&foEbIWUeRaRrL2{jLEMbLRVdcj4Pd+$;PpPe83r_O@pGuNNQT1xPQJ z73m$l;e=F%gh8J9YkPSDGAE;wl7JG1RwH$GpJf2_i zZutPEil~dQE+`KeN^aK$ib2>xC@}QtD_I)2>RA#ZIYGm57Vg>JHQE%0V%(tANsl6%P_GHyp_r<`o5gaD(DgIA=Ytz(#-!O zFDvt{O_Ayk?DV;p4O~!tVW&wB^n`r=NMXC^W)pwWnHgUqs{T^5z|YWEyL z`G2b;#rukBng%FbOz1=T|Jm&o$`TiZ=XWwRG08h)t z=!MAmKm72#e##dffcsTVqevTEJM{5RNP?&Uk|#EbnjAbBs7w1W%1( zSeNW=%?w@-KEjD$UMN5ELWiyKt78@~;{Smh!c;+|TeMrJE)okgz9QK%9eQ_9xpU18Q!9TLaQQWmBeFs|#RU(&NjSb1VU#D=an}{y=&M zR% zhk{&)MD>;Y=0QrS)KU;u=?ma7K-N>_t0vUZ7zE(ojo4Ft()XKk)%D|9wJS~0snuh3 zZYFVpq+nhQrUN(EG-aKD8TYscd z;N8dW!ZM|GJ7RaShZc|GTxawYXF{PR+2l5og~FBGyry2xYuv$jjF6sZ#Yzy+9*_VN z`88wJUv^?7zQ14}_Q|zUYf-&rb~pAyNnS_RK|Q^%kz8tL4&Flm057@6&_A)d$GU-| zk<%JeDIanrY>NvU7;h|^6=2>idx=XxcP3znmOQOskaunAyADU-!DIO0M30L%Z?fk` z2i0}l2=lA_u*@p^;A&Z>cmNL5e0sesZxWen`d+98hr#f}OA&gu*HK&Pd+SLK{XLk7 zOKOh|I*XIn%N_Q~H~X*_!H4b3b5VNDd>5i_IXZf|c*eq$Mh5lac&%F6M)Y3f_`GcT zzzQP5cYujq3_Nu9sH4|{=n@!4FV5`inG1IEd0RQSP2hjqaq zT)hO%BlS|Bk^IOVNc1vizTEv6+EYPWeHtVZozVUW)w$+1|&)9g9hoXKj%UE@T%l@&)12QMrenoRi*QfAI<+FoY~bbP%Ct}>nL#Z|9kET{*|?xKvx=%P z`8E}TNc=?cL%KYE&w#ybdC6XBC6x*pvM67q+qEvWO7c))(H!O01o74n$K(q2_kpb)O?cV6~$w}G%{=h!O_?U21x_pwjIj0tdjm7?@My6_J zsDq{YcQVGsbdl&mh|ooGjPBdjU2`ib!k)E6s;WxG2H9I(8?4U(YglI%kNxhDJ+%sIVQ+efpi0_Qw_yStO(I4x#@Bw3^d~5P2J(9Lg zt#J|+@g&1))740cjLlPZ>!RdDG_D{;r!Jmr7EnQ3In?!W$PAx*%wjFCK3aW<1=NcB z#qv9>tD{B?CrS}wjg1k(Ye@DdND2#1L`Yf%G%h$h= z$!rJRtxq*qlH)N6;Qe(hm>q5JkzWyjz#KfRvm1if@6~b}soz4Pn@7XM?RRYJw7a6xSaq6jInZvK; zIcQ^QOu8H8#7wrP!unsvdNDe4)+C=x6TZKih8KGwm7MXk^F!c*l=c4@K5qRwYgH~k zDU68$;}f9(<+?6Gl|xZIWSnwkGVi-Qm)f4TYm%5GfqQ)}DRnPnhUA6K>xk7=2jUee zhuRd6%Uuma2nIsOV~E*e5Lt@{Mjw)U(*RX~){W6UHF72T+9Z2%D3vV3(VY)xm{Y{3 z$3elt5E_@UEIO={@62KxoFi*7fJGOA%A>zFpQ6KT3DO$9x3zEo7LtC$z5i5Gi`m=x z?b;n6p=HYZxOJ@MmFc}1zZ){Q;Ovjnj_!^5mH2e|44pe0Jhbo z?|tig`)4{=bjpICO{!VnJ6fdeQmY3kYsq@X0&VIZ0@yB`yigw#2oZKHC>aHQm2MCs zd~K*{bSi#Cun+dnUPpU+Qnwo`0~$=2s_#+!Nckn1E>Rg@Mlf;g$eqsA0mDDOnfwx`)*|#KV3i$K&8(e0Aq2 z3g3xRv@#LEvB$+XFc6!VD(b&#>PMtkcb-uA@YB4LpID9t-@*-}i_Ah#K_>d}hSnpB zaX<%ZIU;NUqR5l|>ivOjq?i{8bWqEOj(o5S7eASC{lOfsQ>iC9h3I;XC{xeNvO$3&b~Scf{Ye!iO)kXt$a& zfsdsvQ@d!ptSur=l=fcB>pK%%-o|zJpUTLzCEI>0c&=e`j2?*Gm(bV-f{y-Cxd>Bp zaF|8(&eGSmGMx&X!k*AgJE%7&$B7)ax1wMRr?KkT3Ke`b6d{3%F>V4vW3cZ^7%6cd zn#%VlBN4L36OL8yy{sc3f^|dn8G+pP`2Km}mT6F;l_< zcwFY+uU=qnAjEY1&$rz1K&5$X$Nl^OYtG=CO6q#8wzgZWeAPUZG`O!YfV{wzJh6ME zh!m&45h>}NmCsjIDECyWzqf-gLY`TnFVPAw6RC?-!Ss3&DaMc^)DI#-tm=mc3Q0~E zx(hB_E#LogbYqKBx7@*5r82)kfoWieAEk#1;*$~x9}!Jf+R!X;Av$G}y_;-)Q}iIrD-HYhka;KK%(V`nK?K`jG9Iq7!Kdb;mw+vA-OL(lqOv3f%;OV z`RPI<@kT@len5z7looBhj+~<}nS5-{(a*>%zG|k!GAZ6N3er+b>xHj^fTlb&B6Ovl zJ-mE=qYxeaNbr0QE7CIp4R+I7xX~BEse$3X?omz!Swky|%#aXrHB@LxsKHTU_c_n= z2@B>TuHjW}#OEB9&0)G-kTd1mRBZOD9TyfNqac0hbz5>8sYz}z~#(Kaa;qST7i13r!R9-@NRS?|Y!t=PCVUfW2X zz^f3~p!bo7R;#$~^keRJ$|^6aEtM{B-VtgOk$XSz_Hs%t7G2Q$;WsZ6eebIS-pPQS zcQl)-oNcFDg-%?a-ftHQwQuO!>@il0n-W`b(2sL22fvC{x~g`bTM~}gpLyP?(Xm2|&&)b% z(~wt`V|g|+ugB?b72V1ELwxe) z=U~>@WBJk9_stwo$fNb<9(-}|V-87sD%UJH^QcQO)5E94@0+#e&YSiAcx%zr z-nDORe>UDn4$HYHAQzFE#PE_i?N@9dMG@H~CP=pyA``e}X|(Utyc++l&r zWY)tx7bra-ZH_gCdPYE;-XEjs?V)Q-bidoYCD=jVY95EK8tJZ%{bd2OCS?B0KV`$_ zuioJ1MmvXe-GYThro_j7gjf$ z{VJY`oTk&PJP4aEv=VPIzb-W=H`_&sEJ3E?pRHUrE6AnvDm8wb7yMS^MsBouF8t?AuPnlpND-;65qYau)rKB;&IaPU#mNb`G?qz2G^a zHikwjV#PiGzDPZk2=Jzz?wZw_QZFI&v|2uG;urq5;I17NQORv?M1l>U2zQe4J&=aR zv8M}%MsA3Oiq&j`QzO%iIfOM}`c(hYo4tkj;gpo-g&lN4q=M-c%)j0nXL#f@`x5gJ-Bk* zp|Rr&1OrXMQv^n+j_`Vmj29ZY6fek~CN97C(;q3s90372odKr;`Peun$!kCzy+mv8-n>B<}!o5KI|aVaaI^}pvU5!tsQ zIK8jz)&;4*wNiHS#KPh%BJAiyQUX4Dvb^1=*yNo$XdphM_c)Dr*(bpf2`2v~CNkc; z_es^hR+@Lla3z)Na^>|kC?)K+5!3&@iRwER^3XsNi~@=Y)T}5HPVEj{)Tg>GmJ}|A z&Sf(tb@gb1w{VTrghsfXaR|?LD!gIYz-#_&qr(nd4l~l=ZM+Bro6YlT>W32~tKVe`RaTXZt{s|> zU^0OKQRX)o4N~HK3Vd&K34YV>&I*6_MoEM+FBfsGY51nfDCeQRgun6Yz?qIMH5*{n z|NH_{r3D>GX!n~}%&z62bTy|b7w$sze4C&W*kqV~Jve?MfliUF4;x<^c5%%AbXI=x zTXYzDa73FF#PY#qzz~1H{%7yv^f4;BvA%h8p#9(apX5KiJoq<%9;c$XBz9k)nD3-8 zbF~CBpJGs8Q0R8W1jk^UP=vn2pu)1F|M(-A3YkdM|CdKWC^$0GNx4W}22-gx+eu~o z>!7@dgv5B_OA#J(^~?euurj&&nxFlWhHA!n#_~C{dPIA7&h0ei_NMuCXl2yizKr?# za(*`$11=Wr{NdW;Su&37Z!`^Z-l|--=U|ktnUqj%jS_gr7dp} zD3$RKYmXz`-0y+Xp3Xmoowuvqu7=^iaF=1!w(58j@}BrUa-4;_u2pxo4dMkPrN3i-eA9FU zcUH03KFHg1xVn(EnvWbb3;cYW*jfWY^VBjDEYGyZ1I{QN-`3!PkkBJ)sazSFO! z^cCOx1jYKrYIwEyzL7&;`x4!|UugU=5rIr{tEYQ#>?VY3C3xQN3UL9F#sZffr@|i7 zX!MMihPl50n?91tP5P0JPV_NY)7wP>i7kfLXyY9!-}en!s^+RwjD{rDM9`-X>?mF) zZlzFkOX_e5rsp_P;Bb&Sv7C|;~R4o&#_D{SN_0Aj%a8$yk8Fy?z- z=#7b8PLLgTOCq`IlbJ6304^mLv-`}BZ%+2KCU(V`p^w0zx8NUDyX?vQ^|rVALvbz+to=$kHt9(E zXIw|-BR+o-;{&{`U3p^84@x4aKI(Q$fmOTue%N|astK@GLmw)28y1u82A1#mdn@d{m=xT#)tbR%)C90 z8Y8^WA2WJkUE(SJxl}*=D>Y8$pn|17XLQK7nY;mgm)__UFFSdIW0JiK26si7J`rRf zVCCWcG}nscH7Cd}dMo1jZURJnF?D7sggwLWRO||JI0KuVl?Bsm z?`MG0(ByVy>!SD6=CuWW`i=@=J_>8%ixQWHu8&N)5N0Ncg+CE0ELCdj z6ec8s7n~J638NkJrYL{KdLq@V$6v-`jiq^OJx~{-GCsq>j%q1I4~1SIjz9jm6_i-o z+lbxs+pJ0Nvz!({F^3fX>4-@Ze0;ln>*qicWItbn0tc?Ol}P&G{#Ro6JpQ-w8JMUX+1sAS@f_$F2`IXfxQ$sn3$uvGcr#E1eoYHSJ0$bYG^wf}w!Co_}#!-YA z^~(Vt;&HEBawI20R8xdqH~ZlDr*dn&jm<3O@Q&{$q`WVzyftjcm=V&@cH><}(PG1C zdXr2(XS|B|2Ggw066=$03Q1VOo$0=9BSY*-%&>ZVk5o)Z2}Ja0Bh&z;5_?rh|clPx|J~=ERjk9+gxXr1kWns{de@Et)CR&)6i>O29o?YM%bNbQXeZcyGbP= zvG0bLgyE@?8taAjdSki?%+Lf){~GcLk#a0~^2;5I5l7OZv80WK4hD=84$gI^_e%G$ zMX{fr(}a+8{z)Y05pF}4#5&0v`2b#kd_Cb3%(pWl5PO^Yr}Hk5IPjwyiamR*1LP6P zU`JD!oUhsSB!*sy;8XzOZVwkDmB5rhRcz|F6~;70_9htolT(--nFVDuK}b}wI^&^6 z(n-#@FzBHSng|i2f_I{;@JP-I_l1~}WF3G4E})8Z&v(iD;rm-?Cq|rE0`)=6$-2p+ zj=gxlxqh&Sti1_AWHo{fBmAT(Y1_jTO4?%5vCexybHlO|JIdX;J4MWpKV^WI1EWKj z!Y%qmipDX7D1H5<`*CB6>$$u9y6eFdBcpZ@Z&7uEA{sWWs zYy8@dkeaH`6pOo~zm1rHCxi=${fMgLjFp`jDHrjepR$Vf?-{jW0A(b$Q5ShsrEr@a z8_`cfF!LWWj9d-{C776QNVf|z0-0HPAl(!NPB~UPTOy-{*qs57E5R++=$uU3x)|kK z*ifKni6wrM7 zmGG`t`hY|*Z^Mv&8vz^9^t;^BCT|>a@5g8EuE;m~SdD$Op$_`4Ea7s(=tl4F8SKj-1RV@t35pO+uR!Bx9|ke63}Axfr~F7L zf3gG&NxeOXn2NJTmAi@1MTEkuGibKIMG5=DjGA3%jLJd6rxmdV!$ghe408^@{Z@+Z zOLCi{6n!hvVM^oPfMT!s4!P##4~m{Au{O9a@lZ|$2Z8cvEr^Wd2etynkJ%sDqS@fo z*+m@E79(%zyyrUmg0o!KVGiCWUR1A%n#1G}@zs$O-=m(v@!Z_N$uM7|V&U#l`fSia{vBaH=1R zv&q=}jo*3`?B78={b%dl`f{n(VVOSfsBE9MeW8eD&Utkr4&)hVo723SHaP0l6vm_0#C8SK0B7~ z1wB!R0fUs4!0u<>J_n`cN4oEcSlvj|ZO=zJ`^D z4g^u-7$XkgxW&{ZkJ@x#4uE#z`95Z`8NG+YIAtvuhav^Se|?7@L(46ryAC7s;gV7R zSBzHR`**MpSlQBfzFEW}B==MT3Nb(Cq~937(~6)pL&8%DJSP(gf~O%l$^H7v{~+{P zj_JsFha$5c>^bF^8#gnj_+ZR>4S)G3j9bF9i&)M#jpAonWNwGJ=o{HTY;$5tn>eh& zm@_Mf z-Ft3P>8!Lrop0&+)iEi!J9yu8Poc_X?-;>sV;*V^tO*oSL>7e2^$;N}YtjD-C1uv);2M%mf4z*5!PZs=|_s_bHROohVTKLAq5rbW2M7;H&wy0wCxh2!UYDXf?6bH+Yf(G@eZ`2>_4O#~m5;u@)Fo+le76YGUg~}E*3|_Hdyqo_TT(009 z#f-v(Un|>AKENH>E(Se&;Aw8=`!`>_DSJ!e#5sfT9(d({izSFtl0*xCJVNG zrI-u5M#TTv3cpF*$RFy;H0Ns)^o-IPRES=4RMO@qgl^OBwa)K^aP(_|B47!IxKPAE zY!%IB|Dx@N3&ute1a^IN?=nl0`I+D*(N9z*en4vXMBUOI7n3n+Xvqm z`?d_K7((&4t*3}IRXM8-FIJ6Q4cErMqU~Gh8=FrX?HNTNJKCAHC_d7o@y0;CX@|0i zeWV)CeUA`}4#W;j8xA7_;vX<>grsE&;cp&~NXk;60np#?_?l)6q=?Zudr8|Q3;58m z%?$i9QE%qWgG%glAfOM*GT8wFUR-7E$B|fA_zL86e`q%`_9OYiNy^j)-z?^QFu)#0 zi!(xX^AKyB5kx;~X;{;(Kz!`uE89eC<7~mOk;Y~XwdgRMz&QFb*=FYUF;d#Fto9h) zEzmq@*})I->*RC5`?>wwH;>gT9sxF;(ia4y*k+oN!2!#opvipE$c^dvXmsx{&IaSyMT9d*%m4~yNyO>-DoS`xMTAyC z5?}@cd-Ei4#x@>|#YX<6i`|YU@|@BdKOxTIM6sYO4i4`db#q&&@5i~?OI~%#bKsjC z8!|g1DeSfCk$ZCs3kUbq9JnZWs?0Hzrb~1EATv2K4y!Fl`KDA-w^T4re||KSm;+i{ zcmL;{nq4xAemzGLO3vffzgl+NmCK^fH#D1Kq-eRq^_OaO{;l~Vug;#0kohAYU)y}Z z2%3{y8O~4lZEuY=J^fSU=EGgmObo8+KTL* zz{vZ`V}AZWhvvgO@Y_GWDf3j0rq!#VSQkF(YR%<4y-d4aJ3lhid5xqUJlt1)dQ4j> zj@yXKdOCM5JOm%pnnRD(hUAGb$>z!SuxK?jG~|CameoWC#JHZ%fqNJHERGik z=$1MrzRb)GSFPqB_0m0>I;G(tZJG@=UroSGUqfP)tJNw$S;?BIO?^{8_FFjdnO2Lx zuRlTPQPcPF)l-x3;m8~tcvZdQTVSFi=li>*|92am`mrooo3r1S%#Utxm2f3Z)9odJ z5FheUz%K!(ydLGyT-UO*Y`+<+H|o~>K6ma&+plI#U-8z~t-*eBe~`GH`im8YiDzu=bXoV zsMCyypWjgPHPZf4awxhX1?4Y~pUCxhnzs^NBRy;~M)F`z!HHS04>Y~lv4YRR+p4xG6 zWMSdL;9XG?$Lj$LLJGc?+iaaZXTrv=12ui|fp}-9AtW!D_Ge4({+rF)iU%#o!o1z! zG5(0I2c7Y+iLi2kHKQiAw%h0L@qfHa=b_ArHn*mt^4&)ZbkpF2y z8DY2Q##Tk84-2+*Zr|7%?eHwu(9&MkSfo7v{5?24CF5%KYVRQO_B-BB?OeTX9e+w0 zr^#~v@Pnnef`&?EVyhlyS3^BI;!Sv?Q9z7p@b&iCaO9rXlFy^3S)Zg)0Nf3RYh4|rPPHZ?CDYQ!qd$ck(SlZ}w zoJoF3v2v`1>S}S4)U~ZwJxPz8IqDA z!-1O=vgX(kLy-9KrX_bEgCL-)zboaw96OjiH2 z?C^~J#Wc!6(e9c1r=8K#x(27eKDS536Q#^&W96$3eWj5qR9rTNS+w%0EP0Lt{uL_Y zY99sCs7>+5MWQEiLZ)`-oK&LM)4sGjBBWxCB!#{-jYZqL`4H}6{~V@u65PMiby@rF zROt!7tMYT0&)L_vvfd{X<>xRvPixtH)2CgP)Zs=)!IJqVzFmBd)Z4YE4YNyj?#vaN zmH5lk UH_te8XT4|#eoTuHMo|T@QyST$UJZYm(3tsA+sTz*omTi3Lc9S?#M>oFT z%$~G9S>NiPS3g>7FRexyTM-}YlI?e{NKZ&rWa7>3FcsuLoe|=%_!s^F?;~bWmRJW2 zJ)B{L`ZB<$G4w4BzSoJR%Pr;e$L9|+Eg{X%g})acLuzSV!>M&Ip^9@;M@V1f6*aHq z6~lclvB{o#Xmy|E6)j$e6+QNdGlffT6=N1X^xpb7aXL-7eDl)P{=O1dVUr$@=ZLL$ z@_X{KVjX@lWMsy(QM~I#d&LcN*X-P`#-vf&VX~{oGA^~|VzR>KjFq=E4fc_s$^%tf z5T21dXeavIUgc%=nv;iceRkW$Nh&cRJu3GDWylR{ECZRjlTiD+MfwrkMe)2jCaW&A za_u9W=x|ydFBuYSMR2@P(p{>Dq-1xc-8$`k_w+1}V|mW06V>vbmtCk+4l#EiF^5C$ zvIVOYCYN>x-^P*!U39neW8yw>=Np3FqT?AiQw%06)WA>*FM(dLjRAar+Hc*}v+FvK zzgoI3(_A5rBH3Y%%6*4-mmBvsSCZdOrN4Rp{JRl(JL75D(B!WB4R`i<@yJLalEGOoA(rskik z3PwmxJTsWYsvtv68goD*yfbok>s6>9wZ(|2Pru+qfqup_ORJHY#i8@|C!bVKa-Z0~ zDO9f~37*||ayPALX5+Ugg(kR1e*Q*m*~B?!q4<4-0Dy{N&&Qat4y<}0X@h)oSEZHrGFP)NV zUVEN7=Afgb?BOd`E1#t_l_+-7U-!rvv5iSk-$}AYBH6f`=@EsI&?4H#5tk!IueA#m zlK@$x_|OQYtN-0qPdE82DF-=xQc)A_x5}Z<7kb& zXN}lkB_ZRe+s@}&?0ipCPPV!?Zf(m^ZD@5rFwz*5H#5xH!tV z#mKlN6tIbz4P;!%z0m5L^|x0fF(mQ~D=BBU;%8@zuWrHy<#_uTIJoGCM`Atu_z1y* zD8u-2Sy;9VlB^vm)oxtt&6LY;T6r{=oH%usA&J(jS=;kBz%|%<*2oW5iVRkk|Jw!D z@TX2)*oY2RIx>#*)N&JAI-sCB=|MylK-BP zkxSk@fB#NPw{FT*+wSGSyWJ_9awPCbg8N&E`fZ!#sB? zT|b;R&hWV&<&!(CPAj-f5HGnCCVqP1UHoqAGA3Om;C;8Vx?NfPEo0o#y~C4WrC<|& zoV8ZPE&uLkQ=P)Ot6F)m)@*S`1?uoqeRuWf*yfbV{(cU zAm34Ul$PGXPq%O;tbM8pIh^O@BgPk?TL6CYt+xWhCI%;}N1*6Zu}hkw+>k-2#4}4A zS(pnc3nt$Jt1=;r6d&+KpWgJ&O;#GeF=~EHSs-)6xf~X)9`UF+&;E=DO-TF+)Z8fT z%7P!;!AWv6g){BShJP|Ko}GB~Of0athn`i3As43+0T*O%Hy@4Cn)K6{XTKu&Qv8iU2N!KccYVDN z*Q6uQ!?VE@ii?iO-`X^4{VqxptJBSAGa?wf*sZ^XcF?ZSvH4|QOOV=mi-|{75Y*dG zJ{aOut4rtCG4FiwXKAy;6@t$DT=o(>8CrVSvG_wq$>oqh(+;gjG|T!I9a50Pueh; zFS<<85RkHkRrhU4s27H9P_d#~@YQ!;9S(jk?X8sYzJ{%w1d4-O4@m!_HtP?e`WdGu zW4`pj8hPCjr*>7HabX>27KC0u-Y~QE{MY=j|^eiDfhV&eomRGg9)ps)y+>*Ccw?(BPn;X5?YaOVg-QuumtG zmtCQT&#(23y43VCX(WhCuare?j*&7d|9Ph+w-oQpPn({7Gk}mRBdE1}2`)+py!1<^N*_rk%#M zW30Waw$vWfleez_q$|t!;HY_YG|}jT3Cb7~T91+vFX7e@+I2dlm+XPpNE7%7d7(R% z$EfUj*WA0UPC+)|?aBGY1xIM?k=u&8A=3NG_ZvR$y-~ej+hJTchGVmDMh=cLCEfwP zM@@AyL*cbZlo9X+!T{FlWL8&@@c~WG)G(w#r;Easc+)EHbZ2oQ!cr+6{Zy!4x+IdJUA@Q(juqbV`1wTgp)d1c#$_*niz#<{5O2OWsm`K z3DQWuEw-+3DxR+NB4@J9B2%*DqF`M(r$AkDjTlRMjqw@j0%Q$qL7^RWVMK(-HtAO& zCwuajMUFqY$9SsaZs>)U-tUqoGB9-|7uk~~yvULJha3qz=}SG#^`Cgqx}Z`|+Lu z&oNIsW0i5Bj%6==?RW-p@h?*RB?TR#B@yp$WpQrb%OYzSH0Aj|Xo~T%*2TMhb-+K~ zTC-cAYrnM^ML)@Erl?E0=~xq7C}_X6%!j+3{PylAIH}m#a8}| z;5co~abaN%X`#MdeWAgC__)b{@A%08;W*HM*4F2Y>bPT#ZsAiUBjsyX}Ri8X)M!NA>; zMplpZhSb2InPn1^3OmoUY}eX%%v$c&XBDosaA74nm`z5@$L&Qk%a~1O%L8lSu0?^f zB|4-{hRZ+K{e(137;X1Co3Ix6C&>cH*H~lK$|P5b5xixS&MHz@+P87YeC-fdg%p2w9i}UWY;Cs$L{O@x^}q zed;rr?VN)F*nVNH{yQfFQt-b6i!b&#E`N?7*u1QuhzG`LD#Qv0_A zv`R^qbK)yww=}qVMpFB4H>AoymSZB0k!2!#n80#=rbim3_A`iad4b6Cd}c&?u=Yk$ zyDg8{5^1(w8eA78soi5rVEJLTUs|?)L{fXmm36|2X;K=c;jg6jge&_*_TR?XL{|Yw zRT*EWv}|LBr1ny}W$w0KVl19VGo-3(kaL2PxmS9yNf*R;$vLyGt6}mL;hTkH$VB zdd2`^gbO3KWc8?tO$?Hd)W(PcS}B{826teA7*V8%EDgB_q-8tDCACR1jB~fw2|$b( zy+oD~9)Dx;A~qpaats_3qUZb|M%-Rv%W_S#-0h8bAVvaOBFlbm;P2McFR9I`z&g=! z{vE_f%FHscaV`pCB+nKJGDBV30M4Xzm>h_d9qKX$Z~#RfM9Kwq zSpztLq5vZ0gSwmn96(V7kqSXw-f&D{PDw{Afds^$R~Q@1b7ufC}_V3E%*V8c09`dSw7`07V@npaZ>f062i6 z0TR%MUikx@8R=+Ekbp7tDhA*HiWW$~9D4N&-~fs?NWdC;RR(YX1$c(AhhDV-oLT8; zU66n?^lA*?0E!++z#V$E25cqw$tXf15sEtR=_QX}Q}Q?{A*e!~j(ZP=_ux(t*}Oyj84FzS`zp(GCFC?$zg*J=d59z@9*dEt{p< zKxYR4P!!g!S_;VP2x9Q4e)t;^{@2+b2DIWKiA*@+UW+!5dit%t|$>` zix_A?9zdG)>Q@7&wY+gxWCvVvEnvb0s@(u`7=YftD^drlrU5b(XloKcZr>H1xB=JY z{G@gZwChdk_oKV)R37@!l2h8SI<8UTdZi}haHV!g;xmm->ND*nrGM?8Q@XIWSH#Kt zP$(bOav%a!#Q;@Ddqre!0NUwFEt>nGPzpff0jA`fQm>_P4T1H&q8fl<8`Waf2%rJg z4Nz$TlnT&@rIdd4Ab{H3E6Vssmq0^}fJqVP);Xi)AU~tUDmuS(;c&m`#`B@@8L0Bl zFZEiP)M#GYFGBNrC=>&#Boy_p7n6m(AHE{Gz zYCQK$YWN%hFaRoeex=rWd8L*K^sow0gn$ze0%UA}mJ7g#CNzikqKVGV<^`Z=-esN; z@EdQx#3vQ-i(S75D`d3p9o>VOGFtnto+LCz^<6HXB-}^!ug;#j2?5*wJ(wLpZr_8y z0g#&~iKty>AcV&kFY!GA^X5Gmh+rI#m+aDAW@Mm#^b-GBz>l@<5+4>Q`Y!QdOHJcM?Cxh)rfQ?w9PPb9^8qlK1bxGQIk$JTKWmXGuUWY`ywJPEXx7 zz52Q*ci>MXe#)D7U|Ro0FS;`p8?j6u=b#!MY*YtG~2$2Tldr08XZs`(;BNsM)y# z1Lsw@e2(u9K=U@4fk-u~-ejHu>US;xh?{pP;B_$SiSxTqS{D9*btF_^ zl78EXO_0%c_Wwmy@HJ_tF zN_KQ#6b7hSfSSBpDhPg(uXz1g?c(Q3QrD^q?LNkS{uuS zbv|a44Lg-<))|a>_6ixXe4S3y-M5OevpdHOqEyBd3w5ZqjX>)s=8v(i+2gF2@-cN^ zn}3q*TLewqT=f%aD_e}Y9*LKOHF3PdSxH{jRjhbx>Rl4#l+Xn&oUmr!d*t!TbMtreXd3v8_Z#@~jg1R2y<&MaG}I%@D7 zuPY2zxV5U4^==7Ma;3g|joQ`U3LI7|9HYAN{&(>E&=s~@XN74Xtqj^o*9AgPY}S0J$_(<>JYD|gD|U#@oat;SIVW4= zMq~$F9mhZN%1BqXTJkXdF4oN0=5P$DF#Tis|7Rf$tLpI)hJVk@Ju`rD>DU(+M#l9TwJ^ZW=MK!34uf!gE zwpDIXIZ{!q-NJX#Q@Y$`?7m*BT-skBVe+_s1xHLsCA|2vVJN}F4qzx#_U03;K$o~h zuB6yYJg;KY@)F|0jiSnMXv&4vG;`gN2pNn~KF#&e-$=8U!K2J4vZ-Qgi#eeX`P5;*( z-no$9(kinsJ!ij+iLlACbGa>CfdlEz9Woowx~rb$OZd-!)B z9$+)%`0rqRamuw=X(A)S_6z>HsNG&QMeDD=Ir^2Il_05wV5rrJ{NYqq5vh?;K)-(E z+A?0)JlLkw^+}jpL}xBb?%PaePqa=qg6M%T4?Q|^CJ ze_q@rYck8zv<^Fnu%2+&R79}oS~y0Gq}ey}4Kj>FQ$SrQa1+(TqDOG&yMO2hm4W?x zxx(Niw=FZo$il}ckz*IX=F<88%X;J!Obn>_f5ROTcK@Y2);Ev?KmJ+bk4UaNckLj+ z?h>yYhaK?Iq~Wd@idzoZ!}qw|Q1m#@jzADr=^k4RtB$Jh~@E(O=Tn-{do`;q>i${S+AKeHXKc?<&{df0rUFi1k~~ z_chL?D1MnfE!K&Dap(C^Q<)kXVJdpx%3kptye+4VuO72j`X&=AE_q{djUf~V49_1Je&u$VM`(nBh8^Y^qAKtF`5iIQ)UUepu>f$iM6ml6me`CI$F| z7zmI5oCNbf!s8#CsmWV9+ZwtUTl`Cm{P_16S^r0jAOkVdl8sHtHI@8Ja<~v7;jv5( zc+F2_iPNgfOWqoIsWJV!m8_!%xn&}8i9S~9Hfc^-6 z&~MZ$MLx0YZ=>y9Se-kqzp0~<8h=&^Fn}b4V0qPYW3v{2(FZV?UVW5P8N#fHrpgj_ z^T|o@5WWyR8BqD$>aZbi7+Nt<$Mg%aYo0--d_i|8<@Mb^KYRIDY+V)seyIWc^69^r z?xbB@9R9^0(691}zzThVT7C8?Y6G~UJ}Hq{^{I9O4Qhrz7!>cBVrY&$Z1oS~_vP3x zL(3>m?rDpqmlDLW{rtWC;H=$(9n6ubw59t7m%LBKy9R_OkP9U4vyjO`SQZpyD()JuU z;@3I8f4EUcaf^8c{^9uXX=OEN_jyunFImT&8sGml=bX6D6{h;1OHaq zHhln$B#zehU3dy9l_v~#sb4^iVvNV61Y6PuORqluewyRkOR1&QO$$eOIS9Mg(4Vox z9u`S|0s)bXYS2}Ja^QJoo%a>iKl_}P5w=PW@HrjebFTlw=U?pH3~ek;L~P9Moh)4} zZ2!goKa>R&fqRzE;OsgQFiGOjsQXfrZOK5!f6*j^@jj+^JK042XcTZJ?`kzQHCjhM z6PnYx$A)x9aEcn1SF+oi?=+|QJb(9v857qNbR)BPoSCO?ns=UBB+MO|bkiX2A0JhJel1kAgrZ zQFVju=7F+q$0sQ!a(hv{DM{C@uI`UIy|>jtY?Hm_&_^W00NUN6G_l*_2-EV&IY8R)r7DtE zf9)x-gOS~u{_TilN^zd3{H39Uila#XpB#SUS>MH-kQ7;r!0GoEB@a0Cn?NJfE{SN=}=3%Ke7iB&q;ukAve{aryq zZLKCoB=laG0v>boDYfjED?=?ULalG+q`HOapic0ppWxLAkps@C-4{F1bMx(fnxh^C ze$cp8{Z&6_C&gK#@R3XNHvAadNxRx~7U*t+r;_OnOQ{ie-zj zpv#nd5ApS8Be$)}zM`lJ014}AbU#PNR*p)YopE2*vvle zmQ43qH#Owlprarqc6-PL(htc|I~|`|Y~BeX8|{mpL=70?iUThSlIeoVz49i3yPcwU zIm|x#E;;?JX&6JliM?w0RmD=eS&cbEp~2gZ2l>rEuL3ijL~V-V0wUl7uN8ZXFM4}k z7Ct1#to=WXyw%?c8bGwpnT0wr#u8wr$%tPoJ;5$9+%tckjPF z#xwS>SSw=984+`S0cA7BiQQP)q6wxU^1{SpGmW1UOj5^aYtRjR%R_{e)nQN2WxbKo z50)^GI{f{Km?lDy#)|wf?)?wL&3n>DdF1cN{L8!bzk7>InHd;aI~e^VERhQGHnabd zssE`S3$(jz$eC$P8DNED08ffxaNeW)i@rw;4Nr`MO;tsGHfd1QCpMH$BQLa?^fTlG zWv{gd0yE{=R(9&?z;lb^=<)OO?t{#iJBrv+xTl6@r!>NZ(2G`p)Su*B>@OoR_hnk! z!furb=cD*RieIOJsxr>nozt4l$zn{fH_M)iPM5VN!@wwN<$Tp*+J*f^fy9FNMZ9+v zRg(2nLeN#3b2T~9FoK!lrt(^$d#7@8?HGbp;%VaT2}`}G@~Ou|OJ!n_(i_Z9a*@?* zwdv$|#}#+|oIXUEOfu5|%iQ6DUU&aS(PnK+$~c25Q|2QPH$lLs=$ye{a}yEK6RwTi z!NfcNK3*)(?KxQTG&9?^y+Vt;zxWTWM<+bFtRkHX&_T%gLcb=bSzK38G8Nb|9|ME* zE{g6arW@MUBlTgSXg;fZ%}pp>K1#6!@qG{mfo|oo^YG`Nj;B7a)QNp!(Rbwj_(O=o zNMhECsN%u2Y#1$TG#p{Gq-N7fJZ3Ts*2JaKQSUMsy1Vf~oV9@7px7Wo;fFEnhsfIb zUuR-w5PQ0SRU^nuY%6g}t^XKgX23(heh@ct;O&v2Llgs}Ls%_z_)b>^Hbg6&gAiVR zMsY*7*>w}O2=FyP5p}`&B7`17D=jmJ1v3_9Xzm>8l3?i1r1&$0eJ)O#Y!8WKT@kdx zrdjX*(3CkDL96)x1NEf;?XURH!M?G@rPw#N@bGRC z%^?!#I%wr5U#}rL7b}8htG;r9Z?^d}-ej-TB1pb8F3ErrM6=#C1Lt+{m~Me1P#4Shkbu0TZB*w4$QNDGOL-KLmo)Keed6;96D))93VaaR6 zF}E0qTJTKOQyj+3=xLEb5@R$V+Y2*{&D1W&2yJ+FRyaTu_hhG(mH+!tt=J$*-Y5=T z4n&_hSmB%%&jRiDKRlkDN|)x?zi&D|*muSJZ*sA$g`knW<3FPjZ}Bf>{h%$Hz#(;E z=-rPXsKOjjD9T-$BFWH4W27?FR2g#2h@>Plz;O5ev#^OGcIFNEGut~}l*!zW@atZ9 zj`pea`YVq1^!L|)U3H3%i0v+aaCv&}2);f{^ zuhgi2D6_Ywv#J&}n@o=v^|gDMf@~{W59#kWoUp1~veU%RdCwUb;VrzieI~8g11E>d z=IYKD+K6b2)$-pOPm)4^GMsnXufZ6!vNbNui#SvQToi2PD2}uyR_>3v#89_PWybc_ zxTg?SY*(#3GSQ-Or~7)Pg%@$E*OqkkEq$N_=PjMpSxkCSrC~n-_d%+B2cPw#LDTD+ z5ug4XLGfRU?(=qM7BZ3@-jg}hbadfN-N=HVbjR$<%F3o)5pJ>cIQ_(6#|En`vEfqE z3Mps=7r_)zOy1u=X!;@`>Iv6TcF+RVV!0*VcJB9yNgjv?ARkuJ(`5`IcV79Q+GL@c z_{oZ0C#wF8-O~*pKk-H}vHRx>eFza?_nQqc)bs}okP+h&{-(I0*quYlCg7yH;n=-H z(!uW{zaiNjLxM-viG8NLVF?ut5ham9h!)Qv#!Ythe{LHf*u_FBKF;;XcY`-`j6|6e zf)5pBrX0C(HHGHMem}Ym|3DY=K4$vs^P+&h@Xc6nG15}9quq- zl%g9shwTM3nD-Ln>1DUY2IGtrBnZakqr$`IA6M|t$oh`#cfnQqR)70X|4*a;$#v1m z>DlZ36GlWTsM#RNWAG#^U?8qRZIo!7DUfRg6;`fx_Dwfn<^oCRd*TCHdTZ$ggK}?l zFuhEXrQd;GT|8O6lM69Hk?}55uQFaHo7dN`y1P7oA`JVXkNu{vaIK}(lm1iZn*vW1 z;t{KxVrnP3bV_2~=s3sDCBsyM9K=O2XS$N6m)zi<9_{_PY-;)OUOToumQ`x5Hge?P z7UcV?-9EqHW<0;|Z*?MKr6CZfN9~j*k*q|rCY?~TximYR8WE>bCk%--UzoEmIZRQQ z-6Vd80)HneF=OUExqVg2a{#KQgC3P3PKP-p3)O0=Hi1+0wu=^EBFg6I5lw)NiR~<> zM#<0uFir$;D6zRVZLl9H%X3vb)K;nGmd_sdg**v0f5_M{2+fC&wyx8cUvMVV#WQ}% z?MSCrDgIuduFSnZVGU^R)u5>)?MRdVsT_R4ga5#qXGOerE&&rC+dE)I&6gJpyWWS5b2Dp$?9dyHFhuX& zPZqM}bp7KdCqNb{+y2^YmkI8-B=`Qd!@woSv)3*>_B#O%>1BegUqZ93%OQ+#a<_kH zYY^3TSKl#ICY5W|&c}Q$59$33I`cI(?E9a*mnuZr8<=CE@JtaxD8RfG$_Wc+j^$2r z-WbqROdG$enVP&yTe=HC<#fSF_0%=5`yU6>+q@K8{`;Ly{CzlC|C`#N0N{5pGqL`^ zGEb8KVrRW3hI0dhSn}JBB%?0asx79Z=0SMmxRr zQ{j%lT8x><8rzwLYHgK;@-pwQZ?Ibv6c;1_C5!_T|EmbYUg9}!jb(@Gq%~Re4dy(B z0C1y;YRgt)$AvP|+Pw4@-M$7MlxuY|svDeJqpN)b$7XrS8fm^Q*{r5r!9Yx@El6)D zeG)b|T?+l>zhtF|_kKpmt)+I1WF{YoYQ7?>&fVfVFvc znqi{mbt_A}XlnMg60u%v8r@F@QXFQcG8SDh3?VWIvCd528IcT4jE3gOKsJO|L>{y5 zl}*;IAA>~k{N-QNU?c&a5>~W5Y1cov+7D`MK7sEbL4F76zboJXBYS5f`+pR0um8%S;|F193LYi`jS*#RhF6|IjBEL>t~QSVmew`*o-xRAkSeH{=Z7gd)maaPX|xK z?ZNJT1odQzFi)6e8j~#9-!EfxQx`#+g_@0Hh62t{K6(_K(g8K*OK7j&H(Z9Ky>`Qa zk*MnksX!G*T~l_VW}D~_{(VS~*}B0>Dr$NmP`2{sTK5 zY2}VbKp1ri5*?%eSM=Gr;V<};LvOWMKTlX1ztf@r(%MSA_PSqPsuS~~q%GA6n8t&64k$(74@e0*9l z{$Ahx)zR%E>-}+*y!2ye8^jjJCCv^UuRH{tK9m9!67>ni6Uo<~i}H7(z51Y(Z$+HF zosO`?;7`5 zpdyN~rU4!>9aqzU-fTx_4ndFAx)+7kn*NQ7+ITV{Nv@*UEqYDKDp|hn1H~i*qzhBAFSxL z9J9oCXw9I5H?0}Bj&4G+iEh5${ToEle5a`+;eGb!asD*wCK+`Wx8O+?zg-nf6kow_ zfPO1pvxktmj?yPxx7;6S#jc z)kU&%0D>a_at`G+6Yb4}9%Br)E-y)~j9%F%AB!JD7vz&aR_zTv;5+LwHi!iu>!HHv zqjQJQ)ecYFnR~}2xbrep0>iiqL<`-wS~?3)-NsFWKkWZzb9kL~RL<5Rz!BW~k{7BJ zC*7+}Vdvxw<7^GFLUEmU%;RA5POeH(YfRS(#Z3sY1*wG(&$Tljj+|-11Bn&P$|c}$ z3?z2Z$d|ou`E3E=ZHRq#W?>ez*f@XcP8pv_wIV1gf>AYX=3`|YW{%gjqR1NJZ^*AD zWptEuH8xRuK-y4vzQMWH)u?F9ley5JT4#2vK*3-eXQeA90`E4-Y{X+=(4?98|$W=!)EBXhInP&wX5RE`9%mG z9p>%qY0aaB_QtH0WCk&unUTHaIV{6TtIr%;sK1%lXXAPibo5l*Ixt9x{szY|-SKpY z^MR35-QMx5XMZ>_E1)4Pw&17l&`z>0ukr%h@rvS4#2{|$#wP-pc*`LO>o`;nHs)N= z-@ZJ!-XA#LUy!hA)xg~e(kw4fqxs2ljfYUc>SI$7aTPg@+AE}q7w8G<;3F!>#LzT6 zc}`{<5ml2_)h9=DZB!6uJgE1yS>boMFI)GK9)%@k?_ZVX+yJj=8^c%02jygOUQIlW zb_EqZ9nh<6hZczA$ZmoxfGOD~{){BI1jW3yTG$N=!*`1t4@Y zFjPr{aFOaFS+VnQre^$XPO2ZkO`l=4o_}sUBlA3mzkdyp4B4hqwDeWwM-SRXPVkv% zFyqFZX`pyvxd|bVEM^-df*GQ~?$@jJ|IN>?VJHiAEq>46XW?(oKDU|iYFED!{rX2z z+H-cvpSF(3qF5Xw2Zd*^A&-Gj0J7_;raiF$Qs$OqyesH%O z4wJn<8v$jbTxe;F571laD;;YDa3hY|r*~g8s@-ftW+BcYRXiM><^t`?3RGu$ z;HN?2h$pQiUBs1wdP=6zawBEz%5I-$>-1DAs}c1YZ&L$dW|BUI;OZPJs%Uq+zOit; z<}n`5oY+pygxBN#q`b6U*B&;T2pq>je@VytzIr1HaDxP0qBavjfTy5M7Vip1<#{Xh zu!9<|h;3Tbqr+M0|{yP7}WnCOp%;^rDR)sxTcHpO6HfSu9Z4QV zQH2mJOWI;YNdeQ8u8;(ITm?CzI3ux=1f{w_B!jkvf+m%#38Wi1bXJ!c48rcz{}*RbNCgN|=O+mcm^TC1zDb@XowIj5RGBBE=@+voZ2e(si7HrF1|>np`;?! zTu5F~Se%rEhqPRRIx}i;PlOY3hMFQtQE8kfS*UQ7o?@%5P{VjW%ygur zXxK>W+b5E8LD1hsF{)&yAXbVLg-9TgghCNT5j^hPgdo;RKa+5698b^tp0$lRu<)mv z3m3aeu=LXMh7)*+Od;1oCe_8nAv8&Qv5KLVWq9@mL0zR~`roF#l|rvySkPJymy7Qe z8ip=)?Ly9MYy3RQxqSr*qC(E_?a4b;oRx3kfvqQ(o)0-Lcgl;IPS#Gcgnc!8T#~en zLz6>HRR9kflf%*Z9d3rvRhifa`Y?0!HJUn}is-uQua;-wrsM~vb@?M;`F6LTCs`AT z#!Ls)RvTTMbMKe2B{v{Kq)dT&{i9w!#DFx+-4A~p1hqfH!e|C%e|M>S@x*y0LoM;v zWS{Q54v&|6{?>deodpmwrWB;R?Ju*F5sWbqczNnhr(obBG7R4I^wdI5(2GpeG9a=R zKSxJIM9RWo5Xx|scv}kKF-RfKMZL$cvRX&UV6(`Er0gW1SzTZMJ??pqB|zkmF~#BH zClo~PUqvv>wDZJR5`jA!_waO-6T*!rq^*+)vT{l-i;UQqY?!e^4+kyltaJii z$gPdlvB_bTWQF>324a8L%%+{mjrF2&`s|5swZHh3c)-ypU(khyHMAsB%XK!OT(r9O zrjFx`^`hnEndWD6WxkiHn$^7`{_5d7#J^P9oNFpdI<>5Jdc$S26d*cz>RhYoa_G_) zF>)Dj*CdiYi77^xa6$C=ve!_=VLbF(ClQa%Hb|M;*i>R=pEcyJ)hK_gMk-NqK(mWe zdxi~oV*I!MwC&vfx`|>_>WiC46oi*`&{B-K3kj^^1}zY^N!~AiCcZ?bmHNFwsieo# z3p<=%ts|CP&628mB0Cqmg9D49Y!CZEFdfilXM%Tm=$%46LDWjUYx7EG+#v}AY%iP+p-=rn) zC#v+snwynlYub{8KPP*9sx|B?NOG}|*+Fv>KTE{@zQz`}6=7FdT-(5kEo_Ux99Ue7 zz@`>;H~oNK^JQV^QA`(PV-PJ@op8>7(Qu~}o74-Q_XOOdlmnCJ?Wa)W3n*y5h;HZO z)qIe0lR4`IyBtHU2HWJF2-R>Lcf;)rYI&f|*iSG_3j}$x+-Wrc+0xCphqfVG9rI(f z*TA-C(&RU2L3gkR2aDxL8crJc*{+(eL|%MXXhEC^OQi#E-;&>X9TRr9J7w_`I>eu7 z`oz|Rw!56_fC>H^uvLZ6ZBDp9+wO4c&UATNuz!HFk%BtRElOBWT)}#Ia+ULJT=m}- zsM>E|mz=x(gCXIyds`KKgWZsE3>i_@8$u>+x4~GEHHDrz+q5lFTKa|gh#lI?51k8E z7gH=OV&KB*+!uPX3nr& zo&H`sRl0_VGh?P*{+&CtHmK1w0@8tfS}fx(=7=}*MYiB-%lF(!+Uq_PQq}j!?R_}C z`*&h)*GR8f{lBBGbb8@!-?yQ>L-w|s{VlL%tG79aBT?7)TN?fG4xeLDJ$?6VRC}ZC zJ#9hpcAu}Ie%GDFFF!%8va1f%7J_*5%Hq$!DXM{=s^+sS=2D)?;aF4*&6ZJDMzLrc zoG2w}4|8f8q$(w;4YySfacUajR2gjO7{Ds3{dB5eTT~S-ugG_*KxSE#OJq?Tb_YbV zC>TY~*UHcao+l)TksM^i8h#*pFyU01b8-g2Pv^@tFg+)owwB`tSU94qx&92QiM=dl z-pJ6ve$ICs;|_)278J_vM~1sRXvS)X7B#J$hD8(R(m(zq@k{)iFYL12D@2#~<1rRC zlKRYWjqnUiL^rN5z;F*iZSHTI%p=WeZ%=UOlUdyxQq-;y%&yYxGiA`tFOd}VVO4aI z)ISW)Kc1L=+87RB8?y$1FEelUQKsmk+lKK3!Fa^|X382uX@^SR=z)3Twm%cR!lE>`~IK#yzV>FUSVw+6i7;K{EH=TK9;D!aA6Pd{A~-1*Sme84w_%Mk|g zcNx^JcG*4^s&f|FO9kmW_IY#~hojuO{D;jBjL^C1Vt9E$@R+!_2H*jJ{LQ_yVUJ@R z=KPJNfNxEDUQREUSq*Ox;HH%9i^-my)n&9mqCG8eEbB6TY5yP}f`9^hEFPKI}o z%2m~57p%?b>;X<^U&aTZyh@X~tf?Kw%2l<&JI3E)<*?@rQ!MUlvIKvt+;NM=vWG;R z`yCMRdPPeyc>z{KETh9A3R^ecP*YVWCyLv6Itz1Kd#iIkt4eOVjkm~Eqbk=-EJlSz z)Yo&eR!)tdZ>RTxMjzQu8|#{t@7_xjK9fo>$L0rPRinIDW!!b@FBB>VI<`u<>Xq+^ z=te<0js@N)U;p4B<5z7ap}*(ZDZi~K3I2oj`mau|n30~Lk^O&7>?%pxAgiE!4&99# z;v)-+DF7EXfDX&(_t4OYy9T1^+hT_ZnnIwEK5k>2*BZ&n`OiZ@7F5NyomTPYe0#_T zOUyeBHkBf(a~cb_Y3s#Y!vo5z0sTY=|9wZ`@*8;xhE8%{IzI@r`nFD`WFGLhbu56rYN zIln;>`c<2oj{jvVmG|nMaMnq$BQ&WcF2hsiZ5@|aFb{r_mdjH!E*7!J3I$4wMcfVJg)RRSJGV^dWyN86qY~uJMTTCG!3k?o zLyDhu5Ho<17L?(@Tqa8k+cbVytryRDh9WAptDhAOl9gS`>;`Q6hfT;v2NU6Y9lGB< z7Pr$UJ7~-`Tp@8bQnvX!M*-f?W$h|;U1s{81t_59;17gs7jaz9Ey6Sxlvf=>#xHDp zQbeUJejbi%;54fa-%r7KTazEm;%2$fT)?|ObML@v{j58J0}3t)FTFhZWVZ#eq2S}) zG5z_+h}vy7Fq97V)Eo9NrCN0^X716CrT&Z>NT19oo(eLnr#Fd(U~UE>0y&qR4Ru)J zrDUqKM4W!O*I4|4;eoo#N$+xtaMy^rFV`>|#H2(p>U7Qx2)sOaq6hpk@W(7_>@iPF zSrjZHQpkb?m@eKT&eY~~MbnE>Ac@&Y*yEEN7sq^1!PL4@PPzK(tuiwX(+i~IGT;0a ziA4*MH+8G5N(Ttq3rITcO{fb5pI0OC-d!+yg=tCg)nR)F7{uYg;`3 zox99w#;R!EG4T%?=(lIVx=rKuP?ZAhj-?r#D^u+gZ3qqWlsdG%*FhwZ z;HAixg5i!L$9ChQDhP=YNi4EcjD%i6xCJ;4r>7K$So<|ps$A*P6QY+$-)f0Fk@C{A>DH(9+Dv+L4&y-|5%ud$JLj0l^E@ z5K$hXdjOTeN;{6@W?|2ZWaPF)@}hHkelHg2>x9yhW$=c1n^x&QV$fe+eaig0y0VnB55MJKPMtM;Afyx1Y5gH(PS&M8e%7Q_?yqd4ine4&=N0YX0-uz1o%>qO2CZzluiCL}32{ zK*CHI*hul>Wyvze@8A!sjI1Ne42+Rbne2_W#+n zJI59GdW8A$1Ld2LCH()0+P`Bqq2i&dc#!y&nJ#Y3*eY&J`p1?ZAIcOrCd7Xfq5-K^ z2r&kPTo*5Sgak8nd?1xez}7;iNy)+juL(`VLMN@sJiI`vN3o`9aJ6mi$)dUaqG`>! z-OI|Qxmv6F(dBZTn-u16_Q%oN!i!%71z_4kJ~PY??D}D1lBbl1YU&*9q#;& zAb}@)MB{UawfQmW|V(R>LWw7%lLXJ)vwYgYtOe+<}s zM0k)JOfAk|BFIqu5ukR|5pL%9Et9f;7^0>q9}F=Z>QG z0q}Dpi%@kH^w;xyazs>bb9_9%(I@TV%?DRgKCozUAr36{x1elI@1%2jQvII==eY9(te3yekujyoe}sT4R!AnbG}P0dE*Xr2iw8oLhPYG z!xws&_E!r$H%`i)-rD#-S3)KXP2MIQLB$23Mmf9tF*XX65Nx z?nyt=IGc>hJh@nJI>g7@j>sJqT<}m7S5t8?ZyTBw={+$w;j*PmJ;l@P)?1AjuS*fe z6B?v^mMm2Hc>%AGl>BmB_mk2o%@v2u?HWJq;7V`-ZFHt=B#3u#Si4Y+d!NAl0=gnr zTrKr5^O#<_G>rF%mr0scDvv)YXjhh-nQhZTI3B&{@PCaQX{e{*Z5bYUEb}nujIRN# zU05n~e+>F^#T4{IkAheSoWVI2#>uQc_#d<^y_uL=a(p0|x3>9&nJ7zIw8jr1rS=kV zow@35aqp8dnQf{|rnV%htuBm;fz?k=xRDLZb#_N!9TgOhE7%FkPH1q`b6>obb#V0{ z5F@kB9gbdJY8g#xUw&JAwAo0KraCtp0i;N_&dxG5CofIbse;!TQ-v_qxi{W(l- z{Z_L7(tjXZU+IcCh_qq8RTw*MbPms;)$_q=eSK|ziU9M*^yk40y3A@ z1P_Qz_mTWtnQpV4))U3sCxN6T^zgBYLuGK4EX;jR($8k!+jNa#PbQlx%f#|k_u*gp zk;l_HAybc|4B)NFt>dZ*uQp5|qiL@kT+>DLb!YPpi|Re@9HW6f&S9=}clICK^p;vF z(;I`s2Xz_>O7p91w!PeQ0K7!X4T;l$Otu4mT+>507@9M)s)Aw-`uW(E_;jZ8DO%95 zom|KoRkhR6QwQ;)MkrjKkG|nXq0!)NTUpXcS>1$3*yOD!zb*ReD0;y$3+|x_l!}}-R`{n@cIovM%_XI6(Y71h!bO&%XvmKK) zk)bQ5)$Y`@4)R)UzYv%psdiMR;!JxjS;!aD4fp5PfX+2ARWb94N4;BLbSCL5{V;m= zNhYIGUUAm+rYxR^d(~2>Gk{BUD z0#HV5Dgj{@Nn}w&*lHp-J&9fzpa`CMw$0D=c(vstJ{*Zel9OGYja(4WnDua`1QSRsAH*OQ1J zzoDz~^h(zs(F7cx*k%p&-SaVA(9W3~^-}OT!?grZ01n6ebEugM)h6LbL5ry42Km+W zM+5C+DQJEL{~a%9C{$XNGE4pjKJjn>&V$_?YPJKU*7*cfTPZb)WXQv8)DeICRtlBB zl&JaHc4{Q-K2&;@ty9hyhY9hQF5qeLt%m_JtaIb(xKc%T9Du5MLNoG!+@gxbO0!Ee z4VI$mmA2%;F~}qn16}CBrEevm%7VrlEdC45!{kwq68VS));P;G($`;XW9$&>Q?-Ti z8#@^mCL8Nf1zoR39#o7I9CJ*6kNmAvT+$?FAw`PwGyLm$@_+(qd*QbBD5}mf^zb06 zGFn-1NIWyfIYK>IPvBf0$#*P`oD9J_o9G3;B%sor78HgGQ$O5X#=>th3?fbh6M2{i zf~0EBBX%ePq#g5G#|LFe3w)m3e=o_LpG~Z~|E!#omSXvFkPXKMD0oqqvhw(1u@rwa zu1jA&gHGxQS<3QthBGtyQlw0H+>Y;NvCxo6PpY9 z*#4eh;da%c!sT({8Fs=}eIOOxtZtd!q~UowrC;OcZc-GcI}>gk|4U&dwOiH5Q1%TP zutIL+CF&j@5VaHruHM!eVka4EW6!fo^V|*cnY}ZQ6wN*$nxw7cD64}rnheL){{tZ&$^=qNd zBU8O}J$>{jq}>TOM8?1}&A6h$&6>Pks{ZxoVW6LnfuH7W&M)x-MvlpyF2l($qyCrU zLD(Mx5EjUy=2cC$8U3ptZg-KNtoeU>*$lQO$=fh zHxc6pi+Kd3K-QJuWg5jdcF?u_fw zy2P}gqRhB=3w6QUm3Jo!5v2;u!Lbz*3hHK zF+LnwbR7x`Ba9zPi@5_XNRS-8CU?r|%b)l26+N&EcL^aq9|k&AG{*o>g_GEjpxl=~ zHEk6Azr=Kyr9n{ekOF2Q5E(dTF4y|e}ymttpTvqkSps7%H ztgt~<9D`W{J2RGtFu8g-sU5${z}rWARs~n*FPBitDz6u3X@2fyY;=Epgta{|>z@cGg%- zuX0d@xAQRUxD|Gj$DPEvk)JzZ^e6=jbzxJR7yLlmf`0R~3tOme;?`b2YMxokR@k^h zjO-12Ij|x1FreeTxA?;#Rs!7&}CtE!l}M7ut1~P>W8t~54<&*x9+!u zbYMd^xizBDl^*!veCifK!a2Fi&2RXHB>&+t@PLOr=OZaOwMR;TD~SVE2xOKTK%f>; zK`k8RqwCfR{Pt4p$hWwcqZzJY1g=fQw#!<3!-+Y7H8^--Pqj(iyhn4(x=wJ_ms!!( zzOS}UGqsa>Yt^CQ?dx$s<{JGDb(Ofa6L3q${rBq$9*e+pprl_kk{p-ua9hs+_qR25 zA6bNv>%6L;ya8&|FAKW}d1?$T45p@SNCT|j7UzB9BKP8ZO6+tV7CVfjNm37yGxPx9YG?|4P$Lk&OP}H>4eyh(o4H-H+Vb{>|HP^Hitwm(?N(WZ~Piw<0GlG>sjo z4DMX~kU$UC9g*sbOhZ}JA5{+(!pByzHfUb=6rhuk5M&2}>=bx?;x7m?qrtWczyHMF z5@aO6?-Y3ck58q9f2bau*ZYg}{cGL8H%F3-M_tjb`r1$A8||Q;c6mIbx43Q`-QS;L zeW2dAj(9|aO?N&=HuYMc)_+9qwfO1c=j%#alPoZWh2uT_3h=HbkZ*F!SQG2q0^9>x z-Z_J4@Sx^)|6`Knx16VM|M#-O<2TRvpQ}m#LdXAJQvd*@05%2|MvfwS297rNZvRG5 znaUb~HD!d)D4IR}fzA{Jh}?<7y2w0`bmoI31#opXILDz(?gCkX-vpFV2eJE7ughOE zc(IcxoO8i4*{{=h3n!Pc^lYi5^!YrZ9?nm1kM@S2FHh0DK-@jnaC^`C2nMQpwWIox zPG@SC`}DrU)Ln)Xq~X=7ifY_?wS)TUgG*|j@xs#h$RL&5g*^fvqHmEOi{@j~goz8L z^H^C9`2ALEkUU?-N`8ndO;74|YEE3$1$YorpE(&7NgJEZzjCjdo<=OGS>Gm~1g37( z4cTiBn>K6(bBy2dqy+wgDDAZRDnFzt=r&w79mqUAWZie0X!?b=9W7TLHC^3$RLu9O z&@xag!e{WDH7=bu6+9GGxSr!G7UGc-XE;SM;hbj>=W}&62x4La5F-Qzq6LK|?4?1Y zdZfAyNAQ~;7GpD~A6EI%wxIT(jGl$M8rm!A%WU$`g5tJ|A_VkPt78T8*G^o$UDx9b zl0}C=MbL@nwTW*QEq%VadR(*f!8JEP7(JVa@y%zNq}I@ZT_Qo2iRX7I`dEF>;!HW7*R+IBIbu+w$?4V(P`@+BDKPo9#D^a4OjD3kD}Cb< zqJM%=y1Pp-D}0BrT3sN!({?)^gH1BVaEmt_62=p%Hj75&Grl7dF=QfeaR|v!=*(u8 zOiI%j#2n$tN0prz)=l8_XIuxPWRCTNEl4%-w?GmajrT&-wfu=z^z4rsyJrF$=l!SV?r!tbEJ+fa*8q7*cnDS7X~B?45bIRN>`nc>Xw96E)B z1PLYQ;rC-ElY3Ubpk)qclnrpk)1lWgny`14k`pPFZdvC@LGq;fsDurk=3V2sYVpiPgce9@;T^$yti@A^R8K^# zo}l_vAoUW&iy_PII{JxEgiRUC2ORP3-1dXCDlgs0r?+Si=Iu7g6WYm@+R0R%v>M5d zFFPa|qSb$2C~_A<@0fNw=8_!_!ZLVOOrt;lp*FHjdS8_AO?^>)rv%FXBZ>0wbf9SO zcXC$ zf<9vbtJN>|gQ+7q*e&!?5!FI<3TE6Zj6#u-HZ|I#$7G^8U{IxLG|Jp^`qdTC(LScd z+_6rZ$EtMUaVXG|7y23_lx8YZIN%Ww=`eLCkKC!O+D*F=R<(^Qp+j}|mn8Sy+{Rh$!{>ygfR`2jxdexfJVBc$Q zvf~f3pOOf<<;7h2E(k=j2Y*Q^cK0z~o`;}oOw@(&g;^1ak?Dn4=eT~DVUSLd<3jFC zAVk#^>lmJbW4;iI5@5xV-3wb?x{OHupnYT&oUXE`M?0TO(esA@i~kBY0!K@S`gT)~QQd)o3)!^47k{>Jyf z$gb#`fEr5Rv#$i-t63b($m&GJxD4SZRp5}nKW`5n$>s_*;fX)vc&Cf;B+t`$=F7|3 z8vGCNWjX_(rwBDR<9-p$CmQaW+R&g#H|~b(zkO%{Oo#FY2@oVNa`e=Vn|BOPM6_?8 zrOLVIyd(5@u5q3bO3}Fm;Ci#mCMC_2-$HH}Pa?6`!_gm0M}Bf?ND{%hFb@pIbJX^%HV1ySSxg4FVL}kwG}Qo3}CvrxESk<_@T7PEBgeePWl;6oF8LNYtjSX;is( ze4IiZ37nP>I3q~~9gtd=B;vWPb1L+hn|F#{1abQjmS^?=#8#KH7(t>Du4d$#_qX|FXSBB>`sV zmjNM*Lk`w3gdihJqjhAnM5mQ*afMHhEpsEr_vst zwXv_$lnpX|R?Q6BHf2?l06=ZR3DPu_pg-dQ+6TKF_jyc|VrODWF z&<&z8C+|wgRPFAYr#apPNZCa$SA|=&?%T^$?FF`Kqq80FifFADgyWs=8f(3ap*LTv zS}zPlZ_pE$InErR@gg>#>^i?5wzdU~UCj^3I@u*-J%&+AuU`ZWyDfVR!YzK{W(k_N z0+65DcFd~W)YpeuKCBt=!BSTT6)unVfnt&eDRvHchIInOw>Y;RCgiUT(h`d3rW2T!*$Mf zL8aeC(CONCh!!tMnqAf2mC>t@ca@Aiq|vJ_c41G07CxljqvF|C;I?-6t=RIOy^5X# zT!Z6z&3CnEU*btW&qXLU_x8PHKKn+%)1IdzITb!*Nk7AM=L7qDO{J50(V&MAGPH8} z0=mHoia$ZVlzq%>xaXB5K*JF=rE@21csH?N2^Ay1B|*HMU-~l(rEg5h*IQe>C389( zxMpY8P^mZ6<<;?^K>BO9cMFNd6!0LBMRszk40?|jbsen$PO>&{W!tPE)fBvFF!v&Y zH&%rf?rY*;N{BAbD#Y7YWtFAk^zi6Hf@Qi2=4Ym+<-EAe>FsSFwXXDK7=sLB2+V20 zDX;T1=D$&`ABS1X1w`k{+72KL4A%PmiWGTx#P4d_KK|(9%kZ?MHg2XZj?+4%SiIj% z5WQ5o|gmq@HbBdynayp;hObCEULreW5shLMo|w^+Rk^9wB0mYG?2U!#%xRfQ($MQu}PzDF}?l6uW8ws z*PqUd%a~k?AJUGiol9!d?ZmC!?C>MAH%iWg9*xc_#EDh~1xuD$WWu=6<`8c&QaMm1 zxy4L1lN+;8XD!GiV0Gng8ww0~tAa%>bAz*->{qBLaBP*m1H(qGs)}@pi37caH|t}H zqofVO*spR_2BVli@go)X(wnyQGEI^s9YUYltU00mEDMSVFvEgCKHNw~YJL!I&iI?_ zI46-rx;RrJ(_4C8R95~ot`mneimiT7tZ)4|TD`}oxMdUT*wIfF_|}EzebrBoUJ}bQ znVq%x6A-WkowV45wRzS&iL{V2!wTx7{;l^XC83TWm^K1CIc5VBpO01eT;Ojv?!0Pj zUK)W>&xZ(R%)gCTlx=ou>78Tp)2v&EZpBXnMUKv4hL&pEBdAi+7f%Fgos#OT^{pgy zee-o`1N^y?6qDp_#~By7MHxuBbY!R-iK}(DHd`tYu}&I^&4q#WTbSMi_heA^O>03C zabUoctSm6VXvr)F+>P%YUjz*+FIVG#_GY<~BNdH|UP43}CeLRDp($9@F+>J^CCAAX zG|;ZkVHNk~bZfvaVm^tQVK9S0fBZ#qMZKGC!oT41*Lr}I5smgA#b!KWkEAhHDace; zKh;bt{(lHN>!?VgB~Rl{V6?(W)H1BJW0G|)id?(XjHTDV&`?oQ)QBfZSL*_nC! zW_Qo2lbL_q$jUkuH!|+I5x*yV(Ue=xWg;X#GE;Qzx+Tk`W2jL^~if@WRW5q(0*mG;un1jhByADK}WnW_!Lm_Y2y~*;F zhSnQ+s|^G|GcYbHp?4|f$-w=ML-rctECoeI69+7*yzcA%B@s}*QaEiMkA^W7Iye?v zqkwyg}VFn=|aXV;NO$&ID<585@6C$G`p=XG^Rj^g`%QcaM;iJ)=ZP>ZT+oQjD z)X=0#)A-6;C2E$j9mY)!6U4)*N**IkIZ_xjGi2f`sVcAW-J)~7P)yR4Ftq_$1?7~g z)?{f~O3cuUM3zzgqro3s!_FMN|Ef;$}Xo}iyglw&*FAhV>QX#msgKG`mI^T(ezA* zDNiZ4SBad!N@A3~BwXszwdK`sx0mlcC*Hx2CuZ974XKi7qRe7FwwIak_r-1x>53Y` z7fF8EC$nSs)-dJMqrF}}Wna8@$|H=`~TcD*+XYYZ99s&pDG9R$6sZLem^ zY~rMDvm9A6A#IhpHQ~t0>)meA;J$D@_nO=s-j`g>*|1>)>?sA~HEW`)tdH0)?0JfM z6<%Bk2dLevE57=hPJCgQ15S>f-&9&`ILNGbZ7Q;}7c;SylgMMZ+g76{J zT z9QC=F((rKq$ouVX)iN7Vo*{ZX4ZTnb_eGDoQS;%MF7+Z|R8u(H#00%m?=UJivg%{} zVuRcmiksy;1tl&foaraCeOdf$*2dC zs^y`n%n^BY$KBiW})t z5v(-;G1YAr2E2hmt_|_k1=i44p?>CKnlFR$O8gy$9Q9DgY;U#Y%xc5@I9*7L!|Wj$ zuufcn9X~U_cWEAU810=OkalK9lDO@t*5Sf}WAP3davZvbBXS)vhY2)qs<*x)W z=zGb}NPNmicrJq81=;<|-W(a|TjTU9TRf#a#uH&b$obelp0sQ8JEZ zPM#OaT$~Pz+wX%863S*5-5~@niP_cTbii!-GQ3;T)5QZfiJ5o~_f}A|=mtGC}T)I|cV*Rn3Fh63jMNd$M2pq^W<=>6>MFm;ID1qedc zWlCe?Pn51bDq7F9iL8R#9K)aPa186A?+9!S?2O5z5M(R+nT`=n*BQT?AO4_VWwMFtyHkd^1P1V%b=xOaN zXzfU~ssVWWultmiF2pO4>pu$`stpz%&+6-_3)h)!y>as=#1q8n&8la{a+aJZ{kC1h zm*ba`;<8<{NxcK9T-&coZ$;liwoKwdX#ZL;7r}1D7aqt94U9@ z_p!nTn&;>*cgSrb%b50rVhQcNU#r~~SB(&^x~y$8g2~#R?$U(?&qtRQ?jSo=wFN0( z?4qv=QA9>Z%jE(%y$DvV4#ONKLf`x_@-9Ej9M(Jw6>Ywj#iD^Hc5+@oPxE+nG5kHM z3xQ+!$TD2)<56D!>q|C>2J3PB{%{pLynWDuR`Bw7;p)UOea5O@pph%xr6rXW=nO%FijKU=Qx7Q<|1trtnG1}=BeA*bF zLPaC_k0|?^fb_Rb;5BC_*%G~Ci0TJ%fU#>mC+g{CsegTP+kmLGFYS4p1H-qYIB14b)idNg0b{ZQ8=CwJuS=qNc^Bng!2vlJZ;Z0qKgZ8r$z z1Thtfh`rYGYU8y+Ua(om949Q=W|*+MZ-249Nnqj23 z>rwLol6@AJwA#@QzD;f8Ay;~f@_LaJfd0g+<(y3oh}4Q*m*~<7#c9ozr(>UM z{nOIo3nJ6qM%VO|&eRmFcefsX( zwK{tjg`m=V`EL0fri|ng9T9`ZvBX9qLaU<}v9tPAGi28w*gjsj#WX@=$NeA{YFhI1 zS5{<^F_ntbqtnv^nqgLhrlClq)6Mc#-D7kN9`n9vTT+hh)s2j)SWC5afQ_h)A}K7% z;YAO_-W4m$84Y&yA5gfI^}RrUxG|xUyp5Ie;{_301O*At?SgiCUxszKWN^#3i4-|` zYJ%p6j6X?u`oRWRA!|X9Pr}rgRdJF&YvU3ZG{Usn zylOH(%Sj$`)Cg`*Sn9smG31#ZLTd}-AZi(g4AZj=c+;~Cv!~3Cx}<#FfkG&uaJ-HT+a*}HMp$8G&RHouRe<_{Kq-cTH8G7iS9 zyn&Rpkbe(2MEvb^XmDWY3d#?R@rd9gB<1XEtL&5Pc3x-K#PNs$Iqlu_hV6^6^FcOO zmshf~xp|Zh17RSBO2K(pLw0nRjP;V}sou^{dMH@ygCFW+Kz3ZjI{y3|{+~P{c+8w+#0ZB}a_I^q8)Q=9tC@a1&oPiqoE;7hL`9ZHF2C^Vp58}i~ zf(7M)XH6-e1$q}G;wNJ1IynMlGs59xbNsn$}hVy5@#lZr??3Mpkg zsZV}dw4GvAID=Ng;sX7iO5ML-$t7?|tC>}n$O^!eBTwonZw(oA6pumunOuR#h=S?Q zs$A5SLwB{0=vWy*XOnFXe%4MrS2CjURj)jEl}Xs_h&%sGQOqZ)>0J}kOAAK_t`(^p zG-`vg$_wwekV7zQ47c~(=lLRfOiDBvv-imdqT#1e_@+J#w^=Uw5O+Z#)qXxkxrHUy?T%fi*{v&4-}tl12KDTz z{!dC*enFnyBqhNsz`ejS=D7S@eJe?3y4@D^?>_+lUDW30FN5>%bMgl;lJu+z9&GUgv+r~S_}jiB7LXE201h!J%+=0=~RdI zOoXOpGAB5Z6)Llm#nXO~3n}EKO^93*PnJ;oCL5l6U=t1?2xQ6k7=p$+0GFBouMO%D z0GQf>DCDSgsV{Km)VteI?nm}|sC+LAN{KUv&}|IGyWAs$T9S0MGI=3$sbY_f@dZ(! zuNg9ra1!V82c?4%qRR_NA9o_?cu!h?%5IWv%O)PSns?ais^J$$Xc2KzO{EweSbzOx ziRle7alaw;Bp%i%*m$`^YPZH=M@r1acBS?qj5iL;iXp6c#Eq$@@1N-!GSPX$v)cO&&mkvR~X>$XymL4*$h1n!IuX-JKoJ;*6OigS<&o zcXf%{GWi>@NHNu2{*REKVkINXcqV6L_2Do1oX9fWM()6wBSegGM~NDN-4L>s1wBr4 z)J~X!ER#vi(s;YS0}rBJVgp9*lC(2Bav(p24Pdtmp)LkwnI+$-X# zn`+t+b`g#JskDgyekzZOz=vlZpW+$!CPVtg ze~c!$1B(6Tl@*4ChP4Z#bT_5>)Bu^PohWwrcL8!8w1^?BJ0v zzw~DBed5&Mk zpqy#Nnw+|i?QM4LET9**r;io8;^t|0YvGlaM|rw}O}|K2uJz1k6@>7bmsqgL)^V_i z=_k@_MDeuJKgvgiQc_hiVEkmvsqDt9q@Kq~!Sw#J=L&@~_j^MKEUh8#iaPPZ$=m(Z zU5Uq^>L}o|EQpi25=AExTwS^U%&|eq%)N`W0$MgEp+^5G6mXqtvd`zU7445Y+0^FS1D3E8~0ix--z%@Ad|!y<=ou zBLdWZgMxTQ3EwY+%J>`NdX3l~X>d;chO;3@+@nA$NKV-;I$FgOf+38J??-1)4&SEt zq%q~)CMGsYLZ>=M#|%J3ZB_}0Xe)QG3xoYR!JnTJ>(o~1jxuNSSEt68u(n^gkdqg7 zW-#x%b1{f5vhAi5q*RaRNHd{SmS%eMrI1}_WiSWci!?{cYiC?f2xbO1aQ!U8jv#Oy zar*5`Z!p)mz^fRwU1|NZEugr;%rhZ^Fd9dFKnhLG!0wMg=$dylzKB+NfkCG84=Znx z3$l15yz!Oze#ptwq}~2|fBheoBy>rYQJ&(Xe<28dNf0Nj#?y@i%6J)9;vzzmqf88W z=}=OOHl@Rj&ZBi{C<4pV5iG;4oocQJkWFCjqcX0m$HGvC>?G9!f$32gQe-pO!*aN4 zcB}H#%3oPvWofnobXymYSoT#?Y6xLx={B7D@!Sr0*!J|=rv}3bc1#c{=0@Nsn4~vS z`r&R6bg`kEye5MHLz}R%XuW{wdT@{&2@;-nZ0v?9zZITVg#`#M%)yU%KkBlwHYZlbz=)6KX<7M~5&sM5XR*(A>;-C;BbL5ros}%!s>j<3~JQJqa!zkkUXxAZr1KMAv zExa%ULk%nBv7_-;p!DngjzS_9EU|8RyFro&W%E7}){D`RPJO%TF6JP2%u*elFd3Rp zEhx40vBxg>H2NjiL2I2r0nA@HQH!*JX4PG>^{!=uompzCqT@PGjNRc+bk&n#9QAp$ zkT~ksLnrB>KSMSkCC%gNpTz`JJ45~~RUAEqf>dRr-jxPRO%{Q068KT(5qR`;lXP^% zt;`q?^0@be+$VgP*JdlMX=F)iGh)zk)ksC-V5tRQ(NoTCSZ3BpdIuOYP|`LQL;=ZPgxA>oxficsiCYpgfn$Fp-_dOMejMm3gf`yF~^+P%-R!4LDNQt zU)8Jkm2ck$(Bj$Hf32n<;Ef|+se~eV(wk+2C4~!yk?srrh`vHM^NSt6r6n%WXW=vj=yeBCDn{A5{Pux>uE7F+_Y@3Lr>IApnIjR)ap z@Z8n&BJq9}tmXNSHZOyH4w#(_d4E6Y-0#ZQ#%Y;}+jq%k>gyt29HU($0|Fo4t}nsf z#*=x$0=5cjrwy8sA_QWt0^?GTgq}8Aj5G2E+V^Sx)|$ry69=`MV|;n%j{ z!xU&$-7;SR5%fIf>E(9fRJt=%T7-WW*%Hlv8-{L0^u${=IXsht#&PiYjm)m6vu)ct zRqF>8W0^`m>m;Pw?TJ&5x0K*!rZR?P)Tps^$gE3sM`ciE!yZR+D!3hj$)(6Uh@fK^ zvZ%C9yyy_e*ndAWDA;d6C1ZZ|-eE~q3j?q-jp{>fUx~&(V+=fz0J6Ps=!bmGmaZ1z z4_M~Ng#+uKQD-nn2DJ7}+qL{41ctVE#BMyj@Ud`q3?MR?YhNGBgUb#C3u_C_ssX-_H;1iFjuIO6aj;?l~j} zbb$7#1sA}VSSxW$_{iOh_>sy>XTIi1ExnK~1m zco{1A6Ht2GBlhejqvg@)`Zp;@z&FHoF;yoTcE2zIh8}s&oZC!}{aXg&;vp`tcx-5j z3t{t^;Pqq#YKq}WO41G^9~N`M;tu|4urMFdpF<=s!iW7zIp1qQxv;3k08pnhD9!XUH|DbLKf8$f4Jb3$tjw8fP?|smMP9YIq8#l4O3pm~Q2 zX{Sg32Syw#fj_-I)mK&Pul;IQ8Ql`Y_P+rXq2NqT*%Q+;y)LnHW<+?0^68{s2N-ox z%p1YKq{TQDf3g$Cxy-U{0l{`Z_7C{k7akr7HWT8b9O)tHikR-0{IaC^pi}p*Zcv(& zG4f%y2Sh6Kc$@dSG)G()05eHGoCj752Y)ecvJ3*WEnafpv&O!2b%9xaDcIr6h3!xX&5Eyz`x^6p7?~xV%uL^||~ki^AZc z$;igdHgis#mW#FP#i72{1^<~mtyg?H(`sFjKobnvbz0-H4<@l)XU+o)kuGWNL&7gf?!{Z8o}scCS)Y zDn>%uGTyuc1|Z5w5>pZrlpbS36)Wk~B^W2gO7kdiaHu^0opS9P`DN;|ujB^9-YEHZ z^v|O1Zjm3b2QT^IVUmP6H%_>5Avfi~kvGk?W<>lon6d@ah|@POHd|_oI^}3ZD(me^ zGP|RN_l_cOEOt@;`RU>-qTlZ!SW3%DA)1Bb)^Epm8*=8r4KFhoo# zTTjbfAW)TE#KFc2ui53u$=PSB`3cWdSL6CAgLpl*c^OQz*CrE&AeUBWsOqzXW-`jW3ieEbQ#d!)-K%Q>hTCx6>)$s6}&Asr7U! z-d$rj_4MIfID83go&WT}72l+i&RZZU{%HugB4Ib7L5_ndc>6$;pRw@OaFNb}4+EJ4 zXs@_<#JIv8AXF|kC8hCFn8WRK05EW7yhi3j(V2TXcKSAK4)$s>g*{l=IF818wAVAGnL^I8~Gj; z{A;717qlEQ!(UjHb6&p2=;|(Se#7CqF`o&yPksDR+aIUy@OQQmopM8tb7lL;;~%Bn zy`gRH8zI7-4|p>nF21lTjWQKMAHUtZ8OUbFZk{BgC&qu`tZr2{PSUPd5l!8?_|$$h zR=V#9GNH(e%DSvtw?Ei&y(T)ez#Z3k)*9bN!n?ao02z;HpJtHpln0uBdT@FWo@p8V z(hg?AuC^2R>j;0XXv$>ky{NZ0buj8R5m=`bos(qFqa|5OyJG5^cnOTqo@@8ln!~4L zud;GZo5@4@F_YP$2oS&7n1xin2*MJR3qhS+y-F8I8lQNjm>+0w2C{ms2-U>Taj zD{3$$!OZ}JXZ6Rb^R&*Wh|1pE?~>TR-p-;QH*5KJKsx;O< zrJ7S(Q}kr5m(^@l*Pa?;Xj?T<=I>w;w!ffkbMEsXoH3(hbH~j=RF})6hHb<`SZ4XI zge{(ftRHtdU!5}jd8di9a7OD9Ty^3Ev@Rz%bqp`zj=f|L4Ts6_Nu%7g zXr=2%C2pYX(Ve4~`xaML-s$$tTu$liP)+0XAL>sAC9@yW=FV<<>;QV}OZ_Crw}OMv z{>g~mKa7>>KTDY#V*{4{#;Zb|VCNMxtyBd@rmAxoz`NzCEYsX$3BO4Z{wa>$GiyEb~co`x4e4bjbjGWSB7vgkAuG5Aj)n9N+p-cZMssvK_@Y4iKA9~O;k@vX#HldAHrSRmV-9iG3YZ)igttoJ(rLVgh^Bqx`!Vy_3h3RqZ z+bh4v*H%#k#x`T(?QU*L64QcTI`C<}OuOqgy>wyjhUpK8LVC0FV8W!WP3Mbzj|XiA z11J$Eg1&bGghQj1mA2tlL~LE}Yat$5TIGe<^AkA)$R`_0AH@DrSxa9TQM^3MLmX3_ zhU1s(cfGVgdo9ROZHA}#+|NLd`BeX&SmJc~E< zTLknQUeYrn;oDD^yuf$$C`xJhZ;{SFPxz&It7$9BjFN2JK9#K12yZ9;F4#IAsSC(~ zQp_dFEUj1wI=c_6}r<^~+AtbIKh5qTE4R>8hzAspyqK$w0GwRp?5=*~T3 z9Lo8@)lINTSI&4}KjC@z-*G1Y*17Rnfd8KoHW>d>jqmPeZTC+aHveYgWJ%I3`>9{k z_1Wz4uWBg&ZX)L50`S)QH2%j6+F#xNlY@=<{c>%-R2+2Zn)048vc2mjl%#_sW;w% zxy2vh!bGASQ*c%p^{*h2y_pk*z<^=IL{7ez1r-D7fVon;QC1OR4Ut+f59V__Nbgv? zcWpV^F=1DmnKHR%3f}M}Sda$sGhEY7DkOt14{Zc1wY10@rG zf-3;{@V*(o=&oK%*x(zp(5KZUk($F`pxhAYoPM7+!2(XrBIOdun8itv!$|bQKQ7NP z@jN02VS#em0Stysl0Ou%!LRfo?<}`4xvFSN_XHhkO358tU`HrEO-Pz?$rqSP9BvF{Pe@RPlUt2YAOA@ zABuasnai4c{Wnk4P}0Eo#G52-PE!LCpwLmt!byzLAs9$*$#BWgD7C~;pctbNyT&sa znc-VjRh@2zR_WsmJDh$hM8<5o0vk~#lYe_JK^*JCF#>YN!(b<7Yd@Y!Jf*}9k#)-vQ%t#okaEUe_*-@9%_iT>23lMEfitoe3hO^L6&SFV?(SAkq!WWOnirbsyC1NS##^G% z;m2%aM|Sq8#Cis@m{@o^Z?Fl*Pu!6d%cR_Y82U?KVj*J`SL|Qmh{tzar@8;fAyZ?3?$>tO(di5pmv2zM2_=ydn;R#Cx?ABPA;=>B)?$D% z9#gKwTPr_8K8>)bpN(nekS;9zvXji0L91sWzVPB4w?q(h?OykhH}9M6Ql!G_Vo!kH zBz@o^UxnKa8TtYD0dM^FZR`)7`Z&j}`Z?}7hrIz@y!&zz#M(IhF_;^v>{b*iKTD(t zx-z8*M%4hE;(QU()v2neRg)@761@ z9b1y*6WD8QpkOf@ZuwWb2`4f(n_u#+h&*!mEmAxVKdY$cB=|bn-&cF!Fv`+!9gW8P zRNNhI?~KTLgn2*F66` z8J7Ch7_2@gL*VCRp#GoZ_8H5?>i5ubu!Bl)dP6yce=P6iVjh%9xiozCSFa0gU*^6& z-(A4%jaAU3MIO7&80mS^_V3KMd&)Ieg^QQtT9RL}4bsoO034tucwhCZ2~ zDip~ShrBFQ`&;j1PrPV!Wa-*3OlWx|5L1cK5>eX}B!q0MR^2yx)eVF9_rm!T0q->!E3zki|%pXS1q5Ml8BHrl}zH$V<_cFES1{ra6f@x#__t`V`i#p`( zx?LZ7g(Bp*D|ZIb?~bBSNk1`pxr!mn=wtPTjlyiAR037_g(3gMm5-ZQ2{#7|lzjh} zQP7FpT3&%e-GTM05dad2h0ER)EJF#{{}Aa2YhDto0G<8s)2?M7Nh-t9X?4nv*3R=hlS+KwIZXT@kfmjHV2 zC#}w{=VI}Ti|8$a`aSNKHyZ8QI+WaF0EM6$9*-uh?xx^ht+w!e1OC9nLg)Ut_DiR> zie<<=?xj|FOEwq}Tx;1(r_)XH@gd{z_w=3MYa!r`*o;qDR*6#WQ+z}VhO{9>>UJz5%n0Ml4oRU|`X}XbF zekX6sVqWeOn`gGR!Aa{wQZqA+95*CTG^PHfGr;ID=l8|+I4Q;8{)9j4w9s+tNkNg2{vd_=_N;S4A0t**8Z$D)a?M~s3%55CLjaAl ziSQ92QZN%Er8Xpbgn8l9bHM~+U6={sL=|#QXonC z2SkDR#Jl#FV=wy6o#^1;h6iiit@2h&Etj9NmO4c8_r3%{{jGv};B?5Pbo)E-R{0ff z4M+kV+eLm5y?P}!(Z~>txj+xYf%`)l_rudo{J3qw=V8u$kYtV~sqQ3KvURlVgh{85 zzzaTFY#1 zYA;ApvXNq9%PAkjfJckiIo~e5HOD~Ja~n7mDmjbc_WF_8{kp&~>R6^LWgdz>!)dm9 zq*Fyggeyri!k1+%66`0qOl$kw;MCYcTzR0ypnH$8lQ%KSeH1~6y}S^*S|||I=Wc?(_-cS9EbAX#sQoFrdHXD9HT<2rw#-eWe88^bTA{l+*lN4AfV|bFm&2s z0~XzSp5AKdk7)CRegn6%zgswzCMs5qN43Up#Zh64wNqZAb!#F4rXr7Ko7s*oZ!j`| z&3jcwN8c;&aqiWY>-VSJF7MZ>v>8ZIV#1amU$XGJ<)G|0ZZirt5p<2O$FPAlNb9(7 zNb7hW>14*hFAT-va}Z>;0(cJ1qUpcGvD8T?9!<1Y;CXb7)1W)Oj;4{iYOaZ%ncE&& zH%x%~x=+3v9WlEez#gPmxA4FI(>(&Xmy%;nzGuJnY~^kB6{;eu6yz23Hm4s-;7-J3 z>ApZys5!E(ogfb(rIen*Vd#8cjh;B$L%`NYrACeLm*sjq4D zEh^v6iDE@wUbm*DckG*-QR}efgQt|YwZ`ufe3uh-rr&FYosqw!y73HUvBh1oL^_2r zx70AXyPI>kiWeQ8#V@3D{#pbN-6;38F^LLQp%IctdZHI52Z~rN;pJ@A^WY;;q-#G1 zf1J=marr7!{LogcY;KWXsEl-P#@ik0Zk~vrC9t{&zHg`?+05I2z=b zUO#=X=Wy{ai2ZWw&K$6uwtj@-1NzqC;5am9)Shi&-gaH4*buJfa9%jwe4QRPTHW0# zPtnfR8H3pTqh#h>C8f=L%x+G-IcudhNelSsII;%8WdTpP>|lqX;e6m&pyHdwx7&Yiap zmJ;-&k^l#}0R4cW@nNj?b40nK{VQk4n4xVW{L#kEZfUb8(SIbG58=~Zr-<%g*J4-$ zc7607@{e=4+Yv7<+F{8iJ0?0vM^~|<_*nq4izLo4ra^(oy(mwOF|T$`B7(-icWy7# zFQf-Xm0M_0k~ssaOQ(p?MTP-$c-wi4Z84Z*1-oPH$1ss|)CARFX~^1Qxp>5l1E6b2 z0>=FWnS4UPM@wgIWW;&XK+#UafLLxHRO;Fes=x)S=Q1lHcv^89jTo+@Wf_{COnRC> z)$ox4TkKu{n5GbbC<`ATWyMoQ@o0yw6zEUijW5_j&MwO#;OIyX3` z;@zrkLR#acC~zY}kv;7uu_*}qvZsg&9(72if3vrY0~Rh0n$pgqLP0~uz2PUUgPNV& zO(*n(kQuD727#<;J_rY@0bB7OerLyq)MZIB7mC7+jWa4s&=Edrh0KKPj%-*aNG*%$ z-y02?9~RQ{8x1o~>Jr`2r%Q@H_kDUGGUGI}49@WSlNL&|=H$8h;n$`yR27-@4G`t? zlWkg6Q|q0CtkjqH)aSp$Bv0B4B3gJUui=s`E3EANvZ1A6u$xY**M_QGl6P744N5Kk z=5WcVB)~Idun^cmDa-)xiUsd#Yvs%?)p^c$#v1iX#dIlTJ&m_a(AeJ2<fZIX!^YP0~~W-s>_Lb#g+7*~q6;34VFfOE)O z`NIHai|XVYA331iOHd9JY%TPHSAW=J|{27J?Q!lDaYs69nB6`Eyw+v zxCZc)Rd7~bqh)$(!2Hdg4oZcy(I(o@U=K^s%u~PkE6$Yu+#$3gNtB$NlEBh<^>$^=drh5=KF3X z$+NaKtC!98fdbKm@yPhSgfYSjE8@tW=Gc%mD{>i#FdP2P8M~*i5kNOnFdBjQYuCVP zi12z>upR9f#5@%m<_vysLfjpNxJxl5gP|-%P%9}=J9w%SlA;s6UJvDIjOVd$(;fD` zQc&^g)v&tXQ}z$D_O4?CzQ{1XIbp5F5um0K?03rHNM8TK}*(mPN1DP*I0A&FWRtRw1!}|6v$&=)=viBBXGbxPzVjurjFP%Pgl11SS)8K`_zcn1#=;sMX+LlI>=W#{~`eVMNs~!AGI803#h9?UX(}AH- z9mA5@g-`#Ca!wVTNhDN87x-gx$=(z2MafAf$w{5GG@FWi);zp70<2h1s>+?vE;ric z1M8$AZ6N=49Ac1va=>LE&$Y4_N!Kom7mi=7Cn(K}h5uT3Lh@-}+7HZg|2w`nYTQ2f zCq`xWcXH^~kj%A&pC;R`m^n7Fy8KQ3Laa=3j+f@?i0K5GU@hVBXiC~m$gQuR_zQYK zIsdhsJJpBmX1LIqgP(j4PM&t$Uenp)bM_zl56O4vT+MBe;e+RM_aE?Fb&(O_2MxiR z??INFLpWSPEY=t|)vZvo)=JNft%$R>aeEAFh|iUe1b#}dNGvC(&z-rUjwjyF8a+tc zOTTZJdhmQ7wEZkew+|~v3o={@bf46EU4v>r5Ux&VJ^vhi_{r2r_7-WAZ>urnUZLt4 zOYHH?@+V&Zo&|^?Wq5pjTNOyP_3d>boYF(_KhFq)4T(_Ns|$N8c0m$cAgWJ5XI)Uq zS9sTjBUCq~*g~br^jte5i9bqqKi#CO~c)4JZjiLQN2MO%kNqL$K1= z8BKL&>VE*JK=i=hxV4tF>hK%*VhzfwCgikS7|FVD+K88Fh{V@$#JUuTa16Al(}n7k z7`0g3gY>a+<=7P=Tqa1n{A6&p!#acf3DP<&BWT>=ZiCVZV(o^}GVINY<9XH7LV;6JHC#B zi0#uEfM4G9y3aa7IYyiDFF5qaIfw)*+8*)*y zN;P2k{3IAKq!=(H8R#>0Xft;lB^c;abZC=xXw!AHYP;0PyVU5p)BxK@1gDb)TNHAY zQw7~+bCnYWrxOL;)m4+ws}1_Ar%bh(&9sj=7!cD_q9_>p~x@_CFZQHhOySi-Kwr$(C?Rxdydmp};iI}UQ7aoqhwm)iP}lOG*-A#Nx;Q#7;?P&!ior#xr1BY|W%(*( zK3wa9Qht3y%q*V{90Q(07Eu~*O6qyQgwVDA3!=<1*l{rY)Hqd65+dKz&Ui}Q(fg#O z*QVANKy@TC6@<%q_IYn_DkPY6edDE0Uk?%gj(>zf?NqfIc;_ADrTD>8-Y1PILCto@ z6SXwT`BW((N|F;uvRX7m5c?`c+Y37?el9yXup&=8HQnSoU66 z#j2v*9~qQzuac-Y&$CrGzAkll=6s;yLQvueWx6DiRCfwXtaEbOxzqEbQmCU}HWgV5 z^-XI9PJnO?aWF4SsD}+XxUU?Cp=_clzk5`depxftD7Fi_^dHUPR^aJ^sBq#O2{2|7WDYx3_eiiE0 za0PyoJ{IOXM?V%i9xsB$*>aD@>bw1%IEIkcD9QkLWcMGz#(c zAb2~4?_y$cTl$PrHALEH64cEM=HDSw6l8rg@ePr#Z-{XGTM&_Ubofsgu@qmE`Njyd zD0)(fX1cWF;>mip5{-QvuDi`NEiX9C5^iyo)tor`(qSS4++)pSX|*r54z}6(dYX() zolGL~QKq}y@@O&1pB!fNvyY0cT^4mA-$@GgCzGzE6(EL)A0m z6W@uaSe#cXL#)d6W?BV3=$XF-cu*|1&@RCRqu_fWbDKfRond#IF$c^7C$pL;F$T@~ zo3?);@m}N58^`fa>X?qq2vCPcfo;aVkfE)1Htdk3?+F!f=(6(nXTnkjtHDg*RKI}&gMVRe!8l77}50qlj zFde_F$Yg=5!Cd}vIDX8$1kG@=TtoBoBwpzDjE!RAE~?g)h-*x4(FLtT)6WY41D2&> z4$jEsVaZll`vl)x$LQ6-?0rRljrs+K)|%N8on3%YTF=TsU>%q>bz@0Z$WxTILvC2w zY@_Ta@8_`y{#ffH3+No*J%K=C>Dx-*&=_E-a^F=RO78vtEU@~^lMIOT>~McKhvne> zdt?U_!#~}}MUtzB{8GZul;^79iUw3|&9@j_Bn^rl&6}P+5U4(Tqb~|y1O@jRpsHNG zk8e;usljai985|fWlU^Lq3tRWC9aR@a10gcNq$Nry(W?9S_;vI(V4{wMb?yu+@bTO zdu$-S$!D7PVf2%yGy747`(?|~=F{qxcDtBB_(|8gLD3NAeE{TDorY`d4J#Pu0Z}B^ z)Pyv>Q1c5av!`$8aW*yUu_`mtti9ALU02s`zWuw`rX&{IRG+qvhRvK{Ykz_4py8l0 zDm7gtjh5a!>RIP#K@zIAzDLFl^yM>=_dODoXSi+T4cXor%&Yxa$5NH{(WrUE6^)kj ztadf+mgDTx^M|L^yZE4eM3>%sH!t`8Q}nvRP~1mN+~=w!`7sA`XHTRx-6s6LrOD;# zxvBN(v8ma{^y2dT^4#>s0)YqvB-#3}a3c906Ek6&*hNa4Voe#;Cv^BV^*TCAOZ3KD zfbi_unjcqZjS#8Tx_6I@6`UE87Y~>ZnwFS;^&8ODd39x-4Ks-HMf^ zlV8Kt^>TC!glXUoTUSgZp@ZW5O4T2O-OTkueN%NfP|)Vh`D$`-pId^s(MnICh5bYM z8KHafwVDi&@=Kj=iZD(D^68gh8TF8vne6k@ie_?!!Q}j_D@t9R{n1822n|F}ZJCU; zuX(D8;3Ht>9SY%hjsfOddq*x%Ba@*VRwGU8-YLb!O0;u~AjcT#P71Ct!jq%zJZHI$zKnMQP?*JKa~RQ$S>;^P;#XDEO@UIseQ$3lT91T{zN z1v0{J*NH9*42Y*~!6w6MnmBb3Vsl_^|K z4v>PygPuI02XpNmIW+Mk;6gb+kg=_V5ghM0y^JV8!1dHuIr!0M47q{@>o}w7_uFgw zV|jFqb3aG2K1DF$U5nQjwlhn0ml$hx6k-*HyCA^d>oBJ)C(q9)f>O_(AQu)G{~^@( zJ_bvY5jyz(yME`dEjCo;z|V$pGKWy^)|S1BPM79Qk|FVuR};^T7+&bNQ@j-d;BE2? zsJaAxB;j)d>s9@g70^67c(c%1Wrmt#p7)G%UMdaqUL5u#%7FJD#^pE*HTcA9kaj;bJrsSW8*^v{9_5Mp zzRE9=QF!kpUYiiwo9y=;K1bWGJHE;fImT&o+vJ<;qg;7)Xay?p3%k7!l9Jg=dp9 z=krD!_^d^qp3cDO?FaSA%8H5h(`4BGbO-GssAKA8{@^U23)%T)@F&B6Cw>z=nolja zNt9AY`!HGY2wBcXa3N8hwHT22p0ZU_#2z)0#mjbyOk`jghJHe=EQZIFjmjL&ie6Ab z>db@_yi`~+4|JY+e;!@ddbGqkpENyRhU%!JbW?r&N$GAQmfi~;>CQ>0C3!B^ttrj= z-0k5~#yaweVUueZZ_9^WM%~Y;r>W1!FuwoJY+Y))qMTcp$u0AeXGA|D*J?G8*?ig3 zY?()iu@MJHeKKxcfZY@pv8K08BJjaLHc#UR_I+fmWqT|0 zh^X~JvAy9!3rkYU>0|0?V>ef)G8<5*nK;(oCL!2|l<6J%UhQgnArn=NG-Cm?$uUyH zbtJT>F_2Sm)vKbNiqNo z(GtmLlcM5mELl-m+Q``6XbIN1ON6@P=4Ae?U-l>9vIXf4z;UoR<-V(gfXzo?GK*i60 zu|jZm#;I(N4)uTfJm9@7+apQi&{4}AkUEeMYvz`?NS4*v9VKO-a6WL#y^f9F0v+Fj zwbEK8A%)XMO7_!lIH^>!tF1Z4X${#FBHxj^qvhD{v9uVq6Q~ItUrWx5K2>x%jcKj1 zsJFXX@LGbt^_pMsBOsGcO#`+YrRT7coyJdt6fBJWC`K%XI75NM634!iB+0}?w_uDAbm6?suhJ?8;DXJi**WEW<+4_Cn;TmMJ9e$HWr z_fCL#A%w~dj@=|{Srj(R%1z)rx2WIhwRBnBIs}Ibu0yZ(7(qUe>i5o(WydnLnxH^H ze@wG_zkpc(04E71GPo@t!GLIeI-KcEJPXNWxb96Z3&AF0@PKzXzFNt1j1u|f%wkBM zT3WO68N!n(_>JyGc8K{?2-y6kUqf|?FKCT@#V!F=gKoUVv{8}g`rsRR-IL5JEO?F> z+`U;TLjXMSw26_i*)%{0!RwF{(QNf-100QFC@lSa(a2 zV&$y-x0~Blv$J!pH&d}R-@|Pl`rJ{j^9Fkb+ac1?y34r&4vs&S*qncwrd;p1^8Ars zUzPN!qHl$zW{2n>jbp(#%#D6YVlL!!)N<*ZEB7_Nj|vy#>KG)y=Y3YY>chDt7xtbR z!}&_%0cP<5dtt$a^zn1>)Au9CTy=?r57a3TXXYX8lGlr-^Iv*rw)VTeBX#(!d;89^ zC`yHh->0J#n8$c(dSqoI?M8e2H z!RZc>-k{`?MyVuAB+<_hywcAE#G*=)q|I{sT*cB#?F-FhD-_E&2EXTSeESW`b?L)mSdev2 z)K7q~86D7GdE4!0IQ%p&#Lc2P0HaOCH%c^Ypa!rf1L|BIV|YVED(Ov^H>l%qm%#b- z(#4$9>_z@HAt*H=d7$<~F`I**d{irvLBsSh#rTF|j+9DZ2=1;de2~gJ$@H=X-Ewi% zs`=S-IKDGDDs{G@uwGnr&yQY~$FoeB!_sOuU zs~^!Beb*JrWO!zK((KtL^F{q`+*^I~Wp8OIUdwP(9`i>QVf2gh;kpd{q5TQ|Y42EA zR7x0A3bTTS3wanE&)ra2*rA5Brm7;9JW|rLQvqqgkYs^1N=wo!;L@)IWjG#R`(brK z<>5_DTCoLu2H)`C8a1*FmGz0LKuIP&bn2lMx6i~=GK!amj<}V@gpJ|~^6fthM#bxw z+pEcP)&|crMz`iKiIX1T$fmLK$u9EhMchLY&0*)TE&@`A)H*A6u#!`RwVkj$4>_9e zeOA8|>r+2}y7q=OsPVzh3e%D!e{Nwj8LJvsW(Ob9i8WLkjksKSG8toXHou^`U*%XJ z?$6XXv_~0RP2UU|QFx?t>7J^9Lbg<0Lo7I|*946TnZCFX=CO)FoJdO$5+B3Ki`Jn| z7x*fx&V*g=Cgjzh8k(qk?tN11icEJ3DqWxUIF+t4YuSV(;99Gh^bbDb2KT);V?5lZ8!lxB=niqRsSjX>(#_IAP^0TFepR zr^{>2_*DG`@u_r(MP6f4`o?e7pe)STK*RZv9kn;_9~K?#h&8w3tAkT5`` zC!!^yZ;yZ%BGre;i;$hXrX@p3h$x9Lh2qv{(+AZj(*Lc`qz|J{l@~@E4L2RHyi}{q zc%VQ{bD9&eKI|TqP_*5U78R%= z>oU2gj);dQ~+*AhcagpYN;pV7g;=Hp1#NG$U{#fIQUhD#2 z)F75Ae5pFpq`zqYowNF!ixnUF|DZLQ82$@dbIojx=U32If+4vqOyw+5q&l&q8^Drp z7#~c0{BI02nh+iv8`E#mJ!E$Tb#nvOn4!^xoIoxf>{0_KLJ3%^P=QZ$u_ItX6J|C8*3HxjTOmPv`6>N=uu)n~ zDChbMa8Lt0Lc0zeCgIQNO>YLOkP5?Zlvv`PQxQ(Hari-f+iVtZWS6fV!+n|8{ER5Y zCIZI70bc4t9Gwqm9t1FtUXsH)BO$t;MvPDeD~4LfY{+b<@`$NwDTecR15v~^{%O?(DFfZh4gmw6p`rHAz?EjnR$2Q1J%jvVe*k{Y zo#V)dfo?+Rn8g>4s$g#J@)xT~(&zk7R&$GN1!3Qq&;)M3PP94+R06?e4p=LZ$NX$w zbJ8Tb2K*na<_PEC5kk*LmOJ+U!D=!w{Fkhz`S&EnU#w;tDs!8}#<|7@y#ppnfT}?l zUvMvhgplx1qjJ+p!NlTUtmeB)CmG4}k9V0`duu-3Rz6zl@68$e>07R?U0?6dAlpbz zn20y)gQ-A3NMKA_B$gY4L6MR{yKOWOaZgYOrQQ==cGU5kNerlkqV%C4*zFtUv?{{2 z_$XptD4}r<)4y*}t)E6}_s4vm)c-|lQe)7z-&p%K67N+G1#=vOQ^)xGMhH1L32Zm~ zgVY4Y4t?#(h=3}TKnOZ`gVa;WkUK3Xb*#ka7V@o3(xg);e8JLYjBlGO%JhJ9jKc!K zpr;_=mxpp4M?j@VQHH)X8)bm$FOqp^y0xC5R*Z5T?=dW%*lY}2meW%Y#SD7=ij3H? zS?oq=7~Qt2R$PdC<1kd9zoOMQHYZVf@Tn1(o^bznZOH-B41IcF~c z#fo*Pe_;$U-}s^m})nHFT3dWA$a^_{9l}=pK%ux{AUQ(kHWhkgsk`I26-Q zHWLLFAV2549~r*5xIXY!LY861K|uBn$*9JY{mEMJ@}>v~X$+RDI%<*p~0LN9)zOcm^Cv%(;*-b_sU2{xL(!T_uQ*R4yeh|eaC3}nodQy3` z0Z_Ne`d(EpJ8!sv#BsE3_m**ZO$A;dZ|9;fP~1RJ(cUoyuy zada=nr&;Et!N52_{`mm?6zF2#j6cm}Qyp!13;bOStBHEU*}eq7cZR-#ae}$8yEgt7 zqwsyyJp;%Ar)v}J=C$y>mC%S5hR1&up-Bu$KW5IH1`)G1ZqXOgxLVxPH&V#PgP-V^ zzCRPCEJz8R2W00?N5x!_2Xqzo&*x4Nfozs*WtttM56(m(yb~V25+O>M-7{h?iC3?@ z=@%hN%u)UP5L%~E4^>6ds%AYLq}5=5IJdeTxWrny*D zuYqK1^Lo!fx=%8X2^~9TFbp@hM}U~vGXe{S@^giDHaIt^f7VvPZ*&GW{5T->(Y+~V5JvEQ=6P@Q~EZV2Vtx3GIk@NVy*6~L6dlv!eRM3GK z_~Zqym}6#nArFb{*z6;-iQ5wuFS-~p(;`zo-b5d=GpnSsBR5j*M-KACjrVvcgn^*2 zaiL!#)Hv0C%mjV&sIdL9vXm6~yy(I#GQBv|3_|uadbd&P;REnRFb_{=_<)-SIy0ok z$z}nuJCOvtg+~2zkg2eR`>e!31guF|b3-MCq-P3*ITl|O3_*x)Avn`h8(G8ocy7Tx zj*7J^zG*A%V3^0q%kN)ERbWrLNnq~VPde0bZz=UOrUP8Ck9QQ^`?~k7*9fqiz4c(8 z$4}ch_LVNzOq(}2AxyBVsx-h|JbReB2A&ctj=0qoNtgb%8}ss=4n!#tsIMVY63-|m zdq1?9^ttda!igoa4)kt8~=X3q&aw|nv=r0^UecLSB=u6{C+Aeau^Tn12n zSwT;o)R#ox1r7{giV~D;%p0zD+M_0(CnJTwV?~}V&|3AknAJ7S)K>5grCt__)!q6R z!sWT=u~cTM=|o%>DitV!%k|H-E0~=SZU}U-&F4Wq@dRdV)`5E6OpBaNu$~bqmuU#dN;;Oa zv4ITG`g<&tjdwdrrk)bCf>Py9#jH{_-%yCzMDcJ5>$+v~*aGMx4NfFubHI0#>OlD($YfXGW}4O2;zHf5l)nMq7_bAk}JHHG$dWKz?B>IP;ql2J1W=9nAi)SU~|vh?cr*oiIJ8m zD6J!Dm8J0qOyJWP?&l0t+stqlvPN{c8{csj8N}cy%@RPCuZ95;C?bVZF6P@3BHYZW z*T@U=Oagqu0&x-oER9#x@cj`6%x_+^fz}@bVi_XWJzZK$OBP4ODpMA%85BO6?=o`< zky4o@>vC8EYqiRBT#xxdhEXdgi5zdZSG3QCETFtNh;{2FIDsMNL^i%1@m$sc@#dO* zsC9r6=-&dh=Lr96ze72t(C~yn!?nqX8a+Z=j>b!{=b6#d<1+~0J#qEiS;0I@uI+uZ za)i!o8(*oyaV4hh9Kdm>STRHMxxHM2qKdRQb`s;;F^iE#eeJ1PNL<{g&|Cds`j?-qZ-8-e`9`*&I8 z=4rT1FX62$5rkb>&R~=UG#&~rerp7xXUnWLqJE+`CBejPU}+Y^)Q)}lsG)jj0;?FL zWkF!br~|kg7;<%1eKj-Pqv)vv`VFUYs3PMCrvrjwor>oJzXCQ@RZA2j_R1ky3x^=( zODzoffSW?7dySUGIPI26nzhERd;_rEyHnO-b>al|jp>>9!%4t_{J5NB)VzHQgFM3P zJ9b_-xAzaJFU5tc>#3<(%N^(aiBa*qYa!m~)ol};%T|8q@ZW~!A-_p-kp}dz#RLG# z1b&$dpfiOyS^UNg9ZYJ_&ogbrYyTmL(qtt&G5FvYXg+k?yl8A&(L}w*?srCL1B=ki z1<1^^4GO;gOTqT%T0cJPw$t*qlghRNAaYYT;bmtbo^vT_U>X%^+iFZ+FSc^6^zqzi z=Qw8VMH7pf`TJc`PU&Z=WnJ?VU}-6_EN}R1qPPn09tM+)0mhs$rrv3tbi#mpc33wL zM=0P8qS*kd1-x`#=Axt?U$6WKfVV~d1+v`@A!9|G86@5P_K~PbwCc241(VI#sk~8* z?T9#`)~M~4rMcr?JOwvM^j^KDmum$4+DBG3>}O`si_(zPK-s*8kfp!!O5>dB>aL?! z?&K}o$cHFP!bu&3>uYd2y0n()F1+Cj<9_(qysEW~)0P0xL{k_kd!ORH?;Y@lkCdxC z_4cZjEBUpc$4IYZkc{oK^UF($TofrjM?X;r8*u~x^b2y$>e1dvz~SNni_|=$uE~!r zBy=^_vYfOAx$#_~aoyl&oT6=~6&xp35am!cH;zsU{!gTmgORWQP=Nle-?`cI-J?(o z3IM?UZwG7tIqy^4l1EoW{Ip^k393fogXR{KD^c(bsC`7{<5dHymyFyN5fpEjldSDt zHDI=4Qp@zX9zgv9)V~>++z&)H4wy6v=3?9P-s9H!xFia42trPmnB@E(n7(?Me7Yd= z{7`|?0V9s~d z78DxD<;g#$SOY0Cfe3;2HQ6jqB@d5)1iaeELb`3;vpNwPAhAeu>mg0TY%3jm^~-Fs z=-Zf~zd*3(&~7-dh)r3!anarO62jrIUP)}iiNT!mH1)7NPRujD5SviH)0iWf%BUY2 z>~H05lZwX}tOeN}it2{Z z2nQT}7D(i1eYcCG;-*SQn$m+r5(hke{*YiICy)2AjAP7KNdU7as`~9}cVJMTZz=$qM;6G=fkCuPsK!=+;70Nj zz$vku3%RI_+Ymny+vY9(qh&CV_&0$y)2s2P`6M8@Fxn@6&x_-;4GKSst-zQ2( zYf#UcW%TP559PvrIKz0AAH74R+QB+m#=*>r3!@{gR=wK( zK~{Z@R1t2yvjgVg&*tP&Yi0io*36RwM$#s@LL{P;e00}-*G~OvrGBrOuS)D;9ozO} ztBpt6HE@&kxWd>vwyYeMM3_F{W8B%xyP{?7wA&k+yY-p+@2Om94y6X{_h@65VMGAx#k8=o$F;QG`=w-R0IEl~F3VH8{T1idQ-(eBU*3V%hx~b& z2iwYF7w<421=*-4yQO3g6G(S`_NLPJrxmJ*u59vC|6}a6735uK^R2HhgB3&_g;P@C zQ%)2TrZb`dL{DrMEJPHbp}6m#x-luEKek#u^P_zpRH*SzaZWRnSF9r*)m_-n2l*^n z(uW#bRGdIO_Uo5cPthFYjSz>?x;Slcbi*tkEf%2{_j7X-*?Zw)iEAkL$I5FZ6sF*(E&C zrAQzjMP(-SmD47gL&k*4^*rd3kgZ{3Ukp)D{#gyz9Ff;XUCiO^3K-(Gtj&V?z_AZNA=KehS(bWyQ$xt3-tX0Zz zJGADC=JW|bsUrBJ(A~gOr5q3Wgu+FTH&ZyeHFl+qD{xvf+I)n`n?d|yE<%E8k}CM{ zy9@cblA$2;0C{jIi&;i=skh#rN+aK_r-zA7#MN6Es@q<ce9XXAM8kjLFbcPzjW^V9JUwt5$XKNDKz4A5 zuk*QU%5Y7_y~_28Q+y1!m$QU#M5qiCWsU+-1Xt8CPQvpOZRrQOE%3l>rXUPl?I3K9 z+6}+Sw+9JGGJTujW#?muLa^Qi6iPwwBOh0LfCu)5j=?$-0NCl^y^gsD#!Og&+k_Fl zrnn02&^ludn9sRlrZ_QSbg1`fa|Lgn)UGr&Oa9~Y@nA05trd%^ZyMe?$cU>uh)&~8 zPyEx0@Kaz3`{x$7-zUc-qT4$doG^+rS2SUiw}3Lmr|9l;krI=a51id>CeEm!WdANB z3iqA{{c-I)ellgF@(RW_%njXrZTcWtn`hGaVsn!eG`h;Ry4E14+tW+a#$sd?iw2|A29Fe= z!qYhfYKeqsasn_E%jmXpG`CaDW6K;E_#e;kY@K*-RWI3HJLGSP^v{)%1M{R5Muw41 zUm$;XHiHilH`^cp0NCHde~kZAXY-F4!vBXj%J>dI6uw0qF&?R$om;vMK_CYDvEk0i z$o-0W32=bJDtpDKW;$RTa_;Z+e2Y(n?Gn+)UhZQru)xz==>P(rQrauu=<) zUct-?kt=R;M!1eH4O{Tc`YDdmr=HU9KD@ZI?l_M&UyjBE0UA*5vVn17!)!GMK#@GD z+An?m5M~jb>Q0jK;>bn~zK2+$VHw&>^rrm*(__e`Mo{^q@a(8c^+W_E227Fq0Nvhp z@^Y?q)Q!cbbi}2l#Z_>Q4Ad7l(O?>=*VIAMD>QR@G5~YMak?l|k|oeX9kOVdknCyn zbt#JzaE1We2xKT^h<(QZAK;IFFH`acf}jyBTl;dtYaq(}bTh9f)&mUOxDhxXo_8Tggh2g6tJc0TQcSyOoWBdNE=0%)pM_X_!G#bB99y#EL#rzw ztmkWD=aC-23_FnPE5f0hUkFrTMEoI)j>?=ElA2>zCe^4UE{H+8m(F9T_*87W_%i}E zbcctA4lTS8y$qXqBtVJQZFNdmAK`?>e~unM1vh=rfB_46Se#hBXs#wvVKn(SNrUjI zWYKQuju1WE)t=LjrxOLccEY`2(xw#h1QCu29@g1pdbUv!eSacIMy?euu0KYIMfpqA z_QL`Tv!Zk15&`N9HN5041)+^__>3(CkdP!>wZ?RROzrg=pfn|CXwN7(J4Q+r7#87@ zg7en`s{Xt&oC{P0@h_8C@0AcQS!K$Sk(ctM{6Wh^izb{!3N>i$Ee?qh3KW%e!VyY%;1nK-gie5cQg4Dcu~bI+90k3{l}Jk zhJlm%$$Qg4q75Kn8Io$2YS?Xdg`7g^c%IW}>uH}67cJp1FKH$}Rs(_R=GudtVjcC6 z(9iboRwE0%%Ickoesv2M@*5^}uRqPRLE3ngwL4~dS5UMdRRb1FZDH1%a?c% z)yU@=ovb0|hqG*rErqt^rz>VDAj^@UFTJ|3k7{X8HB*1=KVA1VG;3axTUr?Ex>qTL z--?51HrmHoPY!`G4omI6mTWY5KcPul9^Bor#&Vs_HKbTY9!^R` zC!v|6Xiu4_fVXNyheRlgt`L+u_7R?y-7}LNPqLI_-@ejRBY;}EWX^3QbO7X7pGd-p zyalN#*FWB;O?40=9tB1Zd-a@jch}SB6jZ})Na{UE?woL|rSGdbcRZzK ziN4IToeIna6_L*LFf8)E!8g{)hG6A!hOEkr_NCp(j3|Xt)O$$p_w7X9@hzImLPf?B zW#R*~2ZvQ(_|>z>`hnnQX6YAC##L^3Bi4KJK?C7f8OvDN_#nA~62P%}Xp%5jUt?_S zf_yS3ts!XG-W}4wL)ir!Nk8vgj=EO>1v58&e7y0ttm|aj?X#q-M|}PYk#s}VdhC7% zdApn(xu>xSsB4O@rBGp3qCu-+TuIO_K$mRc7VE43{H1K+Tl|T%(LJ)ETlf0q-9t;T z*SlpLSxiDHKDnZ}Qt6L7%~(0ieZrA=0Paxf{o@H6hswyk8^frftRC6sYzXGt0MIIu zUZR~q?ee{%u4L^)ISf25A@?qoFK*Vv6byeBGh2&oG}pmZXcOIAg`;Y#0e*Zk%Z6;# zO^tcC6LY%m<=d)}n(g$!rJC*3KyhbEmtGh`6s7c@YI*1&dq-uO@>4gO^I38~T~6h# zDJ9C@fo+O=^Fv{y7t&Ir^NWZ@Ui0V_>8q~4zcL13H@?H(??H_3d3ehI;Va`?MCqS3 zh5{vXdGsYzFQq1=F~IPM^W^Y}fyh7rNDIG=3 zCHhCRg-Q3Ziyehl2&*YxAqdFHs|{->O;X0IllShO_qPq+*^XKE^}~Sl^Yoa@&QqQ4 zlXqNK_M3N=pVwL1TR_u+S!K!ce{S&0dIDXc*{@Y~?F<=QbuDh1vv&n;yNY@Ha)ZQO zLoOeaFbOZuU|ETB!iEkN~Q{?^<0v_y>|0&U=#t z@0k@H%AUi5jBeV2v=^=!Jv7HX#RU}xm>B}D3wb(DhnPQ}6T_^_T+|1g5$B;`pr3|> zh^K_2pRza@8C;#(y)8s|b?)0=a#2OoQc{LV*^@@cF_dKBCX8+yQ);BsPC&$cxmfHh z3~uLFW=|@tJORBes+Ot`L!>k0Pa9JM8?XK989l1nai`}qIEr`xBAvMn_1u?bhKw=3j z1pP)>Y|p^cA8ep2umw{Ckx%faw^Oh(7Oq3C(3l?&|7nc){312IC*GBTDKU|6(Mw`F zOTmX^AX}{oU`3R4TCT7SEhwxex|)zo2*KM?A|V%ZLj;N7AuN{?zcB>GS5cG_&zYvz zzBmIJdLqKaj6i!d(O-@vYH)OzEF(Z8B2XxgO7Bd{M+Ws9ic^f2=LP=7@3GVG(Hu|) zFA!Gxw^Bjv9~Gi58t$x#6(u{7d=a1Afc~e{mUP!pNU=Q$iL560$DKuC&3TP;4{zO> z%2@E3`jjbo(>BD7q=d1>B~7?d<>~a~xg?9Rp7QjXa}!hBg#e}vkjt%Ash4Pi*{I3Y zl5;*PUFaeK$rsWcuA2B*>yqY>CZZ_6D9R%ku@?IQ3Z(ON5h8T@+Fta!BeXy+quxfl z7f&|5khA1&a7=!d!aaaiM-IaAUzzec7USa2!2_jNrOi3|@WhDc%PgikxCul+w2^_; z<^&myM&k~9!LTYXP~FFyuM87_oWb7nvIF-fa!p16z{YgP2(`Y?kX z?z6>*po~BQu}_|9H)m{r1tXrjrkBp$#P!amL7X;`;b7{cD1S?ha`&~dZT8K6tzw{> zZyYK}Bsi36VMQS?j&SYF8@q@v|0wYV`gDoQpo8P|gARVHuOOIf*w}%iIXbG4h;_Ae zn#8tcRiC=8f)#A?;;dab!#qbydqR2~Kkfs%1Rv2VxsixceQ9UylSkSNFby@fV@m2E zacd0-=a8~Q;?Z*=n>bXNPyw<4dr=93gh!?bLs(_oNAsL{e>Wc!1ln?dNqr{0*gkT^ z#=F)bW_wvtuFlHxM$Y7^V9Cc=3bP+|=TTt4QY1lOoMe^0JHfBKzxnid-u%Nhm4IH{ z45ZAlt>t^&F9&4{Jp zYJQuluIhF@^!P-WVr=rnUxXQe-bY{qwRVj`8m$XEC~EG9D6IpKrL)B{Qbe0siUMOV zh<)OC6WkAX-y6=R_h7qUc!ZOAFpNLES1d6zAv7nAt&VC8jWfFJfA4;5OH1YsFN}8~ z#YNu!)=O~ZK0Hg@F-KRcmCSoia;5h`Yy3cC+!m0((~3^=25<`Rf!*B|RvRh88d~2$ zv&IXbw}-OhzQTz)zNUf8v}^LLW!+(XQ|X*u+veWREBzr^?+o0r)Gqcd`YAX7t07D6 zJcN-XOUSuFJKA->U4;pYdhRFYo>1D(_qYVtU-q8zfY$WMw&0pqTshfs5+|lq6O)<1 z7AO4YLe?Yw^mcBFIj>!#)(7<1-oAhG&|tdH_)nb|m>NfW*e%jA`h!cGfa*plP8Ycp zs}>PtQ*okNpNgQMb;n;QKyn33xIt0?i7-@HqQd{RJjlC2XO;+u*r4_`rkC$i9_pO( z;oh&ctM$bvWJ!TjHz+mQ{d_)Pkyk2WC077`HbYWNd{UL zpWW1a&i|R-p&6^iI(8e^4q5yh)H_3eiKS!vH24F*!_MsRNN^M=-_~ z%Lu-*);m_L`6IAITd?mf~n7U64XRUEZ}!(g6=YvSzj(wd{o zyXXmfInrB9Nq1$tokrD>$_=E;bfl_dxC#@Tfcd(PM4tCGFY4ZHjqLGfg3?V*fx*^kQwtg~6KgaHE<5IZx7!0q$PMJ2Glgcm%&WYSX5+`zmD?Mqt_t@nyz9 zMhQ{}4|q23%J*OgD5co1j8P{CQg(tNE3=vIv-ERYNW*!R|#gmrX-#wmT9ka z>ddxi14@7{=7_e<=C$L90Zt)hic7}FpED)>Z~Mg)eEf%|r^Dy(5d50}$A$QxXPy67X373v?FtDSPH2kXy(~rG<2*qD z(?s=3l4#~2a%{Pcg_4m$$s}S3mI>kZljn8bZ+qvSFwvi#UjRHXS;DrU;t-n(H%f>_HRX6!dV&LM zWJ-$Vg0z9oi0$N05D=U4*ItNwDzW^s1JJBhW4icZC|oi&^6=PV53zodAcnrzj%zg^ zguZ_i+{ASp1}anm5d_z27M;@+%Q{RI&uh#$yhw zv^@-i>FB<x=SEJl=!W7dPU#DHo zJg7pK?3H3)WDXSSD@BitwSHETme0Oj2FN@l*Z`{?B#rpVN(BOzA*OgMVYYq~TzZ=f zEH&JYz=;mzSh26lusL^`VD+vo+vIe^>r1g2BcepTNS%8tD1K?RR=;SmQh3w3*hbJd zeGN4Uk)O5PNUji{=TG1tI4h(?6hC1)tfR$4nN&Mgkih5rTG!r^n9x(YA?Vf6L@`T8 zkOYa=NJDJ%*)=cjAC&}RlqtvHyMJAB=zJkGVP^jP6I))ga$T8S(llQG=QRM1MAR=yQ~HNFBQBwLTDUZNAX&D0wD+%n`BT?DgTTPjKTH1h4(&q%Wme%B z*dg=x9@sU7iKcmWrjSx*{O)+f%6Tz3WDe-pGV;Qn=|I0jzT$NkA=8b3X zOCOPYdNR8H4&fv4bKRC!+(?h0BZR^|Hz z!6Tfs#4Haj!K3XhKv7U#%bG3Yh9JAqjH+nSy~5ZU8jDFRP>OPG+7fPIBmEs%GS;xA zpPLI!(^~-M>?F{tYl!Ik>X>CMbrXTsfH~&U2u{;asy&Fc@sS-2FYIEGB^lr>?*(xW|x8V6&48hQZL@oVJM%*mN1c>ZIEtV;W#NTUK0@OnP(A?M=tv41z?tbrMOwlGLQfjR6XM9aYKD(*o?tIF5YI}}FJ*_c6 ziiK;AXXEUl&bRGvA6h1w4l3So0Zu)~8Mfq{nidpC0hiyl!+d?)4*O5rNpXOSMIZ=4 z{$=}rkb0Eoz(+OzK68a{`913YF4yEY(Y5=B<==9A|2pB)O&yW|K zyb85Wo{waZ-k8~1kgc2U85E=aw-y%Du9tRQ?)2ST64ZgvHn;G4bgKsg97H2ZJlI%g z4O&H_pJmWH|Iy^)6yo`?#Q7Ez(!BQw!K&e~C>`Rt>{!B)`wU~>e2;Dt?%Gc`Ki$cD z4_j_jcD!>NV}gA?-!WlLEVo#1zZH{hPI`>A=*m10UF|*Tbf{(v>HyQaH(Va6fiq*Mq~g)ULevAblh5}V>z zfP(eOzUwpS%jO!jC{?)rM=H5Z@rXL8_jv()pB>qMcW!_C zr?Nj-FL8l_g7Sl6Ie|(!fl>*9Hh$M7-{Td8KpCO4%KK{tKGOT!9X!AC%jfsu%_Svp zg+Qs6!uIF=V#nXx^_)PhaJX5z>Pc7^DCxmWAfh3coAEefgoHp7&i4sE-g6y3`u+O% zBNRUN-`6#=xjbW14Lpn0gg}2dn4_RNgwB9FgQMdE;R7K6{iLa-p`)R%26!pw!3r?~ z(sRN|3-ty155;_Yi#zZ9Z!@{R;~Dz@p{f5zO7~xp%>RbzAMu23&35f>B*0Y^1Wl85 z7g3u2$S^C|1K`r>*v8_jgRuTU*lm7PW3fC?Tivwrbo!U0w6FBL&zo0J9pp4pf*LYi z$==ogXdqP}*53FkPW6V;)jP|j7Sb&EV?P?N?|tUk>Di@fni)J__MN;VU;EvI)X~rM$OFT?A9zZ zhgvEr?-axzCo?HGz?bK7apS7i%Y+RZVLg6TF*%(s*yZ8oWYprga zC?l~FJPW=!Rzm^hr4P9<`yuYOAJ$+HH`flOun?iL+A*yQhB38|M!WGgY z@U7SZvltba#0BcX;`;}9Ub6lKzw=ZSh*`j(+p*zt?I*(@VS&dGtAc+RtA zdp^&i<9a#^Z*q}H?K%qF7=2AXUte`^UwwX!(tLTkVDe&Gzs^S7YjYk32a)mYiPp?~ zqzK0~CZGnPqQ2kk=HZsp?F+0$lN)-DywaZc&vPH3jnp@es{0DX4HEHW^%^Z5sdA3* zv7t=YyZR1>Q)=tq(uY$}YU|yShnG`o8{D#ov#55A?opvEcerZw291o^${hFnsWP>( znZEgBJH0a+%MC2v?bk?Bm)cQkG?BurFr)Pw6M}O%B0>*GWe0+B5UEAR5V6hgtR{9A z6!MgeizQEL5>6g-YN#P~8jG#J)%9D;8dEzLJ&q_JG_(7L5U3y-vf%w6d zR*&$p1O1{Ysl;sJfa63HQ^}STt}91t&d-fYdj$PR)0TJoxUs-G-DM?Rn0X~Tl_~|E zq1fUys%VrNIP&=+{{SJal&l1iKo&qD@YIZ}I48`%6&cSbC!Pemk>R@7)SD2VvFD6!G@OMDzTgLuN>LH%t7=CVTfDBV@Y}m z9)(l+HqVTvfHgN*LVk&28%Ra=@-!@bvPv#T)6wD(ELP7M-+;BXWVl7)B=5GlP&i~} z+EpWDBZVB#drrf(Vx6P7xJJd!BKobk%dES_&h?o@nORZSI}`=-)JoM*T2Vu*m;?k- z&%fSH$v#D!V#3cmLAC?aIF3Aca4Ydcf{}r~jyG@iw2;3^J~UP3uDG}*tO2l{ExCJk zj%6=kn*-0ELHvCE_Rv9)=<}lIg;L14`-oGw3gqLwE~10rFqe@JPFM9|24>1al#}HC z;f%;$C&v{*s+@WHa01$%MSBiV_t6ZLBj&#=^DLdh8qqB1wjyt@%!Y>0FbymtYH1A+ zBcy1VdsPz}N!d$##x;YAs88%LS{@6*(cAnV-1V$OfS}cVd=NYR)35E2OWFg~+*u#u z0C~cGS{Pq%j*=1NA0CC}8#~GOVzl9YETv->|Mu=mc+z{21|9fW8Bd%lzv97Tq9LGG zX@W+$={yYc%FH-_99i^}%Cyi4_oJ-O`+_LbIKdDTk53(0U4?1e?=I44PI}J3m&5LN zfjQ>RFaCzt)(^wMspS)cy&|VCb{6j&=!S+iQMKRat_F+AD=Qmk=9<4f&_7MU2w70Ec^vm|8$7y>1H3oa9Epq%OKErM z4}gxKpb;5H2%il&+F2NWnmQ(9rb}ZH&LOIoO5v_3l zS~(hhYS7i7TG7T^uhG>YSkWd~|Jm!Dsp}E{i9Opd+h9fV8S(lpmIbMLUGb&jval`k z$RkYgmQ5D5to0ebvh(n@wCj_wbV#=&npr4$L3bmU(k8UPL9pp8cveTx7OvOZ?G2Ba z`NM#bxT@eQR)G~SY*G+2VyKu=HI&Gu)FwonzvpO${GN}T z5)n4vC>4?bml`3)wMzX+NJEbv?_Q^MHo6EQIW+cY7|bvS9Xr~x5|7PK)b;(VaIRId z7ZvyRp}{OpLcv!tLwdYN!{mi{9U)>wc^PLhF1s#PX=21f_quiTQ<~V(f|dA93JSh# z^U%LOa`}C=K}Zr0W?dlTTcM_AAf{TPylV&&9}5!y?EMl2{_BGjKjHCuza$c;o*LI5 z+<(n2^8!4I|7q;>FG&CY2|F!x{131b#sAOPsbV?MujoIEo&NEt|6kY%TIkkW4KF9s zE;n>2GFgzwS}|{2fXkP>p@8blq7~n67w|=PD0MD}PjiKs;aKwX&k@hDr)O6u;L4CY zgczl`YFrJa-e6CeFH>K@H+W(q|2Oc&#z8#8ruQ)7^v=u1C&aYI?t3K?aNv8n();b` zwOsq|hqGXnt($~fS$68qq|lh4v|Mz^ zoX#12-`O;QC{pKgr+=#4Q}hJB>iLI{i>y93Bh3xjwMk(;`Bqrrt*b2X-Wwb&@TV0I z`Lffr7m5iUgfv%PClr(e&h93he8_#;uszoP7>vnY2B{-&b*^ECK^TLq{w*=FYvNTH zZKx!e(K!50Ok$oCN*FfOBcgMw2@TcWW?z9Acqvj}V%8<_1F@vr=k4aQpA9`}CNL#rNl%9VZR{9Gz{S!U?(`@4ZRk7Mq z&)URE&*WdVw*QiGlC~&yqx@cm5a)A3@fHh$2D+ff!8G(-HnA~2C_X`O z9}1-yZ}%5*jXLVXNdq5xlUrS`HrS3gT3x(9U*C~?h?(nDdzcWHrEc6RjADl(DUa+J z!jY^~TdeX7^)vDAdmc3(WAL=98qgxJy9v-y(yK^Zg8iGIbV8i7@dt~pL%bydsZAb* zjt4Rd?eBT9!dN%Gm$+l1%2bL390~4%a}h1l?9!|{oD6d`_e@6GM|}8R zjb>SgHdxAEu}cXIxufJD6CgK&L#g^!qheL!)(H$RGl%KvDq?J67VTB+^ahp+I)_MA zmH3;OP1*eZ=uO0gI&nwFy2Y^xQ}5vC)~~cnYMcA*i$~Z!{>?h&|BPm`|8yMcTm4@J z!9zLZy!>|+Dfm6d$M;`Zh~LQUyVE6QWA9+}FZrNQN#ifmi%%5u`6N?h;01Rw?V1LD zp&kun;EH7cY+2zOzIil>-^7v#SY9`3*^iU6K0AQy5DfJDhok7<<`=70V1+;+X9CVF zCeLqO4pUk2?|xKZv!E@2Z1+zz5L6;m!Zr zM(TIsHvD5N&?g1nOt@hTW-ctp!Wb0ZiryH*T&b^~s<1#tRhd>8XSTw~GrRCmp^tQ& zn3qJIF!zUSeg;cx62de{l=?jA>Va;7WNjE7z#m92pJ*if%{pl(|2#@*LLm6>$=So=RgJ!*hKsgjW%7J!wRWVj9U;G#aUhX>qinngza( z`It(n<**)elv|~dO@susjCB}Gf+fs?Rcl_Ie0oK8Aw#dQVAF$6#Jn@~sWP|?z(Gy; z*u+9l%@8-NKncrEEgniw!$FzScYv_FJLjy6Va=w7B9kY%q}OE%ot`u5YOSxC zqJKLISNow4c6@&&2p1VVA|kY`HTQnaN=+v$v*U*$V{YoMXCxKTZd61&D*+yf0T@?? zwd#=eg~5uJmM#=_`dEv%+o5D-*VegftH+*0m=KxENY1)lzvJG~e2akZ@VNR$EIfMf z4Ea8*^ayJxGkS51?#R2yTUBV;hA-5p6!`e3>r0hYGLpX|14jmSCszyhgE=tB+x?%} zSWu|>#s)#?LK5}*(Q5(5DCO6=>z`iUC#CyN{M})+m8B47mbuN8ehmQXaT27Jn$xU95B z%m^B|g2&>HF35<6o2uTZy3DHW{3Mi&$%h4Ya6rGLzh8vMA_`=!1Y`^jP%kfcPB!-D zPJ4~Sw#54b+`E8(U>+(0u#np|Uwv1dF=2jYp z7dYRS3*-0-QJ`tQtl{I!BKYDA=*u_-=1UF#CkVcP8QE1P0GAo=L^vAm1Ubdw?_KGA z;2V_E?~Qu*{rV5dlm9!9eeZn9@0> zy(z#o#)r>NnnD>{?<1K(+mE)ddZ=ob8Wu4br^p)A8h?1^$3QRWzoOK1@5|xC@9ca3 zJ;U-JYHj{s&;HjRAn#=P4+GvzMGYB5x$h4=bGhp+ltU(@dp6nHCre)$1e zfA#!PXSSyB%O>k)5S@1+Zc+T0mmSy}5p)yh^)V)S?W2i|)IUe*sr7Gf)6+UX=0<|x z`;Z{1`0N2~v73fu3oLb+vl|eYH1f4L8*ql^uskAw&FlTkBAFp7o3J;n}}%ZXtt7wS_?aG zG?5nTb=8aNNn;{1(VRjg_4KuV#Z+2}5}T{vxz(#DJV|t_=B9sYPR>xAw^`L~wn4EP z#jimehU}}y+bkH3$2m9Uv0!Z6cwXhpEtort&7Zf)C8f!gD_bc0PeGfRUe1~hmJOlp zTm3rIJ<;u@d32sFoOgD-__0ZyNPZm*4l@)5A~m!0K9$rjg=qK4qs^SZ;Vg_OOw42A)Q?QSTP~ozP;8+6QS6YH6By5R z>aAa+cw+$Cat9V=cR{p}0XIokzAeeodYT}bk>_A41|Zxj2GZV|cv_b)uOr8`?cy%^ zEy8hZRI|_~rMI|X#Llzzkca7*!}!WBM@9wc@3O$Q^#Ht&qoJ+0{rXtL27v*1c3ZDA4LC8bdCSM zeg6I6=u~y{)Ko(Lv^I8Qu+eUlOiS{e1395jCCnt|#h8N=HAn-QLrSAAqI@*EOOs3` z>Ub`avg?Ha3(NZzPWCHYoL2}hP>jDoC{{>NNY)PcC;T8ZpB-Q6afcdxyyPFI-fW+V z&gad)#78|%T92m?ZFp>d@HUz{_Xk;UzCcwa+GYlv8?D&+M@;L>4smag?h>O~C++qf z6K>$|Dx+G*@Ah79cxI*3wA=`xyXcMtaS;@E(TWH`9xzr>#O#8 zqYv+apkD2NdZWf|M30~irbK|!UiB?&xyHC)#n{UB3D9@>+KiD7_tU?>B!Ncn$fXAO zNDhG{z1VrCJGuF#U*Gh_(^L1lwEoEoxNg5mz~t$fpSVRwZ+yXXc!>q&8LHoS4`}^N zNYA#rcYyWw)v>?Fa&X;};_x0M!QPpLM9@ySiHlHvS07^UxahM8ACE=y zoWQ3s_rp)tg|_M$WYL>xp%_@;)|3&4q2zj&zZa}lvZPbJh6Ce5Hh-;%>tpSVV7m{Y z(wY$bsENg?!P?r~YPHMG!9(KsDH0oCD}hiF_k!_}HWmVj1(8G{GF4+i)NjDaWF=Og zMCw}C0=3rh1PInDJ3imdV};i2G(zkf^!dxE#{Jx$n?-ISgpFO``b6(h=a$k0zL;$2 z1A!fGh!Skjm}e>7ynxtxautoVlzXK0DJIdXeGmg6nd0RHphZyeSUS5s5uOYoaa49} z_9=rm(Jw?l!3j&V0`T*W@>XIQ?IAiwx+5*sVUm)H27j@ysI=%@g!!`vL{>4AgqfVZftRLEg;l4ZrQ41Xk%L=aNQrwoIa zXLA=@z>;HEh{qz@RUQ9bI_TbzWjv=79*cEPD!zob{L2?U~LWv;VZ#&w2SBlW_1Y;5{{{6half1`kP$qi}MX z8NQ%$!wh(IjTZ9wXkPio7&vol8?rvU48u%^NC+X(J|ggDe8XLMRoc}&3xr)! zSjda@vNO805}NR`SZq5k@e*5hN{uSGV}17DAoVv%gvJ#5Fk1-&nnJEZ;F&(wz`@B` zGe~qY2@nfY6HNENHH$I44*cR$szZSHNc;F-TG-SPhDr5vNsS!0XhIE;YegxQZK4hqWjNH~52N2}Q$AQX| zWM7n|rZoo5_8fsZX39f|YlEy`!A(|XIu#)0q`{=89Tx#2uc`Cpgs00D!QZsACdbdo zPVesA-NWiu1~QbW9f+4CEmouhBM#R-H!|5I8af+x0E}?-<3cBh37ncC6L3G9QsqGs zQ1S{b(Qi;UWK&k=i!rbr?F!Ej#hce#`o^Uv?om6=k0i75r4((@H2RIV^?(Sv=nHYn zYUBmwQ_kXjp*dg#q#sk(MIABCuI@jqtb$ve9%*-vB1~(wjU_N_$xI$Z`V0>a165He zukD%E3{o&|8_e3WGy*b(3J^~?7C*h^Q}qu*AOajQ4%R0lOlT>{deSJQKEnp+M;*;` z#Tb!*KbDkUV1`jEWHTKJO5qIeH2B=PC*i8qv~`O^sd7JfRwU^2DB;_F!BEn^j* z(+{W=9NHcKfu-0iaMxRBl!!48|ElmbV_klFFx<0^<9*MN7|6TLx`8RDmbPN`7AMl= z6_QywA*>)s8&&C!&ub-XB7x;9-y?<;qr$Gdw8}iMlUdAAGBzAq-)}#qs={D9G^Uo~ z8&)|P$bdAe>h?r%e~%+nRAQ)dct4j@s)0AERkAF56XAAiRTtX`jpsA9686 z$@Bv4)F_?1m)ZN|DQQoPf?j9vv6OD*QUF6tz=GT(TG;UJ*yZE>oyG=B;Rqr{!Myli2x+agJ$rT0?Yo2R{c+#r)+40c(gh|p2pP)X#D9OU=8 zqS6SXWcC7b1?;ldl^Mc_9_yy9s&AUh3-~eFf@(**2S|vu=KN|93_2Tq!Z#P0ANBm^=3PwR4AZxNRxa zQ8Opu{ta7asFp0k4i;8tQoJ-Z9n>Fe>PW|(0TWc!00jwvESAyIMIRauf~Vke2Zyz# zgs6w70Lv*g!x>M|<5%$mvSv9QKI*{5 z#4*M=0jy$VUaA`OK9^X7EE2Q<71=?(jH~HyMSv`VYggX@A9yEMa4A=GE7w3Iw?zF< zrn|J|QI)M4Bx^$8vym~C9Rou(d7|=H?K>OgO`#M9Frfh(618V>cCs)x6s)HTE_sXW z941t5G4ISee`zGk(>A*XXSe0Z94npy(kWI3+niqKvpxfB5j9;sDbT_S^6S(BtvAx8 z=-mFG%4C$#TmhPmbfyJQi8gEY@?`xQ*u{nFJO+k8mhj@FAWZDtA$u_{-{WMU;zi6E zgJ%iroC zsM%t>+sDHffz2W)=8@BQkokDg!E$dry#38!2hv3kS1Yiiiu?mrWfI#H357C8S|DdA zy6I4mzlx40y0N+2h3GvzRbb8bQ{XB$p>NyK z4G!#8v$CV%Jn1eS#m1qXh*HBXEAHoNPz_b`xN$H`pwe@Ncn9oJ@-iBIQ(PKH9IG)z zBI-MGv|nY~6ZWbm1$Taom!s;eDA1@GL?>Q5)O_F`-?149sfX2OQ0J6GR zPz4P@Si@seUjE0PIX^=O8J5uEk3sd@79c1C@#cVO+piZz^h$RryhWzPTdUh1@M_08 zSXGF1k$l;trgDHVPeMltra98Xm|j+Y^vC9W?B%jD^$re_HT7q7$Af+3_`r}ukMewx5{7PE@7=xi0{G7TB#rZJ|V#C%EQ{?}Xe zLEGamaG2C>>K5E zVZG&shWy z#;OrSgmL{!?`XWluOs#OBeAbOI3b+Dko{v4*FiDeFmm4T$$6H^Z(}xttC8<3w4>L) z55kiAJK{)2d*rKxN2ZwtEHYh+34{VJ9KrXax846*3g+E~UZ{V^t@nRFZvWxJS*S2A zgUE;UY24gUs5k@hqM;&a_ESx$uP|sOMqJXIW{}vGZr;dL#l>XgYGGutd)PlqlySD* zCnOY=M!Un*vz=0^GKO^1@OZMzZTora`RMXk=b8tgC43Nzz9qKT5{1Qq)|Mi?6)Frp z0d4MC>-3BXq+ZpWzF=vwNjIgWReqt}TLLJ(<1n^ZRsrTYjng*&dLVTUo5gU1Cr%#d z90yi_FT1=EReD^RZ~dr!;}mRNuB+Nm)LDbIY1pWJe7(>5k2BS3aQ(PZP(^;hQ59iS zss1IK&*;8xdoG|#(|BPql6^=N`74FrK|^5G2v^Xk{%Og51g7$bRwwE?Q5PMH2yy_qUN5PTsAzOE}Rr;>8He^Ja?&>2XS>xr-m z`xYRj&}F_{iTJPro)9-mTYeKm$}~Z^905`he7e=2_!^ ztBhj66F(aN_`@VDjhjI{Vye=!%DPKfgb;b>w>36i2p>m z8fzih=N7P@gx)CVjhQ8HhnjP58Ad0DV%e#z+CG|XT6`K|Fbilpyi|{+xad+4f7(pR zHC6SUZ^W{`3*hs@0pe7Du3$Yv^0e$|Tz5xfYg??n@TywRy$- zq{$*zEoQdKn=x_CMF(BtkhmtUDh?MldYDt=3k%%oor7ZV_w&d!n1%FTXe3Zrn4KiF z2Q1#oaU68a>=AE+H?qCNm7n61+=B5Rum_xDsLZ^Sa}(NkcAbNAhf+{Kr1a-=-iPjv zf0(=Heb2*8N4j-I0D%e&86Ieezl_=KWIl_U=CkjGYLA`FZqujVC#f*PlM=!Si!ef7 zw$%!%+WR@?_$%1dJcyg87EK(m>7>4I22Bb|;LkK?(;Daov5MKw25rT=4{TdZW#zJP zePiqV#s;ksr=jc9vlNCgTw|Lnt#a^a!clC#iI^vS3L0W1aGfK+a7rbPpI1>f9g^8( z;gpE1nNl*O^9ghrL}w<1kw3QIQxmtbkPtLMux|5i=$xtS#Ky7gs;7@T6wyq)A3 zyX9z4kCEWWzp9OHJ>ohGHA-pbS*;Ive_yp}KDi3&i=559*-Jx%Xz`?HT1n)3r0u_9L{n zSX@09ANhb@z{Ff)Eol!~L2e&?HG|Mhn9MXzjm|LWK5~vM{a|??eO|Vq?|^ahMv^Q| zUz#DHPs%hF8RWSlE4dX@GX551GLIBF9K zl}U=(SgBO==s@z!jv8}0l3$ZttW}CFYd%~^i()=2+2PjQ^VX718?bfehnV#<)7mzb zl2;4TC#>H6Ob4451CbCxw0nQ0cr0jv{A1wodLCaZ#fUNgx)Cd4REIcqTn9Dmrcz~_ zpO)oOXOX0zyflgg@v1qb!!m$4n*hBMvr}ZR&4|(5Wa>D3Xc;kh1Lh<0khkcEP(j-26cafk&?XMhs~ zJwwUhL5fHVi@lWb$8w@hxu^F16t@aA?M*1~oA`>WYx-xlFBT+)k>Bc9X*0bi!4Qpq+3rdT+hQ{vTOJ&H8G0PIt-YjQ9x4)jErQO05l9+?!M~z z`^h>RvcwCHuZfQ0(eV*a8!@B=3fQp`$vfA4Up=voryy<`>Bm~8W7t9hceiLA&yG1N zOSv8iZ$t~@Dh|L0XV=TNb8@@_=L-ZTVTX~A$SA8gIM(ph26 zTxM}1aQcuVxkT2lZFvZUF}HFX{hf7zttRyh)|R1)8;^+S8&_D`Z`84qz8JM=f}&Rkd$iX*LpG%KFHiZZwb>bmQM3t{J>J z!kxlxMt2I{Jb|gzR64fY0Gf339%CG1xZ^!sbYIY&3IQ5E&TzMY2#eL^w2Cw>+* z2-clWvo@(%?{5;G)PoIlnTqp>>4{jJt6!SbXc2rHU-Md%L6W34Yf9*75l7IlEBh!$ZLYy=(huzo0lc8f1*Jf9~fXW1--RF6L!h zQWdX=m{V?zUs&-d9+#H=91)4CBRdbX&}PDE#*$Q#W&V9Uov?a{!TlX*^|(*}`Q5bb z)-{`1T0w7lcz4J2+au@o{l@e%+t>SaX8Vt)U7N|99@AboLS#t_S|wMy{5|vn#R5ID z5z-W;*@)C1S&57k!5LvcEP>sCQ2}1k!k|;)`-Rv%wFX&kG=FR-JZSvnq&aOqyvaoI zMBP@n1<+RfbM*}P;_D|t-gYD4_D+DjPDjA>TI-jL)KQ_2>Wv@mLtd?u)K4M5oK5!9 z8iwh1`q^|#XeDkE@ zK>)Wz&{0ZigCj>a6VOqa%-e8QsEiQ(s-a-^;{LoaVU}1iSr((-KxS~EcxcWiKS@d-zkEIEC{=X2H6^b7!$d(Hye=E65?|d(S z#EPOGX`7!`TvErYvDz(z-YUtB$#UQTAVtQ$K?5stTF3mX#(;?4XnfDC!~D>Ih~28H znJk7^Q%aArA+=@7R2j>*UV|X4pW=>=GB20`zak$AJ4vC}AlIc~>4Bo2-NlPl9(&D0Ua0`%nLE>U_DW<30`!Pq!|!{`XyJ!=bWL;g}3 zw7px_6NQ{9qCiGaS{zh2+7aO!+uA~Bk&Z60)1}@Ib3CcY52CmnKqQbiKjmnQ4qFNd za$4`kU_qDxZFZ0=4Q;b)1%17fwxFLY-f3@%qh6mm9^0&0WmavocM!WJQt;ry^p4{* zb0eaua6=i3%DUKJ5v)vG&+4`t1G4McsP}K zZ(q$doUz&hF#oa;0Z($&TsZ0?yFmMUxcmaA_Rj1H+}A6c-KHSTXYmH_%A7TD9wV#A z4jOmY;7AD2$Rj%l56e2jr8`J(lw;+GjnqEQ%NILLy+kv0U8qW<-WBoT>eX1@1$%MX zYeLfsk@8c94VW_j_fZSA)7lIUh262%LVYDah~q^tQA)rU`((WDN=CQ`2PHm9tE&3_JHW`9Sll zn@C*t@0cFzb8D|aDDeK2;q-;yV(nY(?;sFe?u3Q!iYy}bgqrq%IhQ-G63TZd>Lanb!$WwW3&(4y~PdTdkUgiwi&kf=mkqmQVGG<5LCC3Z2v5Lk|kdK6-Y zZ=DYRf-ldAPPLten}0@WU1UPWoJcCd`I2t%vw&sx z16=LY3Ia}vlVoilT_w>l<0#0O9E5vXL#QG&jlM|BF^cmRX@6}NQXg!!>XH{t;VZWm6^=I#5Hrnl-mf(|R6gx$6E_JA3LHX8{3m;q*3ju~Qj5C9(7EK&K_kZNSum+LxbY#{qEUiT417gj{q z7)Q{?wc*VV$%0@}Sl9GFh)~1FT+|Pm4)LnizkN8<;_uoU)*~z4>FE-u3!alm%;}dE*hw zNS6b4zW&=`l9qF%{37X!lFNC2S34=MHWTF0(JO?W+EIDq#*xEOIJXkm%zgWl@1zEF zx!UtP{do%I(OtEaM9;>06)6FzbKxN~5GA3)CSC|d0`g4qvuE>6S<=R9l2+MF_R=a$|InF*<2)^3%bD9J|9JcEchb*d?lf$q@4VQ<}wsq4P5aM+~ zocLkgI^~TDZGxneJGl5#DG)@T*pgmWIsJz+)B0eA1W<)~qw=Pmj;rO^R(B~nNR?iS zR}}$9mY*xbmsE}()-*10DX%?IMQF<&e2hf0J{(6ta|7qYT>}s8GHf%>wk}VLjUDlTJ32_R9 zUk<+etU38e?EHz$dFS~_bWI5(e-hnuB0Knhs^MiGf4X;%Ssh_*+V z9qb0h6tUiM4nwyoL_cE^V>BWEig@5tIGGhs3~UP#Tj+np)n+t(<`S~rhOm@BEE_%3 zFCewx;fA)amtF9DZ~N=cf6o?^YpH2Pcw#MS4Xq~X7gm;){4rv5e)JQqa9gpNL@w_a z(v=;@$KQBg;VVs#uHVt$@?Bp4huX#eCvipgFRrztnSqeCiJ7(0KU?kJv}>7pT_o>s zF@ef*Yd?TGU)&sP|5-Y`AYDe|Uc*trcy#NQvo;ouT8qZZ5T5?*Z`Kz8VrVa$Dk9Gju-0qV^ZtWd)_sOccDL6Hm@YJ?S%Iwtj1ab20{x6!QolU}1-&w3g*h#g zBKWKCA@yp~CL(s9rNcIRjpXuOn{lTJFx9R8FHQ|h__=(}zKyM|mw^MVVTU}AfXKa; zg4^t5XYkOrZM`_l`P+f1oNbg|h4Rb0DZ}|@DwkyltX3Rr;=C+GgovQy(3~rEJyO@! zMNi{qDV~$GC5O`0h7a%UXZ|Q`z2O1Ca*7G(ZO^%TE_k-5re&kPLMAo8$ut~6C~eDx zvjFhn^N?g9^P5zP4(kPil0gI-hTWVG1+H9KOGfei%2Y4{3SCttSIZ_0(L(oeT%ab` zlM($QZ`6A7*p*PLY3Glb+qP)Ig?kQ+C8!14bzYDlUk6s3U*S%G3@J=8BECNW?^SmoAy1S{Tqw`E(pQU*wIsa6>aAi&K+q{$#D= zd+!##Nf(Nep}Da{givf%(B=k6REf$-*cR{_*MJ~)leEXb0al1HmVdt$l5`+Ey@xyE zHle^`ICRN*r$d0)djEOL6qLd(hRHv&YBX`nHtG%CyR^@gBT-`@IisVxEi*bRV&5_^ z5TK$NUs0b%(;;k6?=~VT-lZ(+XwH!+lM=3k5|vxJf{v}Ep4bmR4Zs+rn9AvkWR{YL zWK0?GwJfXwAi?%2~nAn2C);`7%Sbt;}=~VXKj$=ThPj=M{y)jUz%4Ju55dZqO zfrJmTIF8HjITWRD_VxcHsQ=N2&y?5ts}IjLUwyfxL|&r!{=+DC36w=Q0WDNa0o9BY zGB*I!EiF@eZE!QycwPUUZ^(p+ZV1i$H_w=>$sl7FS$byYvE)%kCyB$i6O%KDE;KAf zOB5#aSV}YaB2=fDUT=)Xk;8f)He@(dy7jXaZNJ#K0%gnP_~V7n|mBYD>6ELGtZ286v1vecPVb>je@jv7eXzLP+2#{FyLl=jp1&V z|C_3_Y%dWgwK5iG4|%^|tZR`L&CF&fnxzo^)gQAm1yCo43SZ>{-o7Bj={eBJD0Z!BAU$y z)R|9ert>xI@6az&3^3jW{En>FHQA=fj(s!SdPhZq@W7Q_88rl)? z5rJo-oaaQU4RhkmP3wn3PmbX7h$i2~s^qLPHbrqzge(BQnkzGmoK>Lu`Wvs0=l4bD z{5LQ#`Sur={I7t6za5YN_SMRW-^tAKe~VaDs%p3>2qAqIN!iam*itmWC4{4hyAvdY zi$VgMg;O;Eui2YH?uogJN{dPJ+XD#6!4E>_{r+V!>R(N{Wk^M}RCRc_+UWVX=!5U= zU?0aE>NGn(o{`E`|Lo~;ZDVY5H_X%h36cXG?Md4I+Yb-8xRZ5XfNkoPHUb`(S+~J2 z{un9aITs*5(G?Vx{Fa(=HQ->bCLvNupXWhba{!nue1!O{Ul|Qz$g5fBiMYBB*m>~%s66Q=fop&$?o{otkehxSRaWxR&+h*ph zG%6)YTB7lazwx6pxi4229~sU39El7Rr7Di2iPc88a^$i{A> zsnLO_8AnEa8EMAcsJcFe7&wWe9L6MKB_->dS>XD|%06jM!+6!Q+{X>@=_m>Qik=`S zRpde!&r$_c$?%JY66p%k8Gn6VP-Z@nLsQXAvBhYH&M<(45D@rAK2dxd8!?>y#MonRR7n!X0*#3yLj{cy z$atKbZzm)WrEFHy$f%RUPoi|GkF(HsK8N{cG&5;^752HRP(rGWG|+MrftWQ3@sryC zJp_}n5JB)cPsAxuJvURhE`3i@8*1#kuy10kCv z2FRNTN_N6}Vhi;`{mKD6P!iyOin1a?RF%5IUXS-WA!F8tN~VC@G$%#T4?u9s-r{k~ z+X@U0x0LQ7dY0+4SeL#)WtX1}ph9#h+QBs|c?NQjcMVDTHaZZ>53;mS{Hl_`d{*2T zZmxUg9TaectjLh#z`s{rcPQn0;! zV6`__P89!BPg+*^_6R=C%-PpI*8m#m5&wce%;-AXj z;#njKz3Qf=12>gN-hJ*<$MKA#O%Yoz$#Hzm=6sV|;|mL6tf-y)ZhiEvyf6FWxZ?MN z=rI8+Aebx19mA-9o)ovro{2N~4b?SE;N7&Gv1XF`Oqk$+%8DWfeDaA?0!+i~g0kD_ z{neD;%=8_7%LFTN9VO`=@}W1-8-UeXqinQ_cm<{B$|(10;u@c*(e0*vWj&G$=ApRZ zB#BxwU;l;8(+irXmm^Tz_w$IX1Yp~MfMXoUTg0ureDvBpW8jXgg0w%Cgyu5|hJ{Vq z7;kCiXsRz9enBr`J+mLvQ%0-=8b~|X2_cURdI&wFC=wiI;J(_vE z&Zyh#aXhMwL}sM{%5SvXlnas>e?kyj^yApQ{#id?D3j{7sb+>RKve;i=QwUI!X>;X z&V}dh)W*9@Y?##7;D7faImvvlz=wU0TKx^}~;C5fjxL zx=Mxy%(6wA3pWE`mfhkHM%D>vER7b^ZVLL`hSo-{+g_C;Ip;0G&9h1G_$e(2VY!S zLjg+(?i0oX-5DKhmspIp3f>9*mjNRNV7eeLZ8#*}Pr1Fw)!iBnS`XvQOtDzpooL!? zWzQQF=z)^LE+K?F<5rVvWo83Zlnd)fVs~!o)BD+J9wK?F);npQ2g2rn&imE4oto{5`M**r0(OJfNyDFrpz&ff6fIs zuHFtJoT8%Vkx8g-nSs!#$;@tE)vElS^SX!HrDGqgp%H+Y_r@FFWFxSMFQnQ}3F5M8 zN!n0}(4(;#H3w0`CPT3rXXG4*hl+0(R>BPrIV+p=YHdmVx^$2GZjgMzma|>S;Y#YXpc6FYYmk?32l?%!o;pAB>fE1d#;fu8M=PcO+!;!y< z3j@|vyr~OQp)J@`>KLMJ$@eIC#SVu6^FV2XK$IY7y(6crAXP04Hm)WYxmP1P(sT|? ze;}8>kZJ}d!aw7P*Wln^(=Z>7&z;SSL(38%X)oa@=P9OeJ_~NDR5lOmkV`wVK*Fc= zD>S%&!G6x`w`0>v_fkK;L)W1|_*CFX6;kWl;)ojeF#%C}pzlu~^#H36j-CkB64qyJwRm@p=;C5O zQ6TbTX3NlRiFSrQDdLJW&6)22p^&nund<=|L7RN&Z}16lzWybM@~d?7yQvs>G(A%@ zgVN@EldS&5j=#3Up10P+j#SCe5jA9SfZ3N%7@ZS)-~qPpHOHOPu?*HP@{oH^ci1k? zeJ&a|d~5Idkc$h}K(AdYRsik$O-%-n%7Zre?bx`tDAv5zvHilTAmM4mYJou~PhPYvdT|XCTFl0k8L(hDN zI1O#9kCcqOU&S;FXM=ZK_J!9K@J-(Tpj5O4)3np?WUW%yFv88LMcJO6wfW3))N|dTUN+MpugF%^V$~VFDP+B&`3%**is79%XC1728fKwrwXB+qP}nwr$(CE2?D2wv&pDn{&F) zxVL-Um+mq4+kX46-;k&WX1+q_nED&-m-{ zOa8k8rS|DdLCJ*>820=r%{i(uG?VuM*-@jP|8@tXqC}I9{?3|*zBRPG|9$ZLXROnJ z^3FQ+_~K8j{tGBW7*p6IfldP0iERKDgi5paYe*SSoJ$B2kSa)S zO2z?BCI%gpfgBct1%mET?^t)Ku0CJUZ?3mJKM`GSx$w}PXtbWR$Togk_5FLyw{`sS zKAQJ*0aov?>#&c1oe;{+@?HeQ{V6dt27Y@Tf%6qDxB1NKOFV6c&6jrQmvGbuPB%nO z_fGvOGt_tfoI~>&7C`wt7()Bp!}3Kr?T7V@>=!@hXZ2hZ`^Crrqt|{z{;n~kMmI%> z$q_g%fVR4!9xKQCJ@7f3oLUyk0=zZ8Hes?~o2l4A%1F`)^G^1~(K%8Y1`|YPah#YM zkj^55u_F|8!6ICiF+b~=kYcby;|w^Ski$~phD2++WV29vw* zoLL3nNez|UP?j{oPB~X5ETeMCr1wb?90LHyRKl@D1=5wFz>fM9ioaLLk~_%3uySd< zDis^p$}LWq>7(@`VzV~3ii|qSspkv% zN=33}*eAtJu$G0U26ZCvwSOv9%9>m(0TXoq$()(FQ|%npQ|DY+pd8inX3?TM)=q&U z7${RC62prM07f_S}^WgaHHQX3=-lchCiVceq$gq|Lwq?DA zHPycAn{H2j14%&VPN=>L6B4u zjf%@JFVDgRj;9<|F%=HXQ%Ufy>dMF5%2fTh7DuN#m86gXJ5uzLQpd*MdEp^MI;>GI zVLS1pLW`eD=6j4FX9+B%gHRCtc*NliOJp(mv~a_a*|>9%=UD<(tKhi6CvvTCHByPH z#X$|KX~s}G%ec{Sc8kff%>%FHMu~>G^23%AkIh;QDFcrTb?%vkxC7T61M1>Ory^yA zYZB?-#R49ePzJB*OrLli47i5%=SB@%X>~?ICFU*HlKnJDP3@Q!!m{H*q582JT_h5S zYT7N?>)3UPt~xHn-#aR@CD&wyjqFCvmMdHq4+n{4W#p!@u|m89bEzMqU3rmCbkHI~ z5hKAaq!7sFNl%-LCC7~uD4T?>ixqDOb&irm3kvwBhJTGm%Ro7gt##2OL5HZokXmJg z0_RR5#muTN-seyw$<>|0AQ{Ijt+ji3?))3#rhAYbtcoTeAxIl06^Zq7Z;ZXjkl256 zac=Zfq@*m7%?N3FjutKL&P^ma#Tp<-bN9JTff-g$GdT^YpWAcEfDDUm(`LVUG`UW_ zNN7_CDTL?@t>7H?|3Xpz`=#e`dT_`UYt z(2*(Gd<(^gsf`xH7;jW`md!|=3p?pabNF$UAg|6~sXA>28am6NG7A$r@1S!APltC* z&uNEQGbzbZ(P=I?LGqJC2de|ClT_(v4ht5n#$kfK#iX)$(qN(r@o9m(PN!sJTC!tK zG5r~2nH{(8j@9_JSW*MLm4-Oog+-B%cdxlvQNG0G!hqUhK_|_FQ{uRY62}Eu8^-n`-J+BN60> zEUxOU1zaT#ZF}?wlKqH6aTbLITQn#x1OGj;+JP3b+Mx$B+x{4AQfbL~a1^zDt>7Y2 z@n({tUDev<_WN-mey?GKD6YT+gLfpp>N(LQwq4=D1*9)6tR|9Z={4yz=nq;l9nmza zd)C%Rpc2)jURkyDd$`W|;;NG@vfh|14jcT+C>8hawPL$DRx%yxqmI~G_EYdq1(s)< zB-=vxq+VfmlvLczWGYZQ##7bK>0v4N&EoY_c$Ehjwv5e+IUTZ187-O4uq)P6zSmlb zC#8jAh?V$z0^VU4t7D#|*0~|3%^44IM}>%;^+_OkO^rc-BFc7u3O29dd{J{yo@LP$ zCtp9`Ehg{G@oZdL);%b1#heluugt7{e{^ux_*aHHGG3zv};Ht!QJ3<*k0V=pQ3OqvW~d!No#S=yFb7i+cW$mpLh?iYqaG}qp@5;wSsdX*_^GQ`Ff6!4NOxt{a{ z4M_qsYP*#E5mRMbpMR^o9BEK`)a3<_l2^JRtSS-u{c@=o+rLQk@lKLVj`q2daekC{ zSfO}y2_?cQJYS`eNop9<^UZBSpp)YLQx;oJcsIj)M-U$4S1{JZ4~eHRztWDt+V*$f zYnO0;e1M2u()8k@8ZK-MY{lM0p18%+X(BzGOP>wB+<*G7q~?vgs(;N?Mk)f=0SP0E zsp4k;7eK5<_&$63vy>?#+d;ls&Y9DcSt-7SXj|)0SjlJd_PbkF_^8Nssdj}B$tKda z+0aFsA=W!(oG#~;f!9lWA`>cOlI^U}@M~1QuqbPU%CwL2`zQM%(gd#^X$x$Ltw+UXu_>#XUR0-=OSj zzxawXQ)ySBv)LY3J$wx9bUCgzW?W3ZhNW5c36&qYR@m@jhtaKQZINFRm{BtC5Q78-PyDtGl-#{FP()+o*!UH-$RUH5@~NAzlw;UHuh zaeFrdA3hWqo*oAjN%8W@potZ30FQ$DZ|aLz7KZzdg7t zt;W>w)$RvST`wYYit>S2qJX;7g_;STH0cs6xV#x3ETD)ny*@!Z3lXbtMS8BE`&adD6j{U z1vC}qfSjhQeA0hgKMmbEYP{G=Q&+9ToSKMTi5h6U{4l*3ojPWM<_moRrVS2~4b74j zgnE&eU@b2d;mD93I&J2Vsw8zsO-q zC!B(I@BT>ta2z3k$0#{Y`TKJaDL{v#5nrsO9w-*?7t+A-HP^gphoG6`vRG&cI~;kYdgT~rx-uPeY&7_T8v-F}cU&+JzEkJI5wnLb4+YKzggP0s8s-~8 z8Mz4PbF*;8!+KKlEJmB1sGkadflK;jiIxK7{J_}dj0J*w2sgse-Kj^N z#b=v+YRH;RzV)7EkD)UMp)EnGt2DIFC=DYDAs+33}m);&U8!>3}zQ@zC`#UL+Ur zTJKJs`P;*>bC&ZbxfF-&@Te_3N*-Ic<|K?sV|Ow{E*C3czKK~e%z{Tso5M!c{dV>C`KCCJ2Nt#3IqlP;cE^}q z%P}>zWUrZvAq^07WXde8#*wniL1=?kgqpiN`B42R0gsTCW5`db+ilg|7i)Ckr!x!2 zO5`Znx`Aa7rA8afrHd3eV=?ue1CdAG<&n$%zEB=r5f3Y}k}gZ4#FhYS)&W(zGi%wfmAoW-L8$E! zjU!ByEA`qbn|0xOL(bJOw=+9mk=Hfprqqiw{>zCkR}5Yu_?oC0fVerr^^eXyCa-uL z0OkD-m{;QC9<@hA4$yT^^AYGe|M!IRm6umeZw`hH=V3L{{101PsP*D~;U&wOl~swF z4bW?@4N8w`SFCQ^rZ|87rYL`_O-bGgUOv0c>>sUGwNsn*xD(v<_50jQ-q(yv;n$8! zlvja~PkPR_W+TeuR4>k2_5xu3orF<0cT1VF_xntkzxueo6kYcOrTb#fjIf@8O%piX zN2Oi%B%hKqt86ziUnqE3tv~eQ>~2Xb*L6jDe<$0nv;C~QEh6MJ<~Y8lNO3Yw{>@o> zspcN#RDc_{zBRJWHP}iXWFrT@o(tQWXIlS}%qsAV^~sf^RMnowF_o0fl_;LmD2`7ll34*_5dg_Q zwtes;mks^7DZ3wk$Ij`896rpui0qEu&d|^P5V;tjPbGMLT)#2A0{>HBO4jzepMxVn za`pI^vqipX@2;=~%>gOId=tLkqWhGos0(CYM+pyZWb4I3C?YnpV%rovr~7bQFRiE zxc#g-P6lIYA?Gs%^NUovv4Ss?oAckUck+`(d#26W`F4VdCnbr_&2pzZoccf()Uv{?spG`z<*NaP&9F}b8$2> z5w^2+ayGDa{--D*M`1z|L=e#@$7RaUZ~=2aPz=s|Uxo!T@fXq_Xd@HP9is`O)EyzJXpl zAy#15aResZvELy`v0+bhAx>q^M8Vs_e6m%^4(*B=*#luC^pKRhrcy7324h&P1IG&H z1>4bjsJTnZ%Eg1tY|OQ#%%uF9O1(HfS5gPHnc4t6i}jU&-VsUlCG^sHPYh%e@&}yR~o*~mjm4AQHRiz^Z{T~bZgzp9W zk4&EarwadnZ7wMa9kN5;iqi9R4yavd_GVOnP$_I|L>W-zM9yFG(47yaCiE*^J3)Pd z??2$skiqc##Rx~qkdcu^B6B?Mj;0$jXX~C2e&|%U$qyBVks?gusbZdmt)0Aw7qoV; z>f3v{mSdWF+bnn_fKv6K3rb6mwJ8pRB%CqfCD`JfMi=;4MoW4%j5CQCEcYb=wONdh z^u#CZ&67p2V!ae3@}QH^{3>^kzpf%u#n}V_moqaXN<$iYE%l?u6CvI25ij<6kf$JC z99V)I3TAr}nW?|c3olq~6QNp+pkudAMRAp)^*LJ^Uol+^HlvEW_%o!5gHq0X&4^)| z{(9#&9|}WH}bT8>MiS(iUP#lfeuZ4PVq9~ zOhs}`q)rWc-@jEAxxHjR*}hjU>HArQ?LXRX|GRn&|0pM>{L>s#2_4T_mh=nmgGXGR~JJck&W|HPgY(MUHWLR}Sh`(R2Fya$zKPLp-XdpOjdMnOp zc7ywAs%=}Z7kFhb3Dvp!TbRDdaz$9S@%Gy@epL9t1=ShVB^mP`2myFkA~a=yzb`>!v+a$9C!vdLv z;iwARm}c~0N^WXtcuMOp<)D+>NfdH)SU8NV&xMsGQq(9 zpFQ%L^zjqrBS~dMck21pq199`RE_F1Ie}>W2DR$%kdIgmO6K@P&ne#~U>Dqq7Z8mH zILfjj?UMMU_PA2SgW!ZlXOM++l4jw{>@)H1UkCs>!&fxMO{_vO>ED{)R%)KvBpuid zo6(hayKEn<)e6(Bjg6>)qD+#4<@yBikAEZ4#)UHp@J%BBxA5XWk0bx=Eb39Q`bTGx z+az?$Eh3tT$O(wL0BS;*Bp9F=n;3j=?9z3<*48a+lNJbR9|GkomJA(P=6^Jj`#tO6 z640V74HCz>+)l?c9d5HTzF#+oIQ|wqkwp+mJmpX%j3efmVHp&*WQYUSux%?_u?Df1 zIhaSQTZaaOks}nTphPV>g$5Ep3W$)It#t8Jy$h`@iwo6}omZTpDjJC4b{0qBThJ<) zO65mbts<1@aFf@mMe67$o|-MR8c9{_E@8khsiZmG6&%QUo&As;Xw zamo|1xmD z5X6G&5yn(#EL!cgM=L~j*){6g-4t+af(Q#<{&TVxds%-1PPaZ-e|LC_l*vTAylYsg`FLrp{vk4Sr@*x)nF z?o+HRND;XjB@mJc8h`xcjDEwFF${Ec5Ch?m+tLs2gL#ko0R7h|@Smdd-Ob;pE#r45 z-haMJnEx5B760Mipw#7%h^+`r8*(dy00)^09U&!8uaH31;g7ul()NAi+Bqx@=-wG| z5Ym0yHVE18KwK9g8nLS&1q#Dgu`|CtUaqgcyqv!w{NP?W8yq`Cv)+;PuL5EN(I#%r z#kSg@8(7EtiS(|(Hy5mHTyEcVckirTe67AvxH7TT>^RNh38A zkaH3fDWXdvG*c|uBLCu?xt{1ym@-~0LzQ8UBsXG^a#}McETsP% z@vzJW^qdglj%zq~yA&c!CwX^d_Ule;?N`?_4^KvGgjCk<>KL&*kHNbbkF8X9dsTJ|Cvx;~yZ2w}6RD<6 z3rOu^gcHY~xrp_FPSL@4C({UVm{uKIA9m_Q-C{d_fq#8IrH+hRZ2x= zKR)m{<$jc9hu0V9`w3hhAO^&vZI>Tu;?@@yWF_bP2}q{l>8`~1}1G`jXQrNBaem7HWB;YeNO#w(j2z#0JX=2Di)#pKE; z&mi_LX`1#VA9s+r6nzN}CrpSU<=H2Ui>KeLoMdkmpql&+j59`!E7~MV$&n#Cs`Ad# zN?@S*ny5~@wkF_5SA^+kdCe`Tyf4l_Tl;b{G>4T=fO?VS3=NpPUU^Yepl=bEku9;6 z%1SSY>mt0`^@YTLKm_6<#M>bhW+C8$+Ed)j^7si#!L0hUc72^+ns7Y_Nm0cBC8oERC`ddI2vyptnip|yP0=KxtZpO z#AwNt5+0c_4mCEm|w9_zhWe|~bmfLE+6wE-kb(`R$O_@HH`S;|G=7Ur8P zZOo042@B&dzq=eKx-y>jvJ{)9pS8-7jn#{n&Y)Mb*?^0ef}u$*u)l=AdTiHbxO@8Z zK+q5y?5m8qJJjx#GC;fT_);k#ePxvNs>e!|Teu zpJ8^4pz8T#-f{ZHtJ?dY(5h2D&)dh#M(i$-Q#CB>r>S_Yq5;D(BFF#MPbJ}W{`}uV z!uJ0MNpUmi?<&MsaWGk+hkry7U`u&~o*MR|m6|4a-+MI&LEr$-|2;{X$W)Nxh2bA( zre?hFrj7jGdEVglA#mVnHtZEfDx=73*V;|?TVuF^Vak|nD}EgpI|YdTRnL{=PxU-` zx?`Ci;thB)ONhP20G`bH(zU~dR^K!h0FLSr2V@zd%D?J@Gh(&>ns}BkO_(DWrA8={ z?)R!;E!k8z#cJpTa4Q7l6$|UdmOxLaO_(R2M9%_MFqsgB9Y7F&<%d_aOb%aAtfzHA zgMAw*F2R|sl_7vxh2&`lujt1OT$=i0^prxH!m-j0G&V}`F$$+%vB6XmhZeA^ZSPQ zpJyEG{|CvWEeazluWV@s32r7L0cz&0G$F#81SLWN5eW-0r6P2>V9NN6L-ka01o>3a zHUJlgOFt$aRvyLti=OQHK zHOa8Jd1490jy-p(_m>1e*Sx1fsBMsgIc~Av{O4>KsM}KviT*G%MyB#=mu7aN5W8XIGGfZ`8LZKWkBoUk0p6*6ZAVPRcVH z#{m{II8L<3#HhK+)phgcDu{8Nf1hUd(j6x0ffUIXV4{Sd_%er`f>S` zwJ?kBu!PTRgqK+;j~9f2WNovk_Fs1v+POPv@&)F9x_Sfel2cCdI0a2BW=$5DJGTE7 z!`U54Mb>W&G5#wIIsO&H8EIw-E*3%o3YJ4Gg8#vgKpqJ>vK*BXY#|@o zep3pSFZ#1ZSIj9@hIr+zn`H0Q=vOB1iMQT`GSI*`Zk~H*9vv2=<*0CZElR9bZ#iWY4=*Tbw| zRD|r73j^6@qfo3p?uy95wKyaN@maa1>5JsmXWEu$70AAb3Uyjcu0a7IoiX6H>*EGn zp)pEq==FvMi=jcKGKCur4Y6IYGtsz=sIkwyfB#AfX8)Z9Z~O=xYk)-)J{s4_fC{>D z@m62u6un)Gmc+fy*J7)D?MRMQ3((D^Uu1A4_ppHmyW}h2W~jQ zL_xD#^faH38~0ECRVn{R63AGB=9QHa8)h6}8_*qwier5DFv_Ri(;cV$!5kfzGz?%V zqeQxZh&)(dE1PHL5KHA6c%hu42A=Cr83mFKZKGJ`4`7W3nJT#kvxrputDo`L02}{v z;FEKJ?xP~l(8>-8!+g7cMB)?qx3SFYQks2gH&Km7(iChZ3BaZO$7@bNURA9cpc~dLoJmcn!HA8Sy0mcv;*HX~LE)U$W{KcCC!GRLHfx0+XHK{uGSp;$}{(SX1ljD0qY-#}8 z6()t@mVW>`^odV{+UJZ`)Hb!3=^ab>m&2SOP4n@aKw7 zP6hbp`2j_fTj0SoRtndiU|Y5gNuSh%G1`gTT`{(ZY~;Bj#mPP6X28-38|{2J4B=3H z@7yWFf~?AhCH$WPj06bE2eDYT|p_Bw)L<-m|KP=4^Ym-7|Q4IRlQ~gIBq5oTX|EGfL|0?h~N>WNFf{34FtJ6a2ph(wPc?yY$EB*u}1S-nV zRppU#sCqXH8PcRtSvo6Wef|8q3iP^;H-&oxjXa+B5EZ;uzoh1B{` zbfdW7PPdQX!f1$D^T?ZK$j3zr0)Up z5|M-sYBn?6$Gs@pe8=-AjE*<1eCs)w1uadgE=zMtvP)PK%-jV-cRa)oJO0yB>v-sz zUn0M?n2FKMc!|Yir1@l#+4KJDz~1+cA};3w*wm3FEri=3#e1@ceRBATz!=9xw~IuV z)B`P>3yvo++I<|p&Xd9$>R=w+5ExWkaeLmQnd}M~w*Pv@A*5;P9&*OLR%I7ZsPe9F zaSL+KRI@zp{54lRfLAXHs-he#PUQhiZd7Ht1DgSOgQLjwj&D%f*^QSZ0 z0QB^qi2+i>1`=d?4B{TprkHf;Y<_FiYVBG})aV|fFtG(%^v=tucjJ^VtVwK^wX?v0 z%6rkE^>f%+&sxhyyX*Gjt_5Y4tG_jA29!=e`gOAsh^E5B2*w~ANqtw3Ys=w7(7HbD z(_MdBB8B04c;btQ$w}hGS}?OP`7k}|y#g&)FNxsw*i9s`og>*R}P6Pphb6_d$omquSJ0cfiqJT=l{c*l?V= z6m6KX=&{h@-8e8y5sE`)<(_&65GUt&?0k z_AoInMIJGGt{Jn3%Q>ix<06xUi~@a&OiTZIWY#E1XOHe)uokY~wP3I-!7dco$%&)PdtdT6ZYsLH(>C00avnG=7(s8#gY~^)?|PH2UR4Ws`?=c2SS7y zN&_^)mG~u6$^4Q>H(`)NpJa!v)=8nCLr~X6^@jn4H0uB@D*IBUijA8Jqhn7?zDxO1 zMr+aZsgg|!Hwhd^Vh6j{FYf4;Z<-xA9duwLI*}Y0n4l$yxY+H4H*_0p(o&G8EWYE% zBYNzddxEBrP%O4}_NN5V51ybg4rzaUg5IeTD61+`WNp$e;^3L;)aM86&mdgbd{sHX0_PZZN%*E zJNuV6Y(@QbZ3r?nC1={}cXW6D0n!im3LFMJ7F@e~=CF&4p^Yp{Pgh3$P+6=pGcGYA zc$f+i?A*&DtKy;`fG21z2Dv)S36G$fVgi!>0fi~6=S=6rpjeZlTffjt(M@gSYua;K zlVXs8aa+;q-CKfiRpexLb)N6h-8@HY(_4oF5`Z$eDZ4Va&NV#bSFtra-{d{gNwjeV zO0NB3MMsFJX^z(dYEvZb+5A><(^6|Gz153(*U6xW^YHRvd0MUF7+Il{d~GQAXv z|FTLpbgSQ+zl#^tDS`?s)rucM?r{=+faL~!dw;QVIDjYT-BFiq$@Wmu>q<(XfjRn6 zS>-v;Uwdcjnr||@tknl|n{Oj$ydE}m3KvQ~cJ~~7=ZC$`ub-r_M1m5*gYm)Oy3gY< z^4;67nC9VToE-sgoB5|+fA8mv6(++*-KIvTpV*#Uj?b>C%PC%OE~ywZz1x!|BO?pA zBYN8xWtMKXBtmR0lMnAIfl!WaVW0@h_?3Rd1)WDO3RUpv3HQp$k{U&x#d1HTCU>@8f3M`D{Fp@MybI(-7ZdVvy_x=2=~B9t6TD`Z{UJ^#OMP*N?| z-^1S<6!ROa|7hUV|87u4c>`x>6UTo_F;ik2YzGApf0+&37=R%2+C<97qrkzDDi(_q z5S^SXjr|^Nn4QSUiIYk|*eOC}gL7$3&)B=X(ys0E`+<&04T~BI>hh>nDu{P)lVOr+F2vbhu~R=m_tMrSU5Op;=dygkqTrZ zW`gpolp;Jbzc4(o9CAxU#CZFQ^O!`YOL}>4R?e^bxQ8$8siV)%{cKY#7I98`U7WpU z=k&HBel}V=ccSTlS?iD!de#MyqJ3J*d)pB^o5*{In)~~x&~BC8N}U)L-BN3t zbiL{H3NMQ6eR=dK-f30dAzwuoF zroFO#?3CTV{OyoD+a&+8y=>6seL!y)+~(M>jZFUPbDOxDJ(w?hwhj5~05B2y zYJ$eM2cUV^m47%v`{}ZLfrTJ+o(6Rf!wqba=e;?-L~W!@B3u)`@RVN*3*8tf7`6T8 zr4x97J~30~yEqbSm)oUhtbnr$nkdGLW3p(OE61yg1x1149Z2qKrA1#{f9SLa2KHPLT%a&*ml{Tt8J8NaFwJWdDn`^Hza9MmkOJRkRzB{9XWK|p9t3exx)i0N+gB)2~ zH<|-oL+4dSf)dr@4yy4Kaq{cDvOe(C$cmG?v}mo>W-$GV4XdWgTDzgDrKi(ofECIKhjV6$opCE;oikxy zZ0J%CQm!4dyJXI=iZzC{h1uSDz2H%Y(0YFE=5_)T8Y3v1hc*N?HoHQgiA59x7FF|= z{{T6hzTie$FczEqnG%~UiUF(Of)z?QzHf103E8910qsOZHNzq2qQ$UPuD4PlCj1(B zmM9|~YnhKu8&c%yW9HR_r-yGHdtwOIAkW|^%Yc{Kz&c^@z9`f|NC12bkPF4p8k~!j ztsh+MM~gfkDBj8^H_W+-r>iu2HNUY>=GDG5t!yI63aCXtoR?tf?G$4bEgEK-d&up6D3dLXFT`3hnyO2V5_5RJ|+J zRe0ADS1@H;1wF}I7L%rW;_m7|rq*~iE`=)->g*Dymp8?3lne9sd{HLk zGKoj_3?IOXLWD~S#d0JI3>>F+t8Ga4z2c_~{sEx}gndzGQ=k(6wrSV?sB4@ z!(z`WRYGtPJ7v5~v$pj9rrw&^1M&-T_5#<2fn3a~+Js%{(ksbDlfn_Q%^@5dpgl`9 zgWMrEv4gv7PC;Z1o|JebpPwIrBO@zr2_I}XUS=4s3Pd3wJELE~hCHVglu+qzW!8l! zvvdJ{iaTL1UAguX2~C#K)2X%lFJue36=@L|J=2TBuV_(;;`!^73kzysKQw6M3?RZ3 zuX1KQ2nUon@}D}JSU`xPiyZLi{cN`?>(|AcKW8i6*jIE$Kl-skqLVnX)~u5=9Sb*p zX;m}NH*%%J4k&C)H|U&}@Dv+}>pH&8rym?Cm$Hj;I`pWBonLK(RfH+_bM})6xwsCy zFgKC+`;B$Zs4JG1<#zXCmg{l%`R2pk>7^srFoKo@1 zzvDUYf>++7IZ8V$HQ{(E8aU0xSHMMc40;J?J^^aZrBs?nGMQ-vzRK8RH7S>GX~wyD z*Qlu4!nhF$Mb(=uXkKmx-0z5k%r*w(#pOuIX0sVV0zA`hGFJI{rAn28R+;26LWxod zSE5AdQDk8WVA<>hnj=yV8D z9s7wTr-AB4ZzsMny^?a47y0C9e3dt8FN`tx$8`+TJfvJo$m`L5fF#3a;y1ba(id;u z4UU+uLLwqH=+1j~(H8EGj1T*}G9jAOoVjCJNP zLV62Mf%40HBAp%?ruBJmX_IpY@ND5Lx^D-y-|5bTM#Z8*^3a#qx>AkFbreuWN91w zFy4#XFk=qW>#&{tE8~G37q3}kALIG$#5%y@8X^5i%iwnzvFreVP)G0VWeDS5W}Rnx zc_ZV#W!RquTk17F*n79!ZY|XU`zv1WCqZAzz^^O^xn?`M&BB|w=P~HM;v2c=?}62F z8AtHcftK&gz9J0Wl{d4eSPXiwN$5u%!4)(vKbVnT>4eDcWvFwHq=vmObD)xpoEnX> ze<%35R_)#F*ecmSPNxYG%mQGr`$qeI>`7%n4q~DA1uCw5l#KQvhxE=AA%480Ju5#S zDiz$+nbdlVpCI{>OYp9&7DkX7vYi6>899GdVa$G3dmbLq2L7y{f%LDPxYa_&wrao5Os%KvIAERZiEat8{3z1 z{xv9p36d?{eKxEA#|$-U&xERr#WTV;@XXmA<&0Ju%xa&J!=npP#$P3G zA(9X13&yw!%`YiT+UGHz@W|YmdXvq#zC$>){3p|}22oTWJeKv=_aDsDnApS8$tXMu zMYD6hFWp6RJGxPIuI^>W;$FFNty_wQN;%fTOx^njZQUrk**OET@Wzs$pGuwBnunJQo zBdh~@*(v6a(;d6Nh+mO-DdFKucINkoZz>DB7_HHC;M;pl6Ixlp4zXtXOvfbDt9U1P zC_0gIrlHQ&jiRiqny8q^#{J1XYGz-xnH=RG2-)xT2df23QS&y!neaopGY|+C84pI| z9Tu7Besk|JQuuEAvm@_QKJ)f@+VQ>A!Tf#5!H%|4d5~E@t2|S)F4t5bPO+1kBseSv z1wM?X!=phPvR?~~@Z7MQej;Ygyy7DGa2-h1i3%a-P5<*7HmeJz+`B&;FV5`Kxd}b| z71zQ!)#4>ZQ8c0aq^qQa3i*ZHbAu%Ahlcy*Q-{^(-7G`P*G7w+keiPdk88rlaP+iM zll7KyIsDcVenJ%Z%X)sEXwC`vA*~=3d9_;;BZx9Zz+C?E24!EXk(mkOh66{RBs?6$ zKKsPAy)P4HY}#*-KAxzO(Q>#*DR7T3|E!_}xJ3rVZ=3X}C`G2WWz9SPn`{o43@usk zx=8Hl{0qEJVZ_rx!LB$D7U7Qi(G zA>rBwDVn&v^qaD83Syf9y)jqC%qO+cHzAmQWJcY{MjL~SpVf?f0(g{ab5G(xR?QTe zF^~C-e%Kv{FJOpz!xZ3{+-p#eA|jtd2m10j=k#3CoNQFxL5@Emo>rdwZ0P{5O8j~SgmvBd@m zgNePFTPH2=a`uFE>jaTp|3rP_4SxU=-+g0=(q-YuGE$UuYUP+-HH=~onJFCm(*)8Q zjaOFZ&%OHy6}65-Q$h2Py~|KSF zeaDnRWA!A+Gg&^Dy5<64pDvKRqC=>1E-GpaS~Shoz`u>inc6gO{aBO7v5hMeFsnNG1d_ zVwNBThB7V695$|q*@H*M0Dt-jN_i-$JfW;u#~NBD3@|ZgDlEg#uu%a%`o{9{2XU0G zy(qm&=y*O(pER4Vz2kf=6wm{j#sELHM_?Gh{>q0d>c6d&;jqypJ(~#;mZf*> zRxH=mIfc?MaXuNR%yp5%3C0N)+f`oQO<<3|@zSMx{%^x#dL znG5hZzoM8BYSusr?_`Cav#Go$EY<9c(3y5P@8#RP~h-HoyYhMwDR1F8CCO#-ha7_{wtxa=C7P zR%WQRN0{n8=^0uIzUngSGKDCz^8X?29bjzjws6g|ZM$lfZQHhO+t_8>wr$(q)d-fx6j|*$+_LhO1^x_%1Tx;<`{F%G2Zv--Rc{kJEem^^mGB40~y!HxpMQa zI!e%u*MsyVADf0-=)z;p3wMIItyZQ|v@hhXU(zoSEC4QFTbG-j9qW(JfsES;%%y>! zNLU9g)Lt+4R-1qCvRcu<$1mzq<_EA}9FtD@Q_l)^Zv$~H%Hi^!z?C80RsGTGZDHEIuRqCx_xzI%}%6A@x~_ zyuOgU2V<}}arM?)dmh)4h9hw&;i&o*r7w-)ARf!z6UpgQyGmkl9g!b4 z5g{NjFB-6<`$q0~jht z_$=rz&S*OQKCuN!h6-f(%hy8}!_GKSKTg3d>{`@HXij&>rdH0mQ9 zdlBnOApF7PzGSu~vv#Z0mDexJad+Sf2nfAC0aT>tmyEw)@l6^OTf9)cAp`{L@0Xvk zePMhC<`=iWc(vU{Umg+;TLxHR8AGc6!Ky6|5L}>!GlcG?35zgg439IJi-n8B&rvu3 z%utJ&;VJYN2G_S%+ZC&^^ zpiEVMdg*a3WQ|8JR&<`8C&qBt9IRG_SNY6?ot3=84AwM*E0;PlRRHvvDeyJm%w?V<()&5cdliJ6XdS81*DAsv)WXoE9HK1&f*h8#kBnV}apl^3{P8Qsd% zN3>OrIvbmoZrpffG5HdXkKh&(u4J8mjRg}D$~DOqh_ywk+GVFGXeDHPYtrvNVf1m)bmwE01npF zWwq}PXJm)cr{Nl~S| z;_wyFO|iu-*mtoEsf`@lU{@-<;?1(&lpbxvfcfQW-Evic-^PBR2~Tlhm1>jY^n8mmWE;WI{oBU3Ec|-ck^4%lWt38N9OUjpzU`&GJ znne2GD}6wM_Sq;e8)1U-H^FC# z9$Y{0f!=#2TcuzIro!)0$grDYcj$9hgfeC8x9gSB8zZW9>DF+Cjuk^LO>aE&wbU9o zYl@6^r^b_Y?;}(7EL&9D#g#I1D%d@m z#IWa(!iLm{KORMl0jQXTyoQ=_LskO3?xeq?Z5H?@40KNE^QM*rwJay$6~T}4otf0} zcMss{f#Dw@D9<^Fj)aY_hgye(*i)1kF;k2=3VX4@kIOB={Wi{unisZZ^-s;guZBG# zHgp){u#bzCyI1rLTaBviTD5;$XVL{60Mvw0PJkOTb~mkYnYWSTaO%JXXWI{}(U- zF11K013RL}%{HIU-1T<0L4W@zSb{h^7_y6zr6(CNJu!Y*)VOcvNg_CQFf+z*i{Abh zDz*}~!t0;Rn2eZ{Z~h}ODN)Anjg9RsRK|RPMH%fCDw~?IM^KTa>Qw2Kx@13#)2z|U zrs=6L^(xEFIr4+xEyBZ0SIkDGlg8}* z(@L-o-6*gGEYob~AZaL**H|NLQ81^|*=>z`>e6A&!_pYJk_1o3KEM}|*{y(X6qN_H zTrIKV4BcXKw3&>ybxq`i(}IVxVAsSdd%TQgw+h2FyH!X0qDc3uqypu2OIy!xB7mfO z7Z_KuPA$v_bY5b2*wnf``I1=uH==`HRZoILKeK0gyT6c2zRobCf3YJ7lj!$j04yce zxC}U|rkW#cyH!+;x=R({mjRN0)6%-swF|@mq*5p8lM=2|{ZBVY&uZ)SVROj*BJn5GMH?5L z$n#gQ6`y%LNFPFd{gHV?eZNFY8VD_o3mhT#9<7tA*6t|ZER#%BM`-0H*Qx>_5czoC z|3L=8am{8b{Eq+V@A&`krM4nQ?q<%8|6&9Ddvs9MPGLzA&HoyUZkgE-X3DqPVSrn6 z{T6*0ai29yOpcM|w)Uc~xTEzO2k?a+5KPFZBuRF6RQ+w8Zr=^hj)}rw^StFU=a+Yq z<8t%$@%aTR2>gr|Lrf#0ks#=fM(sSLozO^0DgYu97(*WV0S=B5#hhlgmEsgFbR2iI z+A-gt>u;e3zgCZ1W}&UK)v#s0b`e@haz>~a}hR=wmFe5t>wep*3W-h&b z7N13$ZB=B){k(epuO1nPqfQ_C88%vM_Cok`Q`c;517!VG(I2nIb=Y6qlyqL_g|rA(oXpjBE_4p=hAw6rrV<#BI-bESWBR|SYMw}lh%w%Z_uu3Iui@Qy2bCjBYA{wvS!4pd9ia=rGRQE$)+u$@)CTS@l!Sf5`_u+i% zMEu72SUH(vGK5|r=QY?TkfY6t15OwjQ2yy!>1%VNueoV- z3PYy0!M3r9Z}Zu29%GYs^Xqo0^M-kB{AKvXcdm2C=bG<1$7h>w23BX47oq?u;DjVB z+1ZI%esWNFs1no(M_%KB1I9XZNLR8W!jWkYWvDb{8Y&&N zj!0LkBg&C&4`-+~WHjUgY7Nz%h?aOuvLn)wWe;OWd8jsI6RI8c+g6=uOR6KKT=Q=n+XBm4J*_R8zz=;}&X9X~-m`HDnxG0V$8HhLl&#GwT*^ zPix2}LfYdiLsL=^keII()s*J1J+BZ4a4gz{!Wj;rRmQd(kJPkDl$Aeenl3RhF zN6Fa1YY)&HcE^p;oI;tPkKIsub_5RCK7|OZx%b4?@qiPvtEO04x8`~hx0z86AC)6r zHRM!cyJ0i$Ote%k;9NFpgE)}qv?POLF$SOYv;c(fHO?~kf+>stf=R>GOG}Ozv8zsC znQXINf@P<^>5q)NW_Z3dumW(@9&Y{qgM$0^A&Rz(BiK7pE z9s6O6W?P#njUq*W%rA^v^u~LSw(I#{S|d}u`q-(=#WhLNl_)Ps(wV=by41vE`DYAD zS{SvsHGj#>SezN}LG`sYaMRSdOB~cB=eZY$jn|0b!fLFh@0$2NENj)$LZgdnCFLY) z4h#|IYG=XI;3zl|i(>Db?r1Ry#Oync;BqhTs$f2$Q(2@6In#>Z2Ui#9hpI3J7m5tJ zqU#8SQU?WDL+A*>i)iQms!-Mb-KX@Mzjo*amoGQY)n`V#L`xN2XIV`ZCGNgjZAxjb z)jD|?Nu?}7naK>1$|t5YR}8yrMH#jFcbi|jL}^hY`!w_}hb_Xc#|QF2{)psy_aff3pY*<-q zHw&Q2RB9p)({)~IYM-`4{I?JB@I(!e+(r*rCz*&^w8EdohF1;b6QmsM5HVWtjf?{r=b_T#JU)LB_Y>5rmIdi}dA7119Nf>6kq>`}wzUHhoD%p}4 z?!*4Y0Qx=+${LddX+4o#%odYq!ge?f0)JqtI4snpD3VCK>e#?5I$U_q2v6?Z69(;E z6sF82HN2O3DltLImNHq)nle3!Q#JtupXyu{M$IKR?25gpNN2-}M`vNlDZXl5k^F=c zmkN)A!xA|=qnUN+gp+kd3*XElL+2b9*17oeXO=bM)kudj33{rX5*8RoiNpfZyvfKttqy)XQ^pTt*+cx&j3B9s z!9+_@u47~7B3mcLKvcy@7DjZ;0vHWroHRr3G~$rdiZPr&n&HKb1T420af9O(qY%q? zh?;y~!ojzNN^nE6OlWe(U~#C$KWT$9$fX%|QVPXnhw+rcJB=~mj0)9$F^b{X857es zLOqO>LPd{8M3hEE9a&=fGBsi_-QjVM+BA&5M?^CuAmJvWW$#7NNxqX|c*3(Pb4WRW zQl0&M(VuPD zKii6Ti2QTi>EQes0*tRSm(R1=94tJ~$M&CTY&O7X`}ueyjYN>;yURrKq&eHT7Q3>o z`jVW{j%`COP$wuD$aADQ3LQTjiT6l`Mnhzv(oyJ1b>%yP9Le{nhgL#tpw>_@P}7j= zNOcuD0vl2sN%ts*W$0M(ztRfQtc!fNZZqfFvhFC%@py!eE06l`53Z97#fww4o zWE5D(}ZF#{ia|2_7 z0NcfQ+cHpad)GU#rb(1V$D=li%U!Okchdc0rmUkjh@6&1-=T}C^_tz>i>@(6&`B+O z1x>zVp&^sC7K6u@d}EUfULf0!Mn!NV=35BRJnWuK2R%-|FWqD}K1GI`^1LGq`K;>W zPMzpZ$B%q_>& zcPW_p{2m8MtQh8s(gfeg=1g9gJHlT5K6*|?HA+*JZkUv$G#yiCRd<;=H3%jy4jEgS z?5P+d$UwqcFlH4~tE+mAqtvKiUyaVdHDk=uZn=?88pUW)tE0*&lPjg6X^*BF$*E7N zz0-^6)>frN(WvAUW*F7u-<&#~Y4)tUi@bnl3%{TSgx+$n!=_wr9nsiHd(~NaZPF9( zT;RLhwnl8qFWXGU*p}WT{xQCDG#7tWZ0Iq!RWrNlsNDhHyt>DGx)xGuXFx<~Oxd^>u=>hhqj_0Diw zXQ^*$f&{>c{s`~{3iMk*2+xPs$DS}oK9>n@olavE@ETaKnXryX#ZE`NxQuZE;;q=5 z=Ol`alO2$sSqKM306^a+CKax{&XWN8VaJS3r)$t(tAAF*uCKf@Q&z(EVmteul8WHG zP3f^-QoFESQj~`MHWPsNnc!o)B!6CQh~Lr}_ID0||2zW5K}Z2*g-e07;?yK4+EXNi zcy-K}Sz^>gSYT8=wxLWIW(_&o!+|kN3^Vo$2_rpngqN5^ftQ#zVU0;3Ws6BFX2YWy zx1@aX6MyF)pJav~pLT{v_YXBW;{2`=-zB6;TBNmRfl*0*(J?5erciy=Dud*LjdcQ* zP4?Rvj0~4e_TeJ)T8G3rEG)CH`OJY$N=Br)-|Sr{_A8uc8j(|3hd{{n+`7fl#N@j` zU}{%CfWpMmx77af-f7#_Tz&1K7Bqkpp^s|dh&!~=4I;)0G>$p^t3)|&x!m9YI%Y^Q zg(L#LGt!t5@1z+ats8OI0fX9rrluKYU1NH~mJw0T2#xCqvOQ$#8>8(T%l#Fb9W+~k z34tR@m_zwyRCW3-up?rPVl0b9k;N>Hh`7rjwq{b14W_bXg@#N!6>a8(Z_^CX+GiFW zMQMr9K}MZkpne%0;T{=%u*9q%M7|*vKo$E`V5&ku=E+1F7G3XDb+ zMp=xUwf(sa%@EhCm*||`ew2Xetgnj(2rdoopZMBH2NG;WlW+8Y^xx^{6 zKuXzy$!SIxJ6MBHnZ_Mv%{KP4W{jTBKuK+o3FqXL_BQc_FyblFIiD$>p@hsPs$`c_ z9%NFwiY9W^x9W*=rApqCu4i77?TOIXK&8`VS9A>>HayRljoTA^i@FICi;$$=}i z_0mj<-38k+=KG6n(nsoUOfNiD+#cLqS$OuO9q^II1aFIKq-XFYf-?g^upjZq6qV$l zF>~H9be3DykYtclxkilQR>BMBiPSt5Q;lZ?y?At^Y9Ew^;Lj@FVZ#V-H>D=^#GE#ymiMdVun ze@4ZLyV>p|wA|9Sbm=)~{&iuqT|oZ^IAf?4t1BUvG%P2@wKHbS3Om{a4^^4DC?jg0 zS!ASobt_RSY<=IP+4PtE=q~P74a{&0F8rpm6$9*2g-i9~A-d7wRhM4lNN<%LbiMf; zvT>jJO3ww{!#XLm`hBMv1e=0BT<>TWIYxGDk|V*baxU$s*yiDr+4XV>0hXE;XD)b6 zQ0u~@J?q+P$Z!EVtqkWYw}+k(e|x<8>O{WfxjLOoJWGiBJ)Dw0J?3bZ#A?ls@I(zW zBlj8eRcvi`p9Pr?8YgOuCh+m6o7B?<=+wRokN^`Nz0!xZqe1kE@jhv zjH)IWSRYHG8)tmwVtty@W~Ky_Q-M&F%h(`V!782tv--JW$`a!HME-PH6ge$z9T)Hk zXWUk~gd~%~En&HrAcO!ikbY$kJK#iCyiv({ls=1hFQ};QR(!;-c!LdDLgcw4tZYyK zd-sLPw?0tej8q9xOBAOVSgRyT{UduU=eYp6)GZOMEMFf-lc^CABXBWsA4<}Pa+aMh z<}ljcZ0d+3BASl!n>$LiGSjF^oR~(hgXp1UhZ1SW!NR*c7qFl2`~0dsO|x5l{{<0} z`6Gd|q7(J*0f_{e2#dZYx&u|VJsu`vu3cQW47t2L;3ks_iUqxw*4MJh513IFT00qj zuZYkCdff5`Pzjs-MNs@1h4c20#j+C4?fS2D9`c>e75@LGa|Jio|J!QOb68Zw8vhFk zga3=GEzn^%CkjSv)yt@7z|$zUG0~Q~YsB-QFeg>sQc8#QrIOWY0qX$r7PcbNyU-uu zzy)S5^b)4`)JuQ9{_ly@``+5-GZ4KIUofk@Md5zTrz zt`&ddW*8wS1fgR$4cz9ag(A)5uuEH!;<3`&!Ao@@%uGeH2t*OqDEUneKU0!*g(%M9 zIbtP+K3&CMq)Q#+DL8wUcbii&UEsaH-T@%KSWgpxvvpcZRXHvvtG&7{*B!=V#%ba# zDGWo0CqGovT~-{=f(agcb2wnm_&f3@l9?ZsD$naEABQRuvq~8wnom3&{3HHw8|zW} z!C~0kN8*>#FgFo;0Drm#N|f3gsp^}CCOu$HS|3IBI!!*dNCyotY3*%?DO;baPkNWM z4|aUUi?_y45~|>Oz%n|3T%{y{(Ev8G&3vC?LaAsc+bi>Isv#5*O2#LB7xlAXny2(ZYJ=TDIgnA=}@QOT08^T1NN= zcofOXYff5Ye=_AJ%e(76$>V$Wn7{a{yvNTy{Fq#21yNK0ssBMVe99S$pHviQf4T!_dcOujqHrjYv6 z3JWntG5>Owt%MnO5-v$rtKlJEYCCe^AY&1P7)D;*B61tI>K zIIA5hIYx_7OJd|ST2^k_EzaF1l40=5*y0*>6_thXy+Zk{HS-+!y1 z`fA)3<4R;9R8i#{@@3C9Q z>gdAm%?A%d)u3`2qlqs}^DgYkMcnCGZ9|>IMck)g*30kJDSxCpDUH(re+c^Owh}L^ zH;I@J9ji~|z?9lS8vcos@l4%x4hH)i2I9Vx1mZFCgcn%6^9B+a!v*e%oCan!?$zuS z`{k|hnwQ#=CoQoSK&8sXkh+XaVo*7!;#*@zot_w(UVjkR>>d{<+FPws#)=k^QONQAg* zUk^gXmXmdbm7FKn%2{8SUv_hIdu?Uo>V7XKo+l@1gmXxEgm#W-J(Jy~Y*YuZJ}&(w z)d0~G|2JKF{(%9LaC*jw*ZwedW~8~@ALUU71MSNS^edEPO3L<_Ns)a3gZ1GO6Qa_V zzM*yO;ry*`Y_pr!h^=B(fA}+2sFmvy-a9>ZuzH*G+hGUu@BNlz8ZQw3i6kMY>k0yrc^Eq9 zbZt|E|6JSd7`KcaRV(Jhc*enR>@dHKA9X7RU_4_tYHt0E@uWR{2MXla<6sYaauS1n zi}BNFj3{4i-#f87pMLRwCL!JoLgd>*K4{(t(Yu9%o;c3 z$K&D+zcuNZmgTN-**E^G<#+=y{*^_KRe^UnW$5N&9iyZRD!Qj;4!d*i(Gy>Bg?Wb7 zE`PyrQ+dfq9^xl(1LR%`b9GU*-}VJZ<8GhP!pV8Hn3;#-y$}S(L;Ieve(bsq;nj$k z9eQ#t0(2sHv*!n6>6;=pF5Li(zAw5`{r?U%>wn^>)Vu zzdMgM+#mTGMNp2;Dv79)Lr_Y*Ap=Pzrl1&>R|3K(1~{|w4n$a(HeIw~m32@G`jVD< z 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 Date: Sun, 8 Jun 2025 23:18:53 +0900 Subject: [PATCH 02/22] =?UTF-8?q?feat=20(=20#11=20)=20:=20build=20logic,?= =?UTF-8?q?=20buildSrc=20=EB=AA=A8=EB=93=88=EB=A1=9C=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build-logic/build.gradle.kts | 11 ----- build-logic/settings.gradle.kts | 17 -------- .../main/kotlin/io/casper/build/TestClass.kt | 15 ------- buildSrc/build.gradle.kts | 7 ++++ buildSrc/src/main/kotlin/Dependencies.kt | 41 +++++++++++++++++++ buildSrc/src/main/kotlin/DependencyVersion.kt | 15 +++++++ buildSrc/src/main/kotlin/Plugin.kt | 11 +++++ buildSrc/src/main/kotlin/PluginVersion.kt | 8 ++++ 8 files changed, 82 insertions(+), 43 deletions(-) delete mode 100644 build-logic/build.gradle.kts delete mode 100644 build-logic/settings.gradle.kts delete mode 100644 build-logic/src/main/kotlin/io/casper/build/TestClass.kt create mode 100644 buildSrc/build.gradle.kts create mode 100644 buildSrc/src/main/kotlin/Dependencies.kt create mode 100644 buildSrc/src/main/kotlin/DependencyVersion.kt create mode 100644 buildSrc/src/main/kotlin/Plugin.kt create mode 100644 buildSrc/src/main/kotlin/PluginVersion.kt diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts deleted file mode 100644 index a6e17cc..0000000 --- a/build-logic/build.gradle.kts +++ /dev/null @@ -1,11 +0,0 @@ -plugins { - `kotlin-dsl` - id("casper.documentation-convention") -} - -group = "io.casper.build" -version = "1.0.0" - -repositories { - mavenCentral() -} \ No newline at end of file diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts deleted file mode 100644 index 2af0ef2..0000000 --- a/build-logic/settings.gradle.kts +++ /dev/null @@ -1,17 +0,0 @@ -rootProject.name = "build-logic" - -pluginManagement { - repositories { - gradlePluginPortal() - mavenCentral() - } - - // convention 모듈 참조 - includeBuild("../casper-convention") -} - -dependencyResolutionManagement { - repositories { - mavenCentral() - } -} \ No newline at end of file diff --git a/build-logic/src/main/kotlin/io/casper/build/TestClass.kt b/build-logic/src/main/kotlin/io/casper/build/TestClass.kt deleted file mode 100644 index 6a5992d..0000000 --- a/build-logic/src/main/kotlin/io/casper/build/TestClass.kt +++ /dev/null @@ -1,15 +0,0 @@ -package io.casper.build - -/** - * 이 클래스는 KDoc 주석 검사 테스트를 위한 용도입니다. - * 이제 올바른 KDoc 주석 형식을 사용합니다. - */ -class TestClass { - - /** - * 이 함수는 빌드 로직에서 사용하는 테스트 함수입니다. - */ - fun testFunction() { - println("이 함수는 문서화 검사를 테스트하기 위한 용도입니다.") - } -} \ No newline at end of file diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000..876c922 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() +} diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt new file mode 100644 index 0000000..ac8c1e0 --- /dev/null +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -0,0 +1,41 @@ +object Dependencies { + // Spring Boot + const val SPRING_BOOT_STARTER = "org.springframework.boot:spring-boot-starter" + const val SPRING_BOOT_STARTER_WEB = "org.springframework.boot:spring-boot-starter-web" + const val SPRING_BOOT_STARTER_DATA_JPA = "org.springframework.boot:spring-boot-starter-data-jpa" + const val SPRING_BOOT_STARTER_DATA_REDIS = "org.springframework.boot:spring-boot-starter-data-redis" + const val SPRING_BOOT_STARTER_SECURITY = "org.springframework.boot:spring-boot-starter-security" + const val SPRING_BOOT_STARTER_VALIDATION = "org.springframework.boot:spring-boot-starter-validation" + const val SPRING_BOOT_STARTER_TEST = "org.springframework.boot:spring-boot-starter-test" + + // Kotlin + const val KOTLIN_REFLECT = "org.jetbrains.kotlin:kotlin-reflect" + const val KOTLIN_TEST_JUNIT5 = "org.jetbrains.kotlin:kotlin-test-junit5" + + // Database + const val MYSQL_CONNECTOR = "com.mysql:mysql-connector-j" + + // JSON + const val JACKSON_MODULE_KOTLIN = "com.fasterxml.jackson.module:jackson-module-kotlin" + const val ORG_JSON = "org.json:json:${DependencyVersion.ORG_JSON}" + + // JWT + const val JWT_API = "io.jsonwebtoken:jjwt-api:${DependencyVersion.JWT}" + const val JWT_IMPL = "io.jsonwebtoken:jjwt-impl:${DependencyVersion.JWT}" + const val JWT_JACKSON = "io.jsonwebtoken:jjwt-jackson:${DependencyVersion.JWT}" + + // MapStruct + const val MAPSTRUCT = "org.mapstruct:mapstruct:${DependencyVersion.MAPSTRUCT}" + const val MAPSTRUCT_PROCESSOR = "org.mapstruct:mapstruct-processor:${DependencyVersion.MAPSTRUCT}" + + // Test + const val JUNIT_PLATFORM_LAUNCHER = "org.junit.platform:junit-platform-launcher" + + // gRPC + const val GRPC_NETTY_SHADED = "io.grpc:grpc-netty-shaded:${DependencyVersion.GRPC}" + const val GRPC_PROTOBUF = "io.grpc:grpc-protobuf:${DependencyVersion.GRPC}" + const val GRPC_STUB = "io.grpc:grpc-stub:${DependencyVersion.GRPC}" + const val GRPC_KOTLIN_STUB = "io.grpc:grpc-kotlin-stub:${DependencyVersion.GRPC_KOTLIN}" + const val PROTOBUF_KOTLIN = "com.google.protobuf:protobuf-kotlin:${DependencyVersion.PROTOBUF}" + const val GRPC_TESTING = "io.grpc:grpc-testing:${DependencyVersion.GRPC}" +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/DependencyVersion.kt b/buildSrc/src/main/kotlin/DependencyVersion.kt new file mode 100644 index 0000000..83cce0a --- /dev/null +++ b/buildSrc/src/main/kotlin/DependencyVersion.kt @@ -0,0 +1,15 @@ +object DependencyVersion { + const val KOTLIN = "1.9.25" + const val SPRING_BOOT = "3.4.4" + const val SPRING_DEPENDENCY_MANAGEMENT = "1.1.7" + const val DETEKT = "1.23.6" + const val KTLINT = "12.1.1" + + const val JWT = "0.11.5" + const val ORG_JSON = "20230227" + const val MAPSTRUCT = "1.6.0" + + const val GRPC = "1.61.1" + const val GRPC_KOTLIN = "1.4.1" + const val PROTOBUF = "3.25.3" +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/Plugin.kt b/buildSrc/src/main/kotlin/Plugin.kt new file mode 100644 index 0000000..84e3329 --- /dev/null +++ b/buildSrc/src/main/kotlin/Plugin.kt @@ -0,0 +1,11 @@ +object Plugin { + const val KOTLIN_JVM = "org.jetbrains.kotlin.jvm" + const val KOTLIN_SPRING = "org.jetbrains.kotlin.plugin.spring" + const val KOTLIN_KAPT = "org.jetbrains.kotlin.kapt" + const val SPRING_BOOT = "org.springframework.boot" + const val SPRING_DEPENDENCY_MANAGEMENT = "io.spring.dependency-management" + const val DETEKT = "io.gitlab.arturbosch.detekt" + const val KTLINT = "org.jlleitschuh.gradle.ktlint" + const val CASPER_DOCUMENTATION = "casper.documentation-convention" + const val PROTOBUF = "com.google.protobuf" +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/PluginVersion.kt b/buildSrc/src/main/kotlin/PluginVersion.kt new file mode 100644 index 0000000..9b16506 --- /dev/null +++ b/buildSrc/src/main/kotlin/PluginVersion.kt @@ -0,0 +1,8 @@ +object PluginVersion { + const val KOTLIN_VERSION = "1.9.25" + const val SPRING_BOOT_VERSION = "3.4.4" + const val SPRING_DEPENDENCY_MANAGEMENT_VERSION = "1.1.7" + const val DETEKT_VERSION = "1.23.6" + const val KTLINT_VERSION = "12.1.1" + const val PROTOBUF_VERSION = "0.9.4" +} \ No newline at end of file From 261ff65339f2b9389a114a7fee1b117e0952ef25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Mon, 9 Jun 2025 08:16:35 +0900 Subject: [PATCH 03/22] =?UTF-8?q?feat=20(=20#12=20)=20:=20proto=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- casper-user/src/main/proto/user.proto | 29 +++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 casper-user/src/main/proto/user.proto diff --git a/casper-user/src/main/proto/user.proto b/casper-user/src/main/proto/user.proto new file mode 100644 index 0000000..150e780 --- /dev/null +++ b/casper-user/src/main/proto/user.proto @@ -0,0 +1,29 @@ +syntax = "proto3"; + +package casper.user; + +option java_package = "hs.kr.entrydsm.casper.user.proto"; +option java_outer_classname = "UserServiceProto"; + +service UserService { + rpc GetUserInfoByUserId(GetUserInfoRequest) returns (GetUserInfoResponse); +} + +message GetUserInfoRequest { + string user_id =1; +} + +message GetUserInfoResponse{ + string id = 1; + string phone_number = 2; + string name = 3; + bool is_parent = 4; + UserRole role = 5; +} + +enum UserRole { + UNSPECIFIED = 0; // protobuf에서 필수 (0번 값) + ROOT = 1; + USER = 2; + ADMIN = 3; +} \ No newline at end of file From d04d42ba1a6b129adeb90e91a54c2cf85de4f29c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Mon, 9 Jun 2025 08:16:58 +0900 Subject: [PATCH 04/22] =?UTF-8?q?feat=20(=20#12=20)=20:=20gRPC=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=EC=84=B8?= =?UTF-8?q?=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- casper-user/build.gradle.kts | 106 +++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 casper-user/build.gradle.kts diff --git a/casper-user/build.gradle.kts b/casper-user/build.gradle.kts new file mode 100644 index 0000000..7c683b8 --- /dev/null +++ b/casper-user/build.gradle.kts @@ -0,0 +1,106 @@ +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id(Plugin.KOTLIN_JVM) version PluginVersion.KOTLIN_VERSION + id(Plugin.KOTLIN_SPRING) version PluginVersion.KOTLIN_VERSION + id(Plugin.KOTLIN_KAPT) + id(Plugin.SPRING_BOOT) version PluginVersion.SPRING_BOOT_VERSION + id(Plugin.SPRING_DEPENDENCY_MANAGEMENT) version PluginVersion.SPRING_DEPENDENCY_MANAGEMENT_VERSION + id(Plugin.CASPER_DOCUMENTATION) + id(Plugin.PROTOBUF) version PluginVersion.PROTOBUF_VERSION +} + +repositories { + mavenCentral() +} + +dependencies { + // 스프링 부트 기본 기능 + implementation(Dependencies.SPRING_BOOT_STARTER) + + // 코틀린 리플렉션 + implementation(Dependencies.KOTLIN_REFLECT) + + // 스프링 부트 테스트 도구 + testImplementation(Dependencies.SPRING_BOOT_STARTER_TEST) + + // 코틀린 + JUnit5 테스트 + testImplementation(Dependencies.KOTLIN_TEST_JUNIT5) + + // JUnit5 실행 런처 + testRuntimeOnly(Dependencies.JUNIT_PLATFORM_LAUNCHER) + + // 웹 관련 + implementation(Dependencies.SPRING_BOOT_STARTER_WEB) + + // 데이터베이스 + implementation(Dependencies.SPRING_BOOT_STARTER_DATA_JPA) + implementation(Dependencies.SPRING_BOOT_STARTER_DATA_REDIS) + runtimeOnly(Dependencies.MYSQL_CONNECTOR) + + // 보안 + implementation(Dependencies.SPRING_BOOT_STARTER_SECURITY) + + // 검증 + implementation(Dependencies.SPRING_BOOT_STARTER_VALIDATION) + + // JSON 처리 + implementation(Dependencies.JACKSON_MODULE_KOTLIN) + implementation(Dependencies.ORG_JSON) + + // JWT + implementation(Dependencies.JWT_API) + implementation(Dependencies.JWT_IMPL) + runtimeOnly(Dependencies.JWT_JACKSON) + + implementation(Dependencies.MAPSTRUCT) + kapt(Dependencies.MAPSTRUCT_PROCESSOR) + + //grpc + implementation(Dependencies.GRPC_NETTY_SHADED) + implementation(Dependencies.GRPC_PROTOBUF) + implementation(Dependencies.GRPC_STUB) + implementation(Dependencies.GRPC_KOTLIN_STUB) + implementation(Dependencies.PROTOBUF_KOTLIN) + testImplementation(Dependencies.GRPC_TESTING) +} + + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:${DependencyVersion.PROTOBUF}" + } + plugins { + create("grpc") { + artifact = "io.grpc:protoc-gen-grpc-java:${DependencyVersion.GRPC}" + } + create("grpckt") { + artifact = "io.grpc:protoc-gen-grpc-kotlin:${DependencyVersion.GRPC_KOTLIN}:jdk8@jar" + } + } + generateProtoTasks { + all().forEach { + it.plugins { + create("grpc") + create("grpckt") + } + } + +} + +} + +kotlin { + jvmToolchain(17) +} + +tasks.withType { + kotlinOptions { + freeCompilerArgs += "-Xjsr305=strict" + } +} + +tasks.withType { + useJUnitPlatform() +} \ No newline at end of file From 04830beeb28d6f89257279aec4689c6de5f1cb89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Mon, 28 Jul 2025 13:26:37 +0900 Subject: [PATCH 05/22] =?UTF-8?q?feat=20(=20#9=20)=20:=20application?= =?UTF-8?q?=EC=9D=98=20in/port=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../port/in/ChangePasswordUseCase.kt | 16 +++++++++++++ .../port/in/ChangeReceiptCodeUseCase.kt | 20 ++++++++++++++++ .../port/in/QueryUserByUUIDUseCase.kt | 18 ++++++++++++++ .../port/in/QueryUserInfoUseCase.kt | 16 +++++++++++++ .../application/port/in/UserCleanUpUseCase.kt | 13 ++++++++++ .../application/port/in/UserFacadeUseCase.kt | 24 +++++++++++++++++++ .../application/port/in/UserLoginUseCase.kt | 18 ++++++++++++++ .../port/in/UserReactivationUseCase.kt | 16 +++++++++++++ .../application/port/in/UserSignupUseCase.kt | 18 ++++++++++++++ .../port/in/UserTokenRefreshUseCase.kt | 17 +++++++++++++ .../port/in/UserWithdrawalUseCase.kt | 16 +++++++++++++ 11 files changed, 192 insertions(+) create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/ChangePasswordUseCase.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/ChangeReceiptCodeUseCase.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/QueryUserByUUIDUseCase.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/QueryUserInfoUseCase.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserCleanUpUseCase.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserFacadeUseCase.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserLoginUseCase.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserReactivationUseCase.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserSignupUseCase.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserTokenRefreshUseCase.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserWithdrawalUseCase.kt diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/ChangePasswordUseCase.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/ChangePasswordUseCase.kt new file mode 100644 index 0000000..1d11a86 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/ChangePasswordUseCase.kt @@ -0,0 +1,16 @@ +package hs.kr.entrydsm.user.domain.user.application.port.`in` + +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.ChangePasswordRequest + +/** + * 사용자 비밀번호 변경 유스케이스 인터페이스입니다. + * 기존 비밀번호 확인 후 새로운 비밀번호로 변경하는 기능을 정의합니다. + */ +interface ChangePasswordUseCase { + /** + * 사용자의 비밀번호를 변경합니다. + * + * @param request 비밀번호 변경 요청 정보 + */ + fun changePassword(request: ChangePasswordRequest) +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/ChangeReceiptCodeUseCase.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/ChangeReceiptCodeUseCase.kt new file mode 100644 index 0000000..498ffa4 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/ChangeReceiptCodeUseCase.kt @@ -0,0 +1,20 @@ +package hs.kr.entrydsm.user.domain.user.application.port.`in` + +import java.util.UUID + +/** + * 사용자 접수코드 변경 유스케이스 인터페이스입니다. + * 사용자의 지원서 접수번호를 업데이트하는 기능을 정의합니다. + */ +interface ChangeReceiptCodeUseCase { + /** + * 사용자의 접수코드를 변경합니다. + * + * @param userId 사용자 ID + * @param receiptCode 새로운 접수코드 + */ + fun changeReceiptCode( + userId: UUID, + receiptCode: Long, + ) +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/QueryUserByUUIDUseCase.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/QueryUserByUUIDUseCase.kt new file mode 100644 index 0000000..10c9e18 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/QueryUserByUUIDUseCase.kt @@ -0,0 +1,18 @@ +package hs.kr.entrydsm.user.domain.user.application.port.`in` + +import hs.kr.entrydsm.user.infrastructure.grpc.server.dto.InternalUserResponse +import java.util.UUID + +/** + * UUID로 사용자 조회 유스케이스 인터페이스입니다. + * 고유 식별자를 통한 사용자 정보 조회 기능을 정의합니다. + */ +interface QueryUserByUUIDUseCase { + /** + * UUID를 이용하여 사용자 정보를 조회합니다. + * + * @param userId 조회할 사용자의 UUID + * @return 사용자 정보 응답 + */ + fun getUserById(userId: UUID): InternalUserResponse +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/QueryUserInfoUseCase.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/QueryUserInfoUseCase.kt new file mode 100644 index 0000000..96b0411 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/QueryUserInfoUseCase.kt @@ -0,0 +1,16 @@ +package hs.kr.entrydsm.user.domain.user.application.port.`in` + +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.response.UserResponse + +/** + * 사용자 정보 조회 유스케이스 인터페이스입니다. + * 현재 인증된 사용자의 개인정보를 조회하는 기능을 정의합니다. + */ +interface QueryUserInfoUseCase { + /** + * 현재 로그인한 사용자의 정보를 조회합니다. + * + * @return 사용자 정보 응답 + */ + fun getUserInfo(): UserResponse +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserCleanUpUseCase.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserCleanUpUseCase.kt new file mode 100644 index 0000000..9fdef58 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserCleanUpUseCase.kt @@ -0,0 +1,13 @@ +package hs.kr.entrydsm.user.domain.user.application.port.`in` + +/** + * 사용자 데이터 정리 유스케이스 인터페이스입니다. + * 일정 기간이 지난 탈퇴 사용자 데이터를 정리하는 기능을 정의합니다. + */ +interface UserCleanUpUseCase { + /** + * 탈퇴한 지 일정 기간이 지난 사용자 데이터를 정리합니다. + * 개인정보보호법에 따른 데이터 보관 기간 준수를 위한 기능입니다. + */ + fun cleanupWithdrawnUsers() +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserFacadeUseCase.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserFacadeUseCase.kt new file mode 100644 index 0000000..1bd1d4c --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserFacadeUseCase.kt @@ -0,0 +1,24 @@ +package hs.kr.entrydsm.user.domain.user.application.port.`in` + +import hs.kr.entrydsm.user.domain.user.model.User + +/** + * 사용자 파사드 유스케이스 인터페이스입니다. + * 여러 계층에서 공통으로 사용되는 사용자 조회 기능을 정의합니다. + */ +interface UserFacadeUseCase { + /** + * 현재 인증된 사용자를 조회합니다. + * + * @return 현재 인증된 사용자 + */ + fun getCurrentUser(): User + + /** + * 전화번호로 사용자를 조회합니다. + * + * @param phoneNumber 조회할 전화번호 + * @return 조회된 사용자 + */ + fun getUserByPhoneNumber(phoneNumber: String): User +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserLoginUseCase.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserLoginUseCase.kt new file mode 100644 index 0000000..281848b --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserLoginUseCase.kt @@ -0,0 +1,18 @@ +package hs.kr.entrydsm.user.domain.user.application.port.`in` + +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.UserLoginRequest +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse + +/** + * 사용자 로그인 유스케이스 인터페이스입니다. + * 사용자 인증 및 토큰 발급 처리를 정의합니다. + */ +interface UserLoginUseCase { + /** + * 사용자 로그인을 처리합니다. + * + * @param request 로그인 요청 정보 + * @return 생성된 인증 토큰 응답 + */ + fun login(request: UserLoginRequest): TokenResponse +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserReactivationUseCase.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserReactivationUseCase.kt new file mode 100644 index 0000000..b282ba4 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserReactivationUseCase.kt @@ -0,0 +1,16 @@ +package hs.kr.entrydsm.user.domain.user.application.port.`in` + +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.ReactivateRequest + +/** + * 사용자 계정 재활성화 유스케이스 인터페이스입니다. + * 탈퇴한 사용자의 계정을 다시 활성화하는 기능을 정의합니다. + */ +interface UserReactivationUseCase { + /** + * 탈퇴한 사용자의 계정을 재활성화합니다. + * + * @param request 재활성화 요청 정보 + */ + fun reactivateWithdrawnUser(request: ReactivateRequest) +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserSignupUseCase.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserSignupUseCase.kt new file mode 100644 index 0000000..25b3bdd --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserSignupUseCase.kt @@ -0,0 +1,18 @@ +package hs.kr.entrydsm.user.domain.user.application.port.`in` + +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.UserSignupRequest +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse + +/** + * 사용자 회원가입 유스케이스 인터페이스입니다. + * 새로운 사용자의 회원가입 처리를 정의합니다. + */ +interface UserSignupUseCase { + /** + * 사용자 회원가입을 처리합니다. + * + * @param request 회원가입 요청 정보 + * @return 생성된 인증 토큰 응답 + */ + fun signup(request: UserSignupRequest): TokenResponse +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserTokenRefreshUseCase.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserTokenRefreshUseCase.kt new file mode 100644 index 0000000..c790277 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserTokenRefreshUseCase.kt @@ -0,0 +1,17 @@ +package hs.kr.entrydsm.user.domain.user.application.port.`in` + +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse + +/** + * 사용자 토큰 갱신 유스케이스 인터페이스입니다. + * 만료된 액세스 토큰을 리프레시 토큰으로 갱신하는 기능을 정의합니다. + */ +interface UserTokenRefreshUseCase { + /** + * 리프레시 토큰을 이용하여 새로운 액세스 토큰을 발급합니다. + * + * @param refreshToken 기존 리프레시 토큰 + * @return 새로 발급된 토큰 응답 + */ + fun refresh(refreshToken: String): TokenResponse +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserWithdrawalUseCase.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserWithdrawalUseCase.kt new file mode 100644 index 0000000..a678a92 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/in/UserWithdrawalUseCase.kt @@ -0,0 +1,16 @@ +package hs.kr.entrydsm.user.domain.user.application.port.`in` + +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.WithdrawalRequest + +/** + * 사용자 탈퇴 유스케이스 인터페이스입니다. + * 사용자 계정 탈퇴 처리를 정의합니다. + */ +interface UserWithdrawalUseCase { + /** + * 사용자 탈퇴를 처리합니다. + * + * @param request 탈퇴 요청 정보 + */ + fun withdrawal(request: WithdrawalRequest) +} From 5f236641a8f5a92c3bbf5e71c77455fc4f4c9b23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Mon, 28 Jul 2025 13:26:57 +0900 Subject: [PATCH 06/22] =?UTF-8?q?feat=20(=20#9=20)=20:=20application?= =?UTF-8?q?=EC=9D=98=20out/port=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/port/out/DeleteUserPort.kt | 25 ++++++++++++++ .../application/port/out/QueryUserPort.kt | 34 +++++++++++++++++++ .../user/application/port/out/SaveUserPort.kt | 17 ++++++++++ .../user/application/port/out/UserPort.kt | 7 ++++ 4 files changed, 83 insertions(+) create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/DeleteUserPort.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/QueryUserPort.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/SaveUserPort.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/UserPort.kt diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/DeleteUserPort.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/DeleteUserPort.kt new file mode 100644 index 0000000..15cf42c --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/DeleteUserPort.kt @@ -0,0 +1,25 @@ +package hs.kr.entrydsm.user.domain.user.application.port.out + +import hs.kr.entrydsm.user.domain.user.model.User +import java.util.UUID + +/** + * 사용자 삭제 작업을 위한 포트 인터페이스입니다. + * 헥사고날 아키텍처에서 도메인 계층이 인프라스트럭처 계층과 통신하기 위한 인터페이스입니다. + */ +interface DeleteUserPort { + /** + * ID로 사용자를 삭제합니다. + * + * @param userId 삭제할 사용자 ID + */ + fun deleteById(userId: UUID) + + /** + * 지정된 일수보다 오래된 탈퇴 사용자를 조회합니다. + * + * @param days 기준 일수 + * @return 삭제 대상 사용자 목록 + */ + fun findWithdrawnUsersOlderThan(days: Long): List +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/QueryUserPort.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/QueryUserPort.kt new file mode 100644 index 0000000..04bfc35 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/QueryUserPort.kt @@ -0,0 +1,34 @@ +package hs.kr.entrydsm.user.domain.user.application.port.out + +import hs.kr.entrydsm.user.domain.user.model.User +import java.util.UUID + +/** + * 사용자 조회 작업을 위한 포트 인터페이스입니다. + * 헥사고날 아키텍처에서 도메인 계층이 인프라스트럭처 계층과 통신하기 위한 인터페이스입니다. + */ +interface QueryUserPort { + /** + * ID로 사용자를 조회합니다. + * + * @param userId 조회할 사용자 ID + * @return 조회된 사용자 도메인 모델 (없으면 null) + */ + fun findById(userId: UUID): User? + + /** + * 전화번호로 사용자를 조회합니다. + * + * @param phoneNumber 조회할 전화번호 + * @return 조회된 사용자 도메인 모델 (없으면 null) + */ + fun findByPhoneNumber(phoneNumber: String): User? + + /** + * 전화번호로 사용자 존재 여부를 확인합니다. + * + * @param phoneNumber 확인할 전화번호 + * @return 사용자 존재 여부 + */ + fun existsByPhoneNumber(phoneNumber: String): Boolean +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/SaveUserPort.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/SaveUserPort.kt new file mode 100644 index 0000000..d9aa002 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/SaveUserPort.kt @@ -0,0 +1,17 @@ +package hs.kr.entrydsm.user.domain.user.application.port.out + +import hs.kr.entrydsm.user.domain.user.model.User + +/** + * 사용자 저장 작업을 위한 포트 인터페이스입니다. + * 헥사고날 아키텍처에서 도메인 계층이 인프라스트럭처 계층과 통신하기 위한 인터페이스입니다. + */ +interface SaveUserPort { + /** + * 사용자 정보를 저장합니다. + * + * @param user 저장할 사용자 도메인 모델 + * @return 저장된 사용자 도메인 모델 + */ + fun save(user: User): User +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/UserPort.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/UserPort.kt new file mode 100644 index 0000000..4167aec --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/port/out/UserPort.kt @@ -0,0 +1,7 @@ +package hs.kr.entrydsm.user.domain.user.application.port.out + +/** + * 사용자 관련 모든 포트 인터페이스를 통합한 포트입니다. + * 사용자 데이터의 CRUD 작업을 위한 모든 인터페이스를 상속받습니다. + */ +interface UserPort : DeleteUserPort, SaveUserPort, QueryUserPort From ad919cea727148f9dea047fee590d6a14cd4644a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Mon, 28 Jul 2025 13:27:34 +0900 Subject: [PATCH 07/22] =?UTF-8?q?feat=20(=20#9=20)=20:=20application?= =?UTF-8?q?=EC=9D=98=20service=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ChangePasswordService.kt | 51 ++++++++++++ .../service/ChangeReceiptCodeService.kt | 39 +++++++++ .../service/QueryUserByUUIDService.kt | 50 ++++++++++++ .../service/QueryUserInfoService.kt | 37 +++++++++ .../application/service/UserCleanUpService.kt | 32 ++++++++ .../application/service/UserLoginService.kt | 71 ++++++++++++++++ .../service/UserReactivationService.kt | 45 +++++++++++ .../application/service/UserSignupService.kt | 81 +++++++++++++++++++ .../service/UserTokenRefreshService.kt | 48 +++++++++++ .../service/UserWithdrawalService.kt | 59 ++++++++++++++ 10 files changed, 513 insertions(+) create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/ChangePasswordService.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/ChangeReceiptCodeService.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/QueryUserByUUIDService.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/QueryUserInfoService.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserCleanUpService.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserLoginService.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserReactivationService.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserSignupService.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserTokenRefreshService.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserWithdrawalService.kt diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/ChangePasswordService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/ChangePasswordService.kt new file mode 100644 index 0000000..3f66192 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/ChangePasswordService.kt @@ -0,0 +1,51 @@ +package hs.kr.entrydsm.user.domain.user.application.service + +import hs.kr.entrydsm.user.domain.auth.adapter.out.repository.PassInfoRepository +import hs.kr.entrydsm.user.domain.auth.exception.PassInfoNotFoundException +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.ChangePasswordRequest +import hs.kr.entrydsm.user.domain.user.application.port.`in`.ChangePasswordUseCase +import hs.kr.entrydsm.user.domain.user.application.port.out.QueryUserPort +import hs.kr.entrydsm.user.domain.user.application.port.out.SaveUserPort +import hs.kr.entrydsm.user.domain.user.exception.UserNotFoundException +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 사용자 비밀번호 변경 서비스 클래스입니다. + * Pass 인증을 통한 본인 확인 후 비밀번호 변경을 처리합니다. + * + * @property queryUserPort 사용자 조회 포트 + * @property saveUserPort 사용자 저장 포트 + * @property passwordEncoder 비밀번호 암호화 인코더 + * @property passInfoRepository Pass 정보 저장소 + */ +@Service +class ChangePasswordService( + private val queryUserPort: QueryUserPort, + private val saveUserPort: SaveUserPort, + private val passwordEncoder: PasswordEncoder, + private val passInfoRepository: PassInfoRepository, +) : ChangePasswordUseCase { + /** + * 사용자의 비밀번호를 변경합니다. + * Pass 인증 확인 후 새로운 비밀번호로 암호화하여 저장합니다. + * + * @param request 비밀번호 변경 요청 정보 + * @throws PassInfoNotFoundException Pass 인증 정보가 없는 경우 + * @throws UserNotFoundException 사용자가 존재하지 않는 경우 + */ + @Transactional + override fun changePassword(request: ChangePasswordRequest) { + if (!passInfoRepository.existsByPhoneNumber(request.phoneNumber)) { + throw PassInfoNotFoundException + } + + val user = + queryUserPort.findByPhoneNumber(request.phoneNumber) + ?: throw UserNotFoundException + + val updatedUser = user.changePassword(passwordEncoder.encode(request.newPassword)) + saveUserPort.save(updatedUser) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/ChangeReceiptCodeService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/ChangeReceiptCodeService.kt new file mode 100644 index 0000000..26e17b2 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/ChangeReceiptCodeService.kt @@ -0,0 +1,39 @@ +package hs.kr.entrydsm.user.domain.user.application.service + +import hs.kr.entrydsm.user.domain.user.application.port.`in`.ChangeReceiptCodeUseCase +import hs.kr.entrydsm.user.domain.user.application.port.out.QueryUserPort +import hs.kr.entrydsm.user.domain.user.application.port.out.SaveUserPort +import hs.kr.entrydsm.user.domain.user.exception.UserNotFoundException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +/** + * 사용자 접수코드 변경 서비스 클래스입니다. + * 지원서 접수 시 사용자에게 할당된 접수번호를 업데이트하는 처리를 담당합니다. + * + * @property queryUserPort 사용자 조회 포트 + * @property saveUserPort 사용자 저장 포트 + */ +@Transactional +@Service +class ChangeReceiptCodeService( + private val queryUserPort: QueryUserPort, + private val saveUserPort: SaveUserPort, +) : ChangeReceiptCodeUseCase { + /** + * 사용자의 접수코드를 변경합니다. + * + * @param userId 사용자 ID + * @param receiptCode 새로운 접수코드 + * @throws UserNotFoundException 사용자가 존재하지 않는 경우 + */ + override fun changeReceiptCode( + userId: UUID, + receiptCode: Long, + ) { + val user = queryUserPort.findById(userId) ?: throw UserNotFoundException + val updateUser = user.changeReceiptCode(receiptCode) + saveUserPort.save(updateUser) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/QueryUserByUUIDService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/QueryUserByUUIDService.kt new file mode 100644 index 0000000..9363fba --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/QueryUserByUUIDService.kt @@ -0,0 +1,50 @@ +package hs.kr.entrydsm.user.domain.user.application.service + +import hs.kr.entrydsm.user.domain.user.adapter.out.domain.UserCache +import hs.kr.entrydsm.user.domain.user.adapter.out.persistence.repository.UserCacheRepository +import hs.kr.entrydsm.user.domain.user.application.port.`in`.QueryUserByUUIDUseCase +import hs.kr.entrydsm.user.domain.user.application.port.out.QueryUserPort +import hs.kr.entrydsm.user.domain.user.exception.UserNotFoundException +import hs.kr.entrydsm.user.infrastructure.grpc.server.dto.InternalUserResponse +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +/** + * UUID로 사용자 조회 서비스 클래스입니다. + * 고유 식별자를 통한 사용자 정보 조회 및 캐시 관리를 처리합니다. + * + * @property queryUserPort 사용자 조회 포트 + * @property userCacheRepository 사용자 캐시 저장소 + */ +@Service +class QueryUserByUUIDService( + private val queryUserPort: QueryUserPort, + private val userCacheRepository: UserCacheRepository, +) : QueryUserByUUIDUseCase { + /** + * UUID를 이용하여 사용자 정보를 조회합니다. + * 캐시가 없는 경우 새로 생성하여 저장하고, 내부 시스템용 응답 형태로 변환합니다. + * + * @param userId 조회할 사용자의 UUID + * @return 내부 시스템용 사용자 정보 응답 + * @throws UserNotFoundException 사용자가 존재하지 않는 경우 + */ + @Transactional(readOnly = true) + override fun getUserById(userId: UUID): InternalUserResponse { + val user = queryUserPort.findById(userId) ?: throw UserNotFoundException + + if (!userCacheRepository.existsById(userId)) { + val userCache = UserCache.from(user) + userCacheRepository.save(userCache) + } + return InternalUserResponse( + id = user.id!!, + phoneNumber = user.phoneNumber, + name = user.name, + isParent = user.isParent, + receiptCode = user.receiptCode, + role = user.role, + ) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/QueryUserInfoService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/QueryUserInfoService.kt new file mode 100644 index 0000000..246f351 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/QueryUserInfoService.kt @@ -0,0 +1,37 @@ +package hs.kr.entrydsm.user.domain.user.application.service + +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.response.UserResponse +import hs.kr.entrydsm.user.domain.user.application.port.`in`.QueryUserInfoUseCase +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserFacadeUseCase +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 사용자 정보 조회 서비스 클래스입니다. + * 현재 인증된 사용자의 개인정보를 조회하여 응답 형태로 변환하는 처리를 담당합니다. + * + * @property userFacadeUseCase 사용자 파사드 유스케이스 + */ +@Service +@Transactional(readOnly = true) +class QueryUserInfoService( + private val userFacadeUseCase: UserFacadeUseCase, +) : QueryUserInfoUseCase { + /** + * 현재 로그인한 사용자의 정보를 조회합니다. + * Spring Security 컨텍스트에서 인증된 사용자 정보를 가져와 응답 형태로 변환합니다. + * + * @return 사용자 정보 응답 + */ + override fun getUserInfo(): UserResponse { + val user = userFacadeUseCase.getCurrentUser() + + return user.run { + UserResponse( + name = name, + phoneNumber = phoneNumber, + isParent = isParent, + ) + } + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserCleanUpService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserCleanUpService.kt new file mode 100644 index 0000000..e2cadeb --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserCleanUpService.kt @@ -0,0 +1,32 @@ +package hs.kr.entrydsm.user.domain.user.application.service + +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserCleanUpUseCase +import hs.kr.entrydsm.user.domain.user.application.port.out.DeleteUserPort +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 사용자 데이터 정리 서비스 클래스입니다. + * 일정 기간이 지난 탈퇴 사용자 데이터를 자동으로 삭제하여 개인정보보호법을 준수합니다. + * + * @property deleteUserPort 사용자 삭제 포트 + */ +@Service +class UserCleanUpService( + private val deleteUserPort: DeleteUserPort, +) : UserCleanUpUseCase { + /** + * 탈퇴한 지 7일이 지난 사용자 데이터를 정리합니다. + * 매일 새벽 2시에 자동으로 실행되는 스케줄러입니다. + */ + @Transactional + @Scheduled(cron = "0 0 2 * * *") + override fun cleanupWithdrawnUsers() { + val usersToDelete = deleteUserPort.findWithdrawnUsersOlderThan(7) + + usersToDelete.forEach { user -> + deleteUserPort.deleteById(user.id!!) + } + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserLoginService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserLoginService.kt new file mode 100644 index 0000000..68b7acb --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserLoginService.kt @@ -0,0 +1,71 @@ +package hs.kr.entrydsm.user.domain.user.application.service + +import hs.kr.entrydsm.user.domain.refreshtoken.adapter.out.repository.RefreshTokenRepository +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.UserLoginRequest +import hs.kr.entrydsm.user.domain.user.adapter.out.domain.UserInfo +import hs.kr.entrydsm.user.domain.user.adapter.out.persistence.repository.UserInfoRepository +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserLoginUseCase +import hs.kr.entrydsm.user.domain.user.application.port.out.QueryUserPort +import hs.kr.entrydsm.user.domain.user.exception.PasswordNotValidException +import hs.kr.entrydsm.user.domain.user.exception.UserNotFoundException +import hs.kr.entrydsm.user.global.security.jwt.JwtProperties +import hs.kr.entrydsm.user.global.security.jwt.JwtTokenProvider +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 사용자 로그인 서비스 클래스입니다. + * 사용자 인증 및 JWT 토큰 발급 처리를 담당합니다. + * + * @property jwtTokenProvider JWT 토큰 제공자 + * @property passwordEncoder 비밀번호 암호화 인코더 + * @property queryUserPort 사용자 조회 포트 + * @property jwtProperties JWT 설정 프로퍼티 + * @property userInfoRepository 사용자 정보 저장소 + */ +@Service +class UserLoginService( + private val jwtTokenProvider: JwtTokenProvider, + private val passwordEncoder: PasswordEncoder, + private val queryUserPort: QueryUserPort, + private val jwtProperties: JwtProperties, + private val userInfoRepository: UserInfoRepository, + private val refreshTokenRepository: RefreshTokenRepository, +) : UserLoginUseCase { + /** + * 사용자 로그인을 처리하고 JWT 토큰을 반환합니다. + * 전화번호와 비밀번호를 검증한 후 토큰을 발급하고 사용자 정보를 캐시합니다. + * + * @param request 로그인 요청 정보 + * @return 생성된 JWT 토큰 응답 + * @throws UserNotFoundException 사용자가 존재하지 않거나 비활성화된 경우 + * @throws PasswordNotValidException 비밀번호가 일치하지 않는 경우 + */ + @Transactional + override fun login(request: UserLoginRequest): TokenResponse { + val user = queryUserPort.findByPhoneNumber(request.phoneNumber) ?: throw UserNotFoundException + + if (!user.isActive) { + throw UserNotFoundException + } + + if (!passwordEncoder.matches(request.password, user.password)) { + throw PasswordNotValidException + } + + refreshTokenRepository.deleteById(user.id.toString()) + + val tokenResponse = jwtTokenProvider.generateToken(user.id.toString(), user.role.toString()) + val userInfo = + UserInfo( + token = tokenResponse.accessToken, + userId = jwtTokenProvider.getSubjectWithExpiredCheck(tokenResponse.accessToken), + userRole = jwtTokenProvider.getRole(tokenResponse.accessToken), + ttl = jwtProperties.accessExp, + ) + userInfoRepository.save(userInfo) + return tokenResponse + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserReactivationService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserReactivationService.kt new file mode 100644 index 0000000..5faae1b --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserReactivationService.kt @@ -0,0 +1,45 @@ +package hs.kr.entrydsm.user.domain.user.application.service + +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.ReactivateRequest +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserReactivationUseCase +import hs.kr.entrydsm.user.domain.user.application.port.out.QueryUserPort +import hs.kr.entrydsm.user.domain.user.application.port.out.SaveUserPort +import hs.kr.entrydsm.user.domain.user.exception.UserAlreadyExistsException +import hs.kr.entrydsm.user.domain.user.exception.UserNotFoundException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 사용자 계정 재활성화 서비스 클래스입니다. + * 탈퇴한 사용자의 계정을 다시 활성화하는 처리를 담당합니다. + * + * @property queryUserPort 사용자 조회 포트 + * @property saveUserPort 사용자 저장 포트 + */ +@Service +class UserReactivationService( + private val queryUserPort: QueryUserPort, + private val saveUserPort: SaveUserPort, +) : UserReactivationUseCase { + /** + * 탈퇴한 사용자의 계정을 재활성화합니다. + * 전화번호로 탈퇴한 사용자를 찾아 계정을 다시 활성화합니다. + * + * @param request 재활성화 요청 정보 + * @throws UserNotFoundException 사용자가 존재하지 않는 경우 + * @throws UserAlreadyExistsException 이미 활성화된 계정인 경우 + */ + @Transactional + override fun reactivateWithdrawnUser(request: ReactivateRequest) { + val existingUser = + queryUserPort.findByPhoneNumber(request.phoneNumber) + ?: throw UserNotFoundException + + if (existingUser.isActive) { + throw UserAlreadyExistsException + } + + val reactivatedUser = existingUser.reactivate() + saveUserPort.save(reactivatedUser) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserSignupService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserSignupService.kt new file mode 100644 index 0000000..b75ce52 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserSignupService.kt @@ -0,0 +1,81 @@ +package hs.kr.entrydsm.user.domain.user.application.service + +import hs.kr.entrydsm.user.domain.auth.adapter.out.repository.PassInfoRepository +import hs.kr.entrydsm.user.domain.auth.exception.PassInfoNotFoundException +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.UserSignupRequest +import hs.kr.entrydsm.user.domain.user.adapter.out.domain.UserRole +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserSignupUseCase +import hs.kr.entrydsm.user.domain.user.application.port.out.QueryUserPort +import hs.kr.entrydsm.user.domain.user.application.port.out.SaveUserPort +import hs.kr.entrydsm.user.domain.user.exception.UserAlreadyExistsException +import hs.kr.entrydsm.user.domain.user.model.User +import hs.kr.entrydsm.user.global.security.jwt.JwtTokenProvider +import hs.kr.entrydsm.user.global.utils.encryption.EncryptionUtil +import hs.kr.entrydsm.user.global.utils.encryption.HashUtil +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 사용자 회원가입 서비스 클래스입니다. + * Pass 인증을 통해 검증된 사용자의 회원가입 처리를 담당합니다. + * + * @property saveUserPort 사용자 저장 포트 + * @property queryUserPort 사용자 조회 포트 + * @property passInfoRepository Pass 정보 저장소 + * @property passwordEncoder 비밀번호 암호화 인코더 + * @property tokenProvider JWT 토큰 제공자 + * @property encryptionUtil 암호화 유틸리티 + */ +@Service +class UserSignupService( + private val saveUserPort: SaveUserPort, + private val queryUserPort: QueryUserPort, + private val passInfoRepository: PassInfoRepository, + private val passwordEncoder: PasswordEncoder, + private val tokenProvider: JwtTokenProvider, + private val encryptionUtil: EncryptionUtil, +) : UserSignupUseCase { + /** + * 사용자 회원가입을 처리하고 JWT 토큰을 반환합니다. + * Pass 인증 정보를 기반으로 사용자를 생성하고 토큰을 발급합니다. + * + * @param request 회원가입 요청 정보 + * @return 생성된 JWT 토큰 응답 + * @throws UserAlreadyExistsException 이미 존재하는 사용자인 경우 + * @throws PassInfoNotFoundException Pass 인증 정보를 찾을 수 없는 경우 + */ + @Transactional + override fun signup(request: UserSignupRequest): TokenResponse { + val phoneNumber = request.phoneNumber + val phoneNumberHash = HashUtil.sha256(phoneNumber) + + if (queryUserPort.existsByPhoneNumber(phoneNumber)) { + throw UserAlreadyExistsException + } + + val passInfo = + passInfoRepository.findByPhoneNumber(phoneNumber) + .orElseThrow { PassInfoNotFoundException } + + val user = + User( + id = null, + phoneNumber = encryptionUtil.decrypt(passInfo.phoneNumber), + phoneNumberHash = phoneNumberHash, + password = passwordEncoder.encode(request.password), + name = encryptionUtil.decrypt(passInfo.name), + isParent = request.isParent, + receiptCode = null, + role = UserRole.USER, + ) + + val savedUser = saveUserPort.save(user) + + return tokenProvider.generateToken( + savedUser.id.toString(), + savedUser.role.toString(), + ) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserTokenRefreshService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserTokenRefreshService.kt new file mode 100644 index 0000000..4909f10 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserTokenRefreshService.kt @@ -0,0 +1,48 @@ +package hs.kr.entrydsm.user.domain.user.application.service + +import hs.kr.entrydsm.user.domain.user.adapter.out.domain.UserInfo +import hs.kr.entrydsm.user.domain.user.adapter.out.persistence.repository.UserInfoRepository +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserTokenRefreshUseCase +import hs.kr.entrydsm.user.global.security.jwt.JwtProperties +import hs.kr.entrydsm.user.global.security.jwt.JwtTokenProvider +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 사용자 토큰 갱신 서비스 클래스입니다. + * 만료된 액세스 토큰을 리프레시 토큰으로 갱신하는 처리를 담당합니다. + * + * @property jwtTokenProvider JWT 토큰 제공자 + * @property jwtProperties JWT 설정 프로퍼티 + * @property userInfoRepository 사용자 정보 저장소 + */ +@Transactional +@Service +class UserTokenRefreshService( + private val jwtTokenProvider: JwtTokenProvider, + private val jwtProperties: JwtProperties, + private val userInfoRepository: UserInfoRepository, +) : UserTokenRefreshUseCase { + /** + * 리프레시 토큰을 이용하여 새로운 액세스 토큰을 발급합니다. + * 새로운 토큰 정보를 Redis에 저장하여 인증 상태를 유지합니다. + * + * @param refreshToken 기존 리프레시 토큰 + * @return 새로 발급된 토큰 응답 + */ + override fun refresh(refreshToken: String): TokenResponse { + val tokenResponse = jwtTokenProvider.reIssue(refreshToken) + + val userInfo = + UserInfo( + token = tokenResponse.accessToken, + userId = jwtTokenProvider.getSubjectWithExpiredCheck(tokenResponse.accessToken), + userRole = jwtTokenProvider.getRole(tokenResponse.accessToken), + ttl = jwtProperties.accessExp, + ) + + userInfoRepository.save(userInfo) + return tokenResponse + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserWithdrawalService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserWithdrawalService.kt new file mode 100644 index 0000000..67b62a3 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserWithdrawalService.kt @@ -0,0 +1,59 @@ +package hs.kr.entrydsm.user.domain.user.application.service + +import hs.kr.entrydsm.user.domain.refreshtoken.adapter.out.repository.RefreshTokenRepository +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.WithdrawalRequest +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserFacadeUseCase +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserWithdrawalUseCase +import hs.kr.entrydsm.user.domain.user.application.port.out.DeleteUserPort +import hs.kr.entrydsm.user.domain.user.application.port.out.SaveUserPort +import hs.kr.entrydsm.user.domain.user.exception.PasswordMisMatchException +import hs.kr.entrydsm.user.infrastructure.kafka.producer.DeleteUserProducer +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 사용자 탈퇴 서비스 클래스입니다. + * 비밀번호 확인 후 계정 비활성화 및 관련 데이터 정리를 처리합니다. + * + * @property deleteUserProducer 사용자 삭제 이벤트 발행자 + * @property deleteUserPort 사용자 삭제 포트 + * @property saveUserPort 사용자 저장 포트 + * @property userFacadeUseCase 사용자 파사드 유스케이스 + * @property refreshTokenRepository 리프레시 토큰 저장소 + * @property passwordEncoder 비밀번호 암호화 인코더 + */ +@Service +class UserWithdrawalService( + private val deleteUserProducer: DeleteUserProducer, + private val deleteUserPort: DeleteUserPort, + private val saveUserPort: SaveUserPort, + private val userFacadeUseCase: UserFacadeUseCase, + private val refreshTokenRepository: RefreshTokenRepository, + private val passwordEncoder: PasswordEncoder, +) : UserWithdrawalUseCase { + /** + * 사용자 탈퇴를 처리합니다. + * 비밀번호 확인 후 계정을 비활성화하고 토큰을 삭제하며, 관련 서비스에 알림을 발송합니다. + * + * @param request 탈퇴 요청 정보 + * @throws PasswordMisMatchException 비밀번호가 일치하지 않는 경우 + */ + @Transactional + override fun withdrawal(request: WithdrawalRequest) { + val user = userFacadeUseCase.getCurrentUser() + + if (!passwordEncoder.matches(request.password, user.password)) { + throw PasswordMisMatchException + } + + val withdrawnUser = user.withdraw() + saveUserPort.save(withdrawnUser) + + refreshTokenRepository.deleteById(user.id.toString()) + + user.receiptCode?.let { + deleteUserProducer.send(it) + } + } +} From a1cff73d9738d98907461f9e34c1ec71b5dd4eb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Mon, 28 Jul 2025 13:29:14 +0900 Subject: [PATCH 08/22] =?UTF-8?q?feat=20(=20#9=20)=20:=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EB=B3=80=EA=B2=BD=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=EB=90=9C=20=ED=81=B4=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=EB=93=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entrydsm/user/domain/user/domain/User.kt | 46 ----------- .../user/domain/user/domain/UserCache.kt | 19 ----- .../user/domain/user/domain/UserInfo.kt | 18 ----- .../user/domain/user/domain/UserRole.kt | 7 -- .../domain/repository/UserCacheRepository.kt | 7 -- .../domain/repository/UserInfoRepository.kt | 6 -- .../user/domain/repository/UserRepository.kt | 13 ---- .../user/presentation/SampleController.kt | 20 ----- .../user/presentation/UserController.kt | 78 ------------------- .../dto/request/ChangePasswordRequest.kt | 16 ---- .../dto/request/UserLoginRequest.kt | 16 ---- .../dto/request/UserSignupRequest.kt | 19 ----- .../dto/response/InternalUserResponse.kt | 13 ---- .../presentation/dto/response/UserResponse.kt | 7 -- .../user/service/ChangePasswordService.kt | 27 ------- .../user/service/ChangeReceiptCodeService.kt | 22 ------ .../user/service/QueryUserByUUIDService.kt | 32 -------- .../user/service/QueryUserInfoService.kt | 24 ------ .../domain/user/service/UserLoginService.kt | 42 ---------- .../domain/user/service/UserSignupService.kt | 52 ------------- .../user/service/UserTokenRefreshService.kt | 32 -------- .../user/service/UserWithdrawalService.kt | 26 ------- 22 files changed, 542 deletions(-) delete mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/User.kt delete mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/UserCache.kt delete mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/UserInfo.kt delete mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/UserRole.kt delete mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/repository/UserCacheRepository.kt delete mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/repository/UserInfoRepository.kt delete mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/repository/UserRepository.kt delete mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/SampleController.kt delete mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/UserController.kt delete mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/request/ChangePasswordRequest.kt delete mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/request/UserLoginRequest.kt delete mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/request/UserSignupRequest.kt delete mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/response/InternalUserResponse.kt delete mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/response/UserResponse.kt delete mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/ChangePasswordService.kt delete mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/ChangeReceiptCodeService.kt delete mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/QueryUserByUUIDService.kt delete mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/QueryUserInfoService.kt delete mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserLoginService.kt delete mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserSignupService.kt delete mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserTokenRefreshService.kt delete mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserWithdrawalService.kt diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/User.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/User.kt deleted file mode 100644 index 48ac4fc..0000000 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/User.kt +++ /dev/null @@ -1,46 +0,0 @@ -package hs.kr.entrydsm.user.domain.user.domain - -import hs.kr.entrydsm.user.global.entity.BaseUUIDEntity -import java.util.UUID -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.EnumType -import jakarta.persistence.Enumerated - -@Entity(name = "tbl_user") -class User( - id: UUID?, - @Column(columnDefinition = "char(11)", nullable = false, unique = true) - val phoneNumber: String, - @Column(columnDefinition = "char(60)", nullable = false) - var password: String, - @Column(columnDefinition = "char(5)", nullable = false) - val name: String, - @Column(columnDefinition = "bit(1) default 1", nullable = false) - val isParent: Boolean, - @Column(name = "receipt_code", nullable = true) - var receiptCode: Long?, - @Enumerated(EnumType.STRING) - @Column(name = "role", nullable = false) - val role: UserRole, -) : BaseUUIDEntity(id) { - fun changePassword(password: String) { - this.password = password - } - - fun changeReceiptCode(receiptCode: Long) { - this.receiptCode = receiptCode - } - - fun toUserCache(): UserCache { - return UserCache( - id = id, - phoneNumber = phoneNumber, - name = name, - isParent = isParent, - receiptCode = receiptCode, - role = role, - ttl = 60 * 10, - ) - } -} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/UserCache.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/UserCache.kt deleted file mode 100644 index dc3e53e..0000000 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/UserCache.kt +++ /dev/null @@ -1,19 +0,0 @@ -package hs.kr.entrydsm.user.domain.user.domain - -import org.springframework.data.annotation.Id -import org.springframework.data.redis.core.RedisHash -import org.springframework.data.redis.core.TimeToLive -import java.util.UUID - -@RedisHash(value = "user_cache") -data class UserCache( - @Id - val id: UUID?, - val phoneNumber: String, - val name: String, - val isParent: Boolean, - val receiptCode: Long?, - val role: UserRole, - @TimeToLive - val ttl: Long, -) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/UserInfo.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/UserInfo.kt deleted file mode 100644 index 3856c40..0000000 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/UserInfo.kt +++ /dev/null @@ -1,18 +0,0 @@ -package hs.kr.entrydsm.user.domain.user.domain - -import org.springframework.data.annotation.Id -import org.springframework.data.redis.core.RedisHash -import org.springframework.data.redis.core.TimeToLive -import org.springframework.data.redis.core.index.Indexed - -@RedisHash -class UserInfo( - @Id - val token: String, - @Indexed - val userId: String, - @Indexed - val userRole: String, - @TimeToLive - val ttl: Long, -) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/UserRole.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/UserRole.kt deleted file mode 100644 index 69added..0000000 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/UserRole.kt +++ /dev/null @@ -1,7 +0,0 @@ -package hs.kr.entrydsm.user.domain.user.domain - -enum class UserRole { - ROOT, - USER, - ADMIN, -} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/repository/UserCacheRepository.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/repository/UserCacheRepository.kt deleted file mode 100644 index 616eefc..0000000 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/repository/UserCacheRepository.kt +++ /dev/null @@ -1,7 +0,0 @@ -package hs.kr.entrydsm.user.domain.user.domain.repository - -import hs.kr.entrydsm.user.domain.user.domain.UserCache -import org.springframework.data.repository.CrudRepository -import java.util.UUID - -interface UserCacheRepository : CrudRepository diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/repository/UserInfoRepository.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/repository/UserInfoRepository.kt deleted file mode 100644 index af59bec..0000000 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/repository/UserInfoRepository.kt +++ /dev/null @@ -1,6 +0,0 @@ -package hs.kr.entrydsm.user.domain.user.domain.repository - -import hs.kr.entrydsm.user.domain.user.domain.UserInfo -import org.springframework.data.repository.CrudRepository - -interface UserInfoRepository : CrudRepository diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/repository/UserRepository.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/repository/UserRepository.kt deleted file mode 100644 index 1bcecd3..0000000 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/domain/repository/UserRepository.kt +++ /dev/null @@ -1,13 +0,0 @@ -package hs.kr.entrydsm.user.domain.user.domain.repository - -import hs.kr.entrydsm.user.domain.user.domain.User -import org.springframework.data.jpa.repository.JpaRepository -import java.util.UUID - -interface UserRepository : JpaRepository { - fun findByPhoneNumber(phoneNumber: String): User? - - fun existsByPhoneNumber(phoneNumber: String): Boolean - - fun deleteById(id: UUID?) -} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/SampleController.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/SampleController.kt deleted file mode 100644 index 8b28003..0000000 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/SampleController.kt +++ /dev/null @@ -1,20 +0,0 @@ -package hs.kr.entrydsm.user.domain.user.presentation - -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController - -@RequestMapping("/api/v1/samples") -@RestController -class SampleController { - @GetMapping("{sampleId}") - fun getSampleById( - @PathVariable sampleId: String, - ): SampleResponse = SampleResponse(sampleId, "sample-$sampleId") -} - -data class SampleResponse( - val sampleId: String, - val name: String, -) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/UserController.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/UserController.kt deleted file mode 100644 index 9ca2a82..0000000 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/UserController.kt +++ /dev/null @@ -1,78 +0,0 @@ -package hs.kr.entrydsm.user.domain.user.presentation - -import hs.kr.entrydsm.user.domain.user.presentation.dto.request.ChangePasswordRequest -import hs.kr.entrydsm.user.domain.user.presentation.dto.request.UserLoginRequest -import hs.kr.entrydsm.user.domain.user.presentation.dto.request.UserSignupRequest -import hs.kr.entrydsm.user.domain.user.presentation.dto.response.InternalUserResponse -import hs.kr.entrydsm.user.domain.user.presentation.dto.response.UserResponse -import hs.kr.entrydsm.user.domain.user.service.ChangePasswordService -import hs.kr.entrydsm.user.domain.user.service.QueryUserByUUIDService -import hs.kr.entrydsm.user.domain.user.service.QueryUserInfoService -import hs.kr.entrydsm.user.domain.user.service.UserLoginService -import hs.kr.entrydsm.user.domain.user.service.UserSignupService -import hs.kr.entrydsm.user.domain.user.service.UserTokenRefreshService -import hs.kr.entrydsm.user.domain.user.service.UserWithdrawalService -import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse -import org.springframework.http.HttpStatus -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PatchMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.PutMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestHeader -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.ResponseStatus -import org.springframework.web.bind.annotation.RestController -import java.util.UUID -import jakarta.validation.Valid - -@RequestMapping("/user") -@RestController -class UserController( - private val userSignupService: UserSignupService, - private val userLoginService: UserLoginService, - private val changePasswordService: ChangePasswordService, - private val userTokenRefreshService: UserTokenRefreshService, - private val userWithdrawalService: UserWithdrawalService, - private val queryUserByUUIDService: QueryUserByUUIDService, - private val queryUserInfoService: QueryUserInfoService, -) { - @PostMapping - @ResponseStatus(HttpStatus.CREATED) - fun signup( - @RequestBody @Valid - userSignupRequest: UserSignupRequest, - ): TokenResponse = userSignupService.execute(userSignupRequest) - - @PostMapping("/auth") - fun login( - @RequestBody @Valid - userLoginRequest: UserLoginRequest, - ): TokenResponse = userLoginService.execute(userLoginRequest) - - @PatchMapping("/password") - fun changePassword( - @RequestBody @Valid - changePasswordRequest: ChangePasswordRequest, - ) = changePasswordService.execute(changePasswordRequest) - - @PutMapping("/auth") - fun tokenRefresh( - @RequestHeader("X-Refresh-Token") refreshToken: String, - ): TokenResponse = userTokenRefreshService.execute(refreshToken) - - @DeleteMapping - fun withdrawal() = userWithdrawalService.execute() - - @GetMapping("/{userId}") - fun findUserByUUID( - @PathVariable userId: UUID, - ): InternalUserResponse { - return queryUserByUUIDService.execute(userId) - } - - @GetMapping("/info") - fun getUserInfo(): UserResponse = queryUserInfoService.execute() -} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/request/ChangePasswordRequest.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/request/ChangePasswordRequest.kt deleted file mode 100644 index b13f442..0000000 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/request/ChangePasswordRequest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package hs.kr.entrydsm.user.domain.user.presentation.dto.request - -import jakarta.validation.constraints.NotBlank -import jakarta.validation.constraints.Pattern - -data class ChangePasswordRequest( - @NotBlank(message = "phone_number은 Null 또는 공백 또는 띄어쓰기를 허용하지 않습니다.") - val phoneNumber: String, - @NotBlank(message = "password는 Null 또는 공백 또는 띄어쓰기를 허용하지 않습니다.") - @Pattern( - regexp = - "(?=.*[a-z])(?=.*[0-9])(?=.*[!#$%&'()*+,./:;<=>?@�?_`{|}~])[a-zA-Z0-9!#$%&'()*+,./:;<=>?@�?_`{|}~]{8,32}$", - message = "password는 소문자, 숫자, 특수문자가 포함되어야 합니다.", - ) - val newPassword: String, -) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/request/UserLoginRequest.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/request/UserLoginRequest.kt deleted file mode 100644 index 7186d08..0000000 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/request/UserLoginRequest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package hs.kr.entrydsm.user.domain.user.presentation.dto.request - -import jakarta.validation.constraints.NotBlank -import jakarta.validation.constraints.Pattern - -data class UserLoginRequest( - @NotBlank(message = "phone_number은 Null 또는 공백 또는 띄어쓰기를 허용하지 않습니다.") - val phoneNumber: String, - @NotBlank(message = "password는 Null 또는 공백 또는 띄어쓰기를 허용하지 않습니다.") - @Pattern( - regexp = - "(?=.*[a-z])(?=.*[0-9])(?=.*[!#$%&'()*+,./:;<=>?@�?_`{|}~])[a-zA-Z0-9!#$%&'()*+,./:;<=>?@�?_`{|}~]{8,32}$", - message = "password는 소문자, 숫자, 특수문자가 포함되어야 합니다.", - ) - val password: String, -) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/request/UserSignupRequest.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/request/UserSignupRequest.kt deleted file mode 100644 index 5030974..0000000 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/request/UserSignupRequest.kt +++ /dev/null @@ -1,19 +0,0 @@ -package hs.kr.entrydsm.user.domain.user.presentation.dto.request - -import jakarta.validation.constraints.NotBlank -import jakarta.validation.constraints.NotNull -import jakarta.validation.constraints.Pattern - -data class UserSignupRequest( - @NotBlank(message = "phone_number은 Null 또는 공백 또는 띄어쓰기를 허용하지 않습니다.") - val phoneNumber: String, - @NotBlank(message = "password는 Null 또는 공백 또는 띄어쓰기를 허용하지 않습니다.") - @Pattern( - regexp = - "(?=.*[a-z])(?=.*[0-9])(?=.*[!#$%&'()*+,./:;<=>?@�?_`{|}~])[a-zA-Z0-9!#$%&'()*+,./:;<=>?@�?_`{|}~]{8,32}$", - message = "password는 소문자, 숫자, 특수문자가 포함되어야 합니다.", - ) - val password: String, - @NotNull - val isParent: Boolean, -) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/response/InternalUserResponse.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/response/InternalUserResponse.kt deleted file mode 100644 index 01141e2..0000000 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/response/InternalUserResponse.kt +++ /dev/null @@ -1,13 +0,0 @@ -package hs.kr.entrydsm.user.domain.user.presentation.dto.response - -import hs.kr.entrydsm.user.domain.user.domain.UserRole -import java.util.UUID - -data class InternalUserResponse( - val id: UUID, - val phoneNumber: String, - val name: String, - val isParent: Boolean, - val receiptCode: Long?, - val role: UserRole, -) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/response/UserResponse.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/response/UserResponse.kt deleted file mode 100644 index c49daac..0000000 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/presentation/dto/response/UserResponse.kt +++ /dev/null @@ -1,7 +0,0 @@ -package hs.kr.entrydsm.user.domain.user.presentation.dto.response - -data class UserResponse( - val name: String, - val phoneNumber: String, - val isParent: Boolean, -) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/ChangePasswordService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/ChangePasswordService.kt deleted file mode 100644 index 6ef15a0..0000000 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/ChangePasswordService.kt +++ /dev/null @@ -1,27 +0,0 @@ -package hs.kr.entrydsm.user.domain.user.service - -import hs.kr.entrydsm.user.domain.auth.domain.repository.PassInfoRepository -import hs.kr.entrydsm.user.domain.auth.exception.PassInfoNotFoundException -import hs.kr.entrydsm.user.domain.user.domain.repository.UserRepository -import hs.kr.entrydsm.user.domain.user.exception.UserNotFoundException -import hs.kr.entrydsm.user.domain.user.presentation.dto.request.ChangePasswordRequest -import org.springframework.security.crypto.password.PasswordEncoder -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional - -@Service -class ChangePasswordService( - private val userRepository: UserRepository, - private val passwordEncoder: PasswordEncoder, - private val passInfoRepository: PassInfoRepository, -) { - @Transactional - fun execute(changePasswordRequest: ChangePasswordRequest) { - changePasswordRequest.phoneNumber.takeIf { passInfoRepository.existsByPhoneNumber(it) } - ?.let { phoneNumber -> - userRepository.findByPhoneNumber(phoneNumber) - ?.changePassword(passwordEncoder.encode(changePasswordRequest.newPassword)) - ?: throw UserNotFoundException - } ?: throw PassInfoNotFoundException - } -} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/ChangeReceiptCodeService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/ChangeReceiptCodeService.kt deleted file mode 100644 index 7927178..0000000 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/ChangeReceiptCodeService.kt +++ /dev/null @@ -1,22 +0,0 @@ -package hs.kr.entrydsm.user.domain.user.service - -import hs.kr.entrydsm.user.domain.user.domain.repository.UserRepository -import hs.kr.entrydsm.user.domain.user.exception.UserNotFoundException -import org.springframework.data.repository.findByIdOrNull -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import java.util.UUID - -@Transactional -@Service -class ChangeReceiptCodeService( - private val userRepository: UserRepository, -) { - fun execute( - userId: UUID, - receiptCode: Long, - ) { - val user = userRepository.findByIdOrNull(userId) ?: throw UserNotFoundException - user.changeReceiptCode(receiptCode) - } -} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/QueryUserByUUIDService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/QueryUserByUUIDService.kt deleted file mode 100644 index 8f75f0f..0000000 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/QueryUserByUUIDService.kt +++ /dev/null @@ -1,32 +0,0 @@ -package hs.kr.entrydsm.user.domain.user.service - -import hs.kr.entrydsm.user.domain.user.domain.repository.UserCacheRepository -import hs.kr.entrydsm.user.domain.user.domain.repository.UserRepository -import hs.kr.entrydsm.user.domain.user.exception.UserNotFoundException -import hs.kr.entrydsm.user.domain.user.presentation.dto.response.InternalUserResponse -import org.springframework.data.repository.findByIdOrNull -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import java.util.UUID - -@Service -class QueryUserByUUIDService( - private val userRepository: UserRepository, - private val userCacheRepository: UserCacheRepository, -) { - @Transactional(readOnly = true) - fun execute(userId: UUID): InternalUserResponse { - val user = userRepository.findByIdOrNull(userId) ?: throw UserNotFoundException - if (!userCacheRepository.existsById(userId)) { - userCacheRepository.save(user.toUserCache()) - } - return InternalUserResponse( - id = user.id!!, - phoneNumber = user.phoneNumber, - name = user.name, - isParent = user.isParent, - receiptCode = user.receiptCode, - role = user.role, - ) - } -} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/QueryUserInfoService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/QueryUserInfoService.kt deleted file mode 100644 index 94caf6d..0000000 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/QueryUserInfoService.kt +++ /dev/null @@ -1,24 +0,0 @@ -package hs.kr.entrydsm.user.domain.user.service - -import hs.kr.entrydsm.user.domain.user.facade.UserFacade -import hs.kr.entrydsm.user.domain.user.presentation.dto.response.UserResponse -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional - -@Transactional(readOnly = true) -@Service -class QueryUserInfoService( - private val userFacade: UserFacade, -) { - fun execute(): UserResponse { - val user = userFacade.getCurrentUser() - - return user.run { - UserResponse( - name = name, - phoneNumber = phoneNumber, - isParent = isParent, - ) - } - } -} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserLoginService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserLoginService.kt deleted file mode 100644 index 041cdca..0000000 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserLoginService.kt +++ /dev/null @@ -1,42 +0,0 @@ -package hs.kr.entrydsm.user.domain.user.service - -import hs.kr.entrydsm.user.domain.user.domain.UserInfo -import hs.kr.entrydsm.user.domain.user.domain.repository.UserInfoRepository -import hs.kr.entrydsm.user.domain.user.domain.repository.UserRepository -import hs.kr.entrydsm.user.domain.user.exception.PasswordNotValidException -import hs.kr.entrydsm.user.domain.user.exception.UserNotFoundException -import hs.kr.entrydsm.user.domain.user.presentation.dto.request.UserLoginRequest -import hs.kr.entrydsm.user.global.security.jwt.JwtProperties -import hs.kr.entrydsm.user.global.security.jwt.JwtTokenProvider -import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse -import org.springframework.security.crypto.password.PasswordEncoder -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional - -@Service -class UserLoginService( - private val jwtTokenProvider: JwtTokenProvider, - private val passwordEncoder: PasswordEncoder, - private val userRepository: UserRepository, - private val jwtProperties: JwtProperties, - private val userInfoRepository: UserInfoRepository, -) { - @Transactional - fun execute(userLoginRequest: UserLoginRequest): TokenResponse { - val user = userRepository.findByPhoneNumber(userLoginRequest.phoneNumber) ?: throw UserNotFoundException - - if (!passwordEncoder.matches(userLoginRequest.password, user.password)) { - throw PasswordNotValidException - } - val tokenResponse = jwtTokenProvider.generateToken(user.id.toString(), user.role.toString()) - val userInfo = - UserInfo( - token = tokenResponse.accessToken, - userId = jwtTokenProvider.getSubjectWithExpiredCheck(tokenResponse.accessToken), - userRole = jwtTokenProvider.getRole(tokenResponse.accessToken), - ttl = jwtProperties.accessExp, - ) - userInfoRepository.save(userInfo) - return tokenResponse - } -} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserSignupService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserSignupService.kt deleted file mode 100644 index a782be2..0000000 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserSignupService.kt +++ /dev/null @@ -1,52 +0,0 @@ -package hs.kr.entrydsm.user.domain.user.service - -import hs.kr.entrydsm.user.domain.auth.domain.repository.PassInfoRepository -import hs.kr.entrydsm.user.domain.auth.exception.PassInfoNotFoundException -import hs.kr.entrydsm.user.domain.user.domain.User -import hs.kr.entrydsm.user.domain.user.domain.UserRole -import hs.kr.entrydsm.user.domain.user.domain.repository.UserRepository -import hs.kr.entrydsm.user.domain.user.exception.UserAlreadyExistsException -import hs.kr.entrydsm.user.domain.user.presentation.dto.request.UserSignupRequest -import hs.kr.entrydsm.user.global.security.jwt.JwtTokenProvider -import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse -import org.springframework.security.crypto.password.PasswordEncoder -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional - -@Service -class UserSignupService( - private val userRepository: UserRepository, - private val passInfoRepository: PassInfoRepository, - private val passwordEncoder: PasswordEncoder, - private val tokenProvider: JwtTokenProvider, -) { - @Transactional - fun execute(userSignupRequest: UserSignupRequest): TokenResponse { - val phoneNumber = userSignupRequest.phoneNumber - val password = passwordEncoder.encode(userSignupRequest.password) - - if (userRepository.existsByPhoneNumber(phoneNumber)) { - throw UserAlreadyExistsException - } - - val passInfo = passInfoRepository.findByPhoneNumber(phoneNumber).orElseThrow { PassInfoNotFoundException } - - val user = - User( - id = null, - phoneNumber = passInfo.phoneNumber, - password = password, - name = passInfo.name, - isParent = userSignupRequest.isParent, - receiptCode = null, - role = UserRole.USER, - ) - - userRepository.save(user) - - return tokenProvider.generateToken( - user.id.toString(), - user.role.toString(), - ) - } -} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserTokenRefreshService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserTokenRefreshService.kt deleted file mode 100644 index e084610..0000000 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserTokenRefreshService.kt +++ /dev/null @@ -1,32 +0,0 @@ -package hs.kr.entrydsm.user.domain.user.service - -import hs.kr.entrydsm.user.domain.user.domain.UserInfo -import hs.kr.entrydsm.user.domain.user.domain.repository.UserInfoRepository -import hs.kr.entrydsm.user.global.security.jwt.JwtProperties -import hs.kr.entrydsm.user.global.security.jwt.JwtTokenProvider -import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional - -@Transactional -@Service -class UserTokenRefreshService( - private val jwtTokenProvider: JwtTokenProvider, - private val jwtProperties: JwtProperties, - private val userInfoRepository: UserInfoRepository, -) { - fun execute(refreshToken: String): TokenResponse { - val tokenResponse = jwtTokenProvider.reIssue(refreshToken) - - val userInfo = - UserInfo( - token = tokenResponse.accessToken, - userId = jwtTokenProvider.getSubjectWithExpiredCheck(tokenResponse.accessToken), - userRole = jwtTokenProvider.getRole(tokenResponse.accessToken), - ttl = jwtProperties.accessExp, - ) - - userInfoRepository.save(userInfo) - return tokenResponse - } -} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserWithdrawalService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserWithdrawalService.kt deleted file mode 100644 index bdd84ed..0000000 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/service/UserWithdrawalService.kt +++ /dev/null @@ -1,26 +0,0 @@ -package hs.kr.entrydsm.user.domain.user.service - -import hs.kr.entrydsm.user.domain.refreshtoken.domain.repository.RefreshTokenRepository -import hs.kr.entrydsm.user.domain.user.domain.repository.UserRepository -import hs.kr.entrydsm.user.domain.user.facade.UserFacade -import hs.kr.entrydsm.user.infrastructure.kafka.producer.DeleteUserProducer -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional - -@Service -class UserWithdrawalService( - private val deleteUserProducer: DeleteUserProducer, - private val userFacade: UserFacade, - private val userRepository: UserRepository, - private val refreshTokenRepository: RefreshTokenRepository, -) { - @Transactional - fun execute() { - val user = userFacade.getCurrentUser() - userRepository.deleteById(user.id) - refreshTokenRepository.deleteById(user.id.toString()) - user.receiptCode?.let { - deleteUserProducer.send(it) - } - } -} From 5422873ecb6c0d8509719f28201287dd2a365057 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Mon, 28 Jul 2025 13:29:41 +0900 Subject: [PATCH 09/22] =?UTF-8?q?feat=20(=20#9=20)=20:=20model=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 --- .../entrydsm/user/domain/user/model/User.kt | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/model/User.kt diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/model/User.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/model/User.kt new file mode 100644 index 0000000..bfd6c5c --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/model/User.kt @@ -0,0 +1,80 @@ +package hs.kr.entrydsm.user.domain.user.model + +import hs.kr.entrydsm.user.domain.user.adapter.out.domain.UserRole +import java.time.LocalDateTime +import java.util.UUID + +/** + * 사용자 도메인 모델을 나타내는 데이터 클래스입니다. + * 시스템의 핵심 사용자 정보와 관련된 비즈니스 로직을 포함합니다. + * 불변성을 보장하는 데이터 클래스로 설계되었습니다. + * + * @property id 사용자 고유 식별자 + * @property phoneNumber 사용자 전화번호 + * @property phoneNumberHash 암호화 된 전화번호를 조회하기 위한 Hash 컬럼 + * @property password 암호화된 비밀번호 + * @property name 사용자 이름 + * @property isParent 학부모 여부 + * @property receiptCode 지원서 접수번호 + * @property role 사용자 역할 + * @property isActive 계정 활성화 상태 + * @property withdrawalAt 탈퇴 일시 + */ +data class User( + val id: UUID?, + val phoneNumber: String, + val phoneNumberHash: String, + val password: String, + val name: String, + val isParent: Boolean, + val receiptCode: Long?, + val role: UserRole, + val isActive: Boolean = true, + val withdrawalAt: LocalDateTime? = null, +) { + /** + * 사용자의 비밀번호를 변경합니다. + * + * @param newPassword 새로운 비밀번호 + * @return 비밀번호가 변경된 User 인스턴스 + */ + fun changePassword(newPassword: String): User { + return copy(password = newPassword) + } + + /** + * 사용자의 접수코드를 변경합니다. + * + * @param newReceiptCode 새로운 접수코드 + * @return 접수코드가 변경된 User 인스턴스 + */ + fun changeReceiptCode(newReceiptCode: Long): User { + return copy(receiptCode = newReceiptCode) + } + + /** + * 사용자 계정을 탈퇴 처리합니다. + * 계정을 비활성화하고 탈퇴 일시를 현재 시간으로 설정합니다. + * + * @return 탈퇴 처리된 User 인스턴스 + */ + fun withdraw(): User { + return copy( + isActive = false, + withdrawalAt = LocalDateTime.now(), + ) + } + + /** + * 탈퇴한 계정을 재활성화합니다. + * 계정을 활성화하고 탈퇴 일시를 초기화합니다. + * + * @return 재활성화된 User 인스턴스 + */ + fun reactivate(): User { + return copy( + isActive = true, + withdrawalAt = null, + ) + } +} From 93ccf4bac11821f35c4e3f4d2956c0f908289657 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Mon, 28 Jul 2025 13:29:59 +0900 Subject: [PATCH 10/22] =?UTF-8?q?feat=20(=20#9=20)=20:=20exception=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 --- .../user/exception/PasswordMisMatchException.kt | 12 ++++++++++++ .../user/exception/PasswordNotValidException.kt | 4 ++++ .../user/exception/UserAlreadyExistsException.kt | 4 ++++ .../domain/user/exception/UserNotFoundException.kt | 4 ++++ 4 files changed, 24 insertions(+) create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/PasswordMisMatchException.kt diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/PasswordMisMatchException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/PasswordMisMatchException.kt new file mode 100644 index 0000000..1bd0002 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/PasswordMisMatchException.kt @@ -0,0 +1,12 @@ +package hs.kr.entrydsm.user.domain.user.exception + +import hs.kr.entrydsm.user.global.error.exception.EquusException +import hs.kr.entrydsm.user.global.error.exception.ErrorCode + +/** + * 비밀번호가 일치하지 않을 때 발생하는 예외입니다. + * 사용자가 입력한 비밀번호가 저장된 비밀번호와 다른 경우 사용됩니다. + */ +object PasswordMisMatchException : EquusException( + ErrorCode.PASSWORD_MISS_MATCH, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/PasswordNotValidException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/PasswordNotValidException.kt index ac99637..396ee09 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/PasswordNotValidException.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/PasswordNotValidException.kt @@ -3,6 +3,10 @@ package hs.kr.entrydsm.user.domain.user.exception import hs.kr.entrydsm.user.global.error.exception.EquusException import hs.kr.entrydsm.user.global.error.exception.ErrorCode +/** + * 패스워드가 유효하지 않을 때 발생하는 예외입니다. + * 로그인 시 입력한 비밀번호가 잘못된 경우 사용됩니다. + */ object PasswordNotValidException : EquusException( ErrorCode.INVALID_USER_PASSWORD, ) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/UserAlreadyExistsException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/UserAlreadyExistsException.kt index 47d2268..ee633bf 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/UserAlreadyExistsException.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/UserAlreadyExistsException.kt @@ -3,6 +3,10 @@ package hs.kr.entrydsm.user.domain.user.exception import hs.kr.entrydsm.user.global.error.exception.EquusException import hs.kr.entrydsm.user.global.error.exception.ErrorCode +/** + * 사용자가 이미 존재할 때 발생하는 예외입니다. + * 회원가입 시 이미 등록된 전화번호로 가입을 시도하는 경우 사용됩니다. + */ object UserAlreadyExistsException : EquusException( ErrorCode.USER_ALREADY_EXISTS, ) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/UserNotFoundException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/UserNotFoundException.kt index eef5aea..39c9709 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/UserNotFoundException.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/exception/UserNotFoundException.kt @@ -3,6 +3,10 @@ package hs.kr.entrydsm.user.domain.user.exception import hs.kr.entrydsm.user.global.error.exception.EquusException import hs.kr.entrydsm.user.global.error.exception.ErrorCode +/** + * 사용자를 찾을 수 없을 때 발생하는 예외입니다. + * 요청한 사용자 정보가 시스템에 존재하지 않는 경우 사용됩니다. + */ object UserNotFoundException : EquusException( ErrorCode.USER_NOT_FOUND, ) From cf5583f13c7721e25c694a5149026d69e4885fce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Mon, 28 Jul 2025 13:30:18 +0900 Subject: [PATCH 11/22] =?UTF-8?q?feat=20(=20#9=20)=20:=20facacde=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 --- .../user/domain/user/facade/UserFacade.kt | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/facade/UserFacade.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/facade/UserFacade.kt index 9410fed..73d56e0 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/facade/UserFacade.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/facade/UserFacade.kt @@ -1,26 +1,43 @@ package hs.kr.entrydsm.user.domain.user.facade -import hs.kr.entrydsm.user.domain.user.domain.User -import hs.kr.entrydsm.user.domain.user.domain.repository.UserRepository +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserFacadeUseCase +import hs.kr.entrydsm.user.domain.user.application.port.out.QueryUserPort import hs.kr.entrydsm.user.domain.user.exception.UserNotFoundException -import hs.kr.entrydsm.user.global.exception.InvalidTokenException +import hs.kr.entrydsm.user.domain.user.model.User import org.springframework.security.core.context.SecurityContextHolder import org.springframework.stereotype.Component -import java.lang.IllegalArgumentException import java.util.UUID +/** + * 사용자 관련 공통 기능을 제공하는 파사드 클래스입니다. + * 여러 계층에서 공통으로 사용되는 사용자 조회 기능을 중앙화합니다. + * + * @property queryUserPort 사용자 조회 포트 + */ @Component class UserFacade( - private val userRepository: UserRepository, -) { - fun getCurrentUser(): User { + private val queryUserPort: QueryUserPort, +) : UserFacadeUseCase { + /** + * 현재 인증된 사용자를 조회합니다. + * Spring Security 컨텍스트에서 사용자 ID를 추출하여 사용자 정보를 반환합니다. + * + * @return 현재 인증된 사용자 + * @throws UserNotFoundException 사용자가 존재하지 않는 경우 + */ + override fun getCurrentUser(): User { val userId = SecurityContextHolder.getContext().authentication.name - try { - return userRepository.findById(UUID.fromString(userId)).orElseThrow { UserNotFoundException } - } catch (e: IllegalArgumentException) { - throw InvalidTokenException - } + return queryUserPort.findById(UUID.fromString(userId)) ?: throw UserNotFoundException } - fun getUserByPhoneNumber(phoneNumber: String): User = userRepository.findByPhoneNumber(phoneNumber) ?: throw UserNotFoundException + /** + * 전화번호로 사용자를 조회합니다. + * + * @param phoneNumber 조회할 전화번호 + * @return 조회된 사용자 + * @throws UserNotFoundException 사용자가 존재하지 않는 경우 + */ + override fun getUserByPhoneNumber(phoneNumber: String): User { + return queryUserPort.findByPhoneNumber(phoneNumber) ?: throw UserNotFoundException + } } From 6c80d0315849df8dd28aa85b7a139d85562c1119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Mon, 28 Jul 2025 13:31:37 +0900 Subject: [PATCH 12/22] =?UTF-8?q?feat=20(=20#9=20)=20:=20adapter/in=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 --- .../user/adapter/in/web/SampleController.kt | 29 ++++ .../user/adapter/in/web/UserController.kt | 152 ++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/SampleController.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/UserController.kt diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/SampleController.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/SampleController.kt new file mode 100644 index 0000000..1e4122e --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/SampleController.kt @@ -0,0 +1,29 @@ +package hs.kr.entrydsm.user.domain.user.adapter.`in`.web + +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +/** + * 샘플 API를 제공하는 컨트롤러 클래스입니다. + */ +@RequestMapping("/api/v1/samples") +@RestController +class SampleController { + /** + * 샘플 ID로 샘플 정보를 조회합니다. + */ + @GetMapping("{sampleId}") + fun getSampleById( + @PathVariable sampleId: String, + ): SampleResponse = SampleResponse(sampleId, "sample-$sampleId") +} + +/** + * 샘플 응답 데이터를 담는 클래스입니다. + */ +data class SampleResponse( + val sampleId: String, + val name: String, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/UserController.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/UserController.kt new file mode 100644 index 0000000..c2cefb4 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/UserController.kt @@ -0,0 +1,152 @@ +package hs.kr.entrydsm.user.domain.user.adapter.`in`.web + +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.ChangePasswordRequest +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.ReactivateRequest +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.UserLoginRequest +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.UserSignupRequest +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request.WithdrawalRequest +import hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.response.UserResponse +import hs.kr.entrydsm.user.domain.user.application.port.`in`.ChangePasswordUseCase +import hs.kr.entrydsm.user.domain.user.application.port.`in`.QueryUserByUUIDUseCase +import hs.kr.entrydsm.user.domain.user.application.port.`in`.QueryUserInfoUseCase +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserLoginUseCase +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserReactivationUseCase +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserSignupUseCase +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserTokenRefreshUseCase +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserWithdrawalUseCase +import hs.kr.entrydsm.user.global.document.user.UserApiDocument +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse +import hs.kr.entrydsm.user.infrastructure.grpc.server.dto.InternalUserResponse +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +/** + * 사용자 관련 REST API 컨트롤러 클래스입니다. + * 사용자 회원가입, 로그인, 정보 조회, 탈퇴 등의 HTTP 요청을 처리합니다. + * + * @property userSignupUseCase 사용자 회원가입 유스케이스 + * @property userLoginUseCase 사용자 로그인 유스케이스 + * @property changePasswordUseCase 비밀번호 변경 유스케이스 + * @property userTokenRefreshUseCase 토큰 갱신 유스케이스 + * @property userWithdrawalUseCase 사용자 탈퇴 유스케이스 + * @property queryUserByUUIDUseCase UUID로 사용자 조회 유스케이스 + * @property queryUserInfoUseCase 사용자 정보 조회 유스케이스 + * @property userReactivationUseCase 사용자 재활성화 유스케이스 + */ +@RequestMapping("/user") +@RestController +class UserController( + private val userSignupUseCase: UserSignupUseCase, + private val userLoginUseCase: UserLoginUseCase, + private val changePasswordUseCase: ChangePasswordUseCase, + private val userTokenRefreshUseCase: UserTokenRefreshUseCase, + private val userWithdrawalUseCase: UserWithdrawalUseCase, + private val queryUserByUUIDUseCase: QueryUserByUUIDUseCase, + private val queryUserInfoUseCase: QueryUserInfoUseCase, + private val userReactivationUseCase: UserReactivationUseCase, +) : UserApiDocument { + /** + * 사용자 회원가입을 처리합니다. + * Pass 인증을 통해 검증된 사용자의 회원가입을 진행합니다. + * + * @param userSignupRequest 회원가입 요청 정보 + * @return 생성된 JWT 토큰 응답 + */ + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + override fun signup( + @RequestBody @Valid + userSignupRequest: UserSignupRequest, + ): TokenResponse = userSignupUseCase.signup(userSignupRequest) + + /** + * 사용자 로그인을 처리합니다. + * 전화번호와 비밀번호를 검증하여 JWT 토큰을 발급합니다. + * + * @param userLoginRequest 로그인 요청 정보 + * @return 생성된 JWT 토큰 응답 + */ + @PostMapping("/auth") + override fun login( + @RequestBody @Valid + userLoginRequest: UserLoginRequest, + ): TokenResponse = userLoginUseCase.login(userLoginRequest) + + /** + * 탈퇴한 계정을 재활성화합니다. + * + * @param request 재활성화 요청 정보 + */ + @PostMapping("/reactivate") + fun reactivateAccount( + @RequestBody @Valid request: ReactivateRequest, + ) { + userReactivationUseCase.reactivateWithdrawnUser(request) + } + + /** + * 사용자 비밀번호를 변경합니다. + * Pass 인증을 통한 본인 확인 후 새로운 비밀번호로 변경합니다. + * + * @param changePasswordRequest 비밀번호 변경 요청 정보 + */ + @PatchMapping("/password") + override fun changePassword( + @RequestBody @Valid + changePasswordRequest: ChangePasswordRequest, + ) = changePasswordUseCase.changePassword(changePasswordRequest) + + /** + * 만료된 액세스 토큰을 갱신합니다. + * + * @param refreshToken 리프레시 토큰 + * @return 새로 발급된 JWT 토큰 응답 + */ + @PutMapping("/auth") + override fun tokenRefresh( + @RequestHeader("X-Refresh-Token") refreshToken: String, + ): TokenResponse = userTokenRefreshUseCase.refresh(refreshToken) + + /** + * 사용자 탈퇴를 처리합니다. + * 비밀번호 확인 후 계정을 비활성화합니다. + * + * @param request 탈퇴 요청 정보 + */ + @DeleteMapping + override fun withdrawal( + @RequestBody @Valid request: WithdrawalRequest, + ) = userWithdrawalUseCase.withdrawal(request) + + /** + * UUID로 사용자 정보를 조회합니다. + * 내부 시스템 간 통신에서 사용되는 API입니다. + * + * @param userId 조회할 사용자 UUID + * @return 내부 시스템용 사용자 정보 응답 + */ + @GetMapping("/{userId}") + override fun findUserByUUID( + @PathVariable userId: UUID, + ): InternalUserResponse = queryUserByUUIDUseCase.getUserById(userId) + + /** + * 현재 로그인한 사용자의 정보를 조회합니다. + * + * @return 사용자 정보 응답 + */ + @GetMapping("/info") + override fun getUserInfo(): UserResponse = queryUserInfoUseCase.getUserInfo() +} From 37f182600c3d722f8e0ef0b35a427e9da688b0c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Mon, 28 Jul 2025 13:32:02 +0900 Subject: [PATCH 13/22] =?UTF-8?q?feat=20(=20#9=20)=20:=20adapter/in=20dto?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/dto/request/ChangePasswordRequest.kt | 19 ++++++++++++++++ .../in/web/dto/request/ReactivateRequest.kt | 8 +++++++ .../in/web/dto/request/UserLoginRequest.kt | 19 ++++++++++++++++ .../in/web/dto/request/UserSignupRequest.kt | 22 +++++++++++++++++++ .../in/web/dto/request/WIthdrawalRequest.kt | 8 +++++++ .../in/web/dto/response/UserResponse.kt | 10 +++++++++ 6 files changed, 86 insertions(+) create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/ChangePasswordRequest.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/ReactivateRequest.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/UserLoginRequest.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/UserSignupRequest.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/WIthdrawalRequest.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/response/UserResponse.kt diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/ChangePasswordRequest.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/ChangePasswordRequest.kt new file mode 100644 index 0000000..f42e28a --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/ChangePasswordRequest.kt @@ -0,0 +1,19 @@ +package hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Pattern + +/** + * 사용자 비밀번호 변경 요청 데이터를 담는 DTO 클래스입니다. + */ +data class ChangePasswordRequest( + @NotBlank(message = "phone_number은 Null 또는 공백 또는 띄어쓰기를 허용하지 않습니다.") + val phoneNumber: String, + @NotBlank(message = "password는 Null 또는 공백 또는 띄어쓰기를 허용하지 않습니다.") + @Pattern( + regexp = + "(?=.*[a-z])(?=.*[0-9])(?=.*[!#$%&'()*+,./:;<=>?@�?_`{|}~])[a-zA-Z0-9!#$%&'()*+,./:;<=>?@�?_`{|}~]{8,32}$", + message = "password는 소문자, 숫자, 특수문자가 포함되어야 합니다.", + ) + val newPassword: String, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/ReactivateRequest.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/ReactivateRequest.kt new file mode 100644 index 0000000..3a3dc5a --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/ReactivateRequest.kt @@ -0,0 +1,8 @@ +package hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request + +import jakarta.validation.constraints.NotBlank + +data class ReactivateRequest( + @NotBlank + val phoneNumber: String, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/UserLoginRequest.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/UserLoginRequest.kt new file mode 100644 index 0000000..8d7acec --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/UserLoginRequest.kt @@ -0,0 +1,19 @@ +package hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Pattern + +/** + * 사용자 로그인 요청 데이터를 담는 DTO 클래스입니다. + */ +data class UserLoginRequest( + @NotBlank(message = "phone_number은 Null 또는 공백 또는 띄어쓰기를 허용하지 않습니다.") + val phoneNumber: String, + @NotBlank(message = "password는 Null 또는 공백 또는 띄어쓰기를 허용하지 않습니다.") + @Pattern( + regexp = + "(?=.*[a-z])(?=.*[0-9])(?=.*[!#$%&'()*+,./:;<=>?@�?_`{|}~])[a-zA-Z0-9!#$%&'()*+,./:;<=>?@�?_`{|}~]{8,32}$", + message = "password는 소문자, 숫자, 특수문자가 포함되어야 합니다.", + ) + val password: String, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/UserSignupRequest.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/UserSignupRequest.kt new file mode 100644 index 0000000..a386f12 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/UserSignupRequest.kt @@ -0,0 +1,22 @@ +package hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern + +/** + * 사용자 회원가입 요청 데이터를 담는 DTO 클래스입니다. + */ +data class UserSignupRequest( + @NotBlank(message = "phone_number은 Null 또는 공백 또는 띄어쓰기를 허용하지 않습니다.") + val phoneNumber: String, + @NotBlank(message = "password는 Null 또는 공백 또는 띄어쓰기를 허용하지 않습니다.") + @Pattern( + regexp = + "(?=.*[a-z])(?=.*[0-9])(?=.*[!#$%&'()*+,./:;<=>?@�?_`{|}~])[a-zA-Z0-9!#$%&'()*+,./:;<=>?@�?_`{|}~]{8,32}$", + message = "password는 소문자, 숫자, 특수문자가 포함되어야 합니다.", + ) + val password: String, + @NotNull + val isParent: Boolean, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/WIthdrawalRequest.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/WIthdrawalRequest.kt new file mode 100644 index 0000000..9fa7036 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/request/WIthdrawalRequest.kt @@ -0,0 +1,8 @@ +package hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.request + +import jakarta.validation.constraints.NotBlank + +data class WithdrawalRequest( + @NotBlank + val password: String, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/response/UserResponse.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/response/UserResponse.kt new file mode 100644 index 0000000..90b3666 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/dto/response/UserResponse.kt @@ -0,0 +1,10 @@ +package hs.kr.entrydsm.user.domain.user.adapter.`in`.web.dto.response + +/** + * 사용자 정보 응답 데이터를 담는 DTO 클래스입니다. + */ +data class UserResponse( + val name: String, + val phoneNumber: String, + val isParent: Boolean, +) From 6780a0ec4022ad0baa7a9d7393a9d7588a01cf61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Mon, 28 Jul 2025 13:34:18 +0900 Subject: [PATCH 14/22] =?UTF-8?q?feat=20(=20#9=20)=20:=20adapter/out?= =?UTF-8?q?=EC=97=90=20JpaEntity=20&&=20domain=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/adapter/out/UserJpaEntity.kt | 52 +++++++++++++++++++ .../user/adapter/out/domain/UserCache.kt | 52 +++++++++++++++++++ .../user/adapter/out/domain/UserInfo.kt | 27 ++++++++++ .../user/adapter/out/domain/UserRole.kt | 16 ++++++ 4 files changed, 147 insertions(+) create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/UserJpaEntity.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/domain/UserCache.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/domain/UserInfo.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/domain/UserRole.kt diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/UserJpaEntity.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/UserJpaEntity.kt new file mode 100644 index 0000000..29cd743 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/UserJpaEntity.kt @@ -0,0 +1,52 @@ +package hs.kr.entrydsm.user.domain.user.adapter.out + +import hs.kr.entrydsm.user.domain.user.adapter.out.domain.UserRole +import hs.kr.entrydsm.user.global.converter.EncryptedStringConverter +import hs.kr.entrydsm.user.global.base.BaseUUIDEntity +import jakarta.persistence.Column +import jakarta.persistence.Convert +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import java.time.LocalDateTime +import java.util.UUID + +/** + * 사용자 정보를 데이터베이스에 저장하기 위한 JPA 엔티티 클래스입니다. + * 데이터베이스의 tbl_user 테이블과 매핑되며, 민감한 정보는 암호화하여 저장합니다. + * + * @property phoneNumber 암호화된 전화번호 + * @property phoneNumberHash 조회를 위한 해시화된 전화번호 + * @property password 해시화된 비밀번호 + * @property name 암호화된 사용자 이름 + * @property isParent 학부모 여부 + * @property receiptCode 지원서 접수번호 + * @property role 사용자 역할 + * @property isActive 계정 활성화 상태 + * @property withdrawalAt 탈퇴 일시 + */ +@Entity(name = "tbl_user") +class UserJpaEntity( + id: UUID?, + @Column(columnDefinition = "char(11)", nullable = false, unique = true) + @Convert(converter = EncryptedStringConverter::class) + val phoneNumber: String, + @Column(name = "phone_number_hash", columnDefinition = "varchar(64)", nullable = false, unique = true) + val phoneNumberHash: String, + @Column(columnDefinition = "char(60)", nullable = false) + var password: String, + @Column(columnDefinition = "char(5)", nullable = false) + @Convert(converter = EncryptedStringConverter::class) + val name: String, + @Column(columnDefinition = "bit(1) default 1", nullable = false) + val isParent: Boolean, + @Column(name = "receipt_code", nullable = true) + var receiptCode: Long?, + @Enumerated(EnumType.STRING) + @Column(name = "role", nullable = false) + val role: UserRole, + @Column(name = "is_active", columnDefinition = "bit(1) default 1", nullable = false) + var isActive: Boolean = true, + @Column(name = "withdrawal_at", nullable = true) + var withdrawalAt: LocalDateTime? = null, +) : BaseUUIDEntity(id) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/domain/UserCache.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/domain/UserCache.kt new file mode 100644 index 0000000..51fe62c --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/domain/UserCache.kt @@ -0,0 +1,52 @@ +package hs.kr.entrydsm.user.domain.user.adapter.out.domain + +import hs.kr.entrydsm.user.domain.user.model.User +import org.springframework.data.annotation.Id +import org.springframework.data.redis.core.RedisHash +import org.springframework.data.redis.core.TimeToLive +import java.util.UUID + +/** + * Redis에 저장되는 사용자 캐시 데이터를 나타내는 클래스입니다. + * 사용자 조회 성능 향상을 위한 캐시 데이터를 관리합니다. + * + * @property id 사용자 고유 식별자 (Redis 키로 사용) + * @property phoneNumber 전화번호 + * @property name 사용자 이름 + * @property isParent 학부모 여부 + * @property receiptCode 지원서 접수번호 + * @property role 사용자 역할 + * @property ttl Time To Live (캐시 만료 시간, 초 단위) + */ +@RedisHash(value = "user_cache") +data class UserCache( + @Id + val id: UUID?, + val phoneNumber: String, + val name: String, + val isParent: Boolean, + val receiptCode: Long?, + val role: UserRole, + @TimeToLive + val ttl: Long, +) { + companion object { + /** + * User 도메인 모델로부터 UserCache 인스턴스를 생성합니다. + * + * @param user 도메인 모델 User 인스턴스 + * @return UserCache 인스턴스 (TTL 10분으로 설정) + */ + fun from(user: User): UserCache { + return UserCache( + id = user.id, + phoneNumber = user.phoneNumber, + name = user.name, + isParent = user.isParent, + receiptCode = user.receiptCode, + role = user.role, + ttl = 60 * 10, + ) + } + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/domain/UserInfo.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/domain/UserInfo.kt new file mode 100644 index 0000000..784429e --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/domain/UserInfo.kt @@ -0,0 +1,27 @@ +package hs.kr.entrydsm.user.domain.user.adapter.out.domain + +import org.springframework.data.annotation.Id +import org.springframework.data.redis.core.RedisHash +import org.springframework.data.redis.core.TimeToLive +import org.springframework.data.redis.core.index.Indexed + +/** + * Redis에 저장되는 사용자 인증 정보를 나타내는 클래스입니다. + * JWT 토큰과 관련된 사용자 정보를 캐시하여 인증 성능을 향상시킵니다. + * + * @property token JWT 토큰 (Redis 키로 사용) + * @property userId 사용자 식별자 + * @property userRole 사용자 역할 + * @property ttl Time To Live (토큰 만료 시간, 초 단위) + */ +@RedisHash +class UserInfo( + @Id + val token: String, + @Indexed + val userId: String, + @Indexed + val userRole: String, + @TimeToLive + val ttl: Long, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/domain/UserRole.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/domain/UserRole.kt new file mode 100644 index 0000000..83f60ab --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/domain/UserRole.kt @@ -0,0 +1,16 @@ +package hs.kr.entrydsm.user.domain.user.adapter.out.domain + +/** + * 사용자 권한을 정의하는 열거형 클래스입니다. + * 시스템 내에서 사용자의 접근 권한을 구분하는 데 사용됩니다. + */ +enum class UserRole { + /** 최고 관리자 권한 */ + ROOT, + + /** 일반 사용자 권한 */ + USER, + + /** 관리자 권한 */ + ADMIN, +} From 4188b09d9f6d5a4b5d150c9cbe8d9b3223a9af38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Mon, 28 Jul 2025 13:34:39 +0900 Subject: [PATCH 15/22] =?UTF-8?q?feat=20(=20#9=20)=20:=20mapper=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 --- .../user/adapter/out/mapper/UserMapper.kt | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/mapper/UserMapper.kt diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/mapper/UserMapper.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/mapper/UserMapper.kt new file mode 100644 index 0000000..60f77d1 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/mapper/UserMapper.kt @@ -0,0 +1,22 @@ +package hs.kr.entrydsm.user.domain.user.adapter.out.mapper + +import hs.kr.entrydsm.user.domain.user.adapter.out.UserJpaEntity +import hs.kr.entrydsm.user.domain.user.model.User +import hs.kr.entrydsm.user.global.mapper.GenericMapper +import org.mapstruct.Mapper +import org.mapstruct.Mapping + +/** + * User 도메인 모델과 UserJpaEntity 간의 변환을 담당하는 매퍼 클래스입니다. + * MapStruct를 사용하여 도메인 계층과 인프라스트럭처 계층 간의 데이터 변환을 처리합니다. + */ +@Mapper +abstract class UserMapper : GenericMapper { + + @Mapping(target = "changePassword", ignore = true) + @Mapping(target = "changeReceiptCode", ignore = true) + abstract override fun toEntity(model: User): UserJpaEntity + + abstract override fun toModel(entity: UserJpaEntity?): User? + abstract override fun toModelNotNull(entity: UserJpaEntity): User +} From b07bc0cf6b721d7e377a01370b7880faca8decb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Mon, 28 Jul 2025 13:34:58 +0900 Subject: [PATCH 16/22] =?UTF-8?q?feat=20(=20#9=20)=20:=20persistence=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../out/persistence/UserPersistenceAdapter.kt | 95 +++++++++++++++++++ .../repository/UserCacheRepository.kt | 11 +++ .../repository/UserInfoRepository.kt | 10 ++ .../persistence/repository/UserRepository.kt | 47 +++++++++ 4 files changed, 163 insertions(+) create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/UserPersistenceAdapter.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/repository/UserCacheRepository.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/repository/UserInfoRepository.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/repository/UserRepository.kt diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/UserPersistenceAdapter.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/UserPersistenceAdapter.kt new file mode 100644 index 0000000..c3f4f06 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/UserPersistenceAdapter.kt @@ -0,0 +1,95 @@ +package hs.kr.entrydsm.user.domain.user.adapter.out.persistence + +import hs.kr.entrydsm.user.domain.user.adapter.out.mapper.UserMapper +import hs.kr.entrydsm.user.domain.user.adapter.out.persistence.repository.UserRepository +import hs.kr.entrydsm.user.domain.user.application.port.out.UserPort +import hs.kr.entrydsm.user.domain.user.model.User +import hs.kr.entrydsm.user.global.utils.encryption.EncryptionUtil +import hs.kr.entrydsm.user.global.utils.encryption.HashUtil +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Component +import java.time.LocalDateTime +import java.util.UUID + +/** + * 사용자 데이터의 영속성 처리를 담당하는 어댑터 클래스입니다. + * 헥사고날 아키텍처의 Driven Adapter 역할을 하며, 도메인 계층의 UserPort를 구현합니다. + * + * @property userRepository JPA 기반 사용자 데이터 저장소 + * @property userMapper 도메인 모델과 JPA 엔티티 간 변환 매퍼 + * @property encryptionUtil 민감한 데이터 암호화 유틸리티 + */ +@Component +class UserPersistenceAdapter( + private val userRepository: UserRepository, + private val userMapper: UserMapper, + private val encryptionUtil: EncryptionUtil, +) : UserPort { + /** + * ID로 사용자를 조회합니다. + * + * @param id 조회할 사용자 ID + * @return 조회된 사용자 도메인 모델 (없으면 null) + */ + override fun findById(id: UUID): User? { + return userRepository.findByIdOrNull(id) + ?.let { userMapper.toModel(it) } + } + + /** + * 전화번호로 사용자를 조회합니다. + * 전화번호를 암호화하여 데이터베이스에서 조회합니다. + * + * @param phoneNumber 조회할 전화번호 + * @return 조회된 사용자 도메인 모델 (없으면 null) + */ + override fun findByPhoneNumber(phoneNumber: String): User? { + val phoneNumberHash = HashUtil.sha256(phoneNumber) + return userRepository.findByPhoneNumberHash(phoneNumberHash) + ?.let { userMapper.toModel(it) } + } + + /** + * 전화번호로 사용자 존재 여부를 확인합니다. + * + * @param phoneNumber 확인할 전화번호 + * @return 사용자 존재 여부 + */ + override fun existsByPhoneNumber(phoneNumber: String): Boolean { + val phoneNumberHash = HashUtil.sha256(phoneNumber) + return userRepository.existsByPhoneNumberHash(phoneNumberHash) + } + + /** + * 사용자 정보를 저장합니다. + * + * @param user 저장할 사용자 도메인 모델 + * @return 저장된 사용자 도메인 모델 + */ + override fun save(user: User): User { + val entity = userMapper.toEntity(user) + val savedEntity = userRepository.save(entity) + return userMapper.toModelNotNull(savedEntity) + } + + /** + * ID로 사용자를 삭제합니다. + * + * @param userId 삭제할 사용자 ID + */ + override fun deleteById(userId: UUID) { + userRepository.deleteById(userId) + } + + /** + * 지정된 일수보다 오래된 탈퇴 사용자를 조회합니다. + * + * @param days 기준 일수 + * @return 삭제 대상 사용자 목록 + */ + override fun findWithdrawnUsersOlderThan(days: Long): List { + val cutoffDate = LocalDateTime.now().minusDays(days) + return userRepository.findAllByIsActiveFalseAndWithdrawalAtBefore(cutoffDate) + .map { userMapper.toModelNotNull(it) } + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/repository/UserCacheRepository.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/repository/UserCacheRepository.kt new file mode 100644 index 0000000..10f8bc8 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/repository/UserCacheRepository.kt @@ -0,0 +1,11 @@ +package hs.kr.entrydsm.user.domain.user.adapter.out.persistence.repository + +import hs.kr.entrydsm.user.domain.user.adapter.out.domain.UserCache +import org.springframework.data.repository.CrudRepository +import java.util.UUID + +/** + * 사용자 캐시를 위한 Redis 저장소 인터페이스입니다. + * Spring Data Redis를 통해 사용자 캐시 데이터의 관리를 담당합니다. + */ +interface UserCacheRepository : CrudRepository diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/repository/UserInfoRepository.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/repository/UserInfoRepository.kt new file mode 100644 index 0000000..3bdc5d4 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/repository/UserInfoRepository.kt @@ -0,0 +1,10 @@ +package hs.kr.entrydsm.user.domain.user.adapter.out.persistence.repository + +import hs.kr.entrydsm.user.domain.user.adapter.out.domain.UserInfo +import org.springframework.data.repository.CrudRepository + +/** + * 사용자 인증 정보를 위한 Redis 저장소 인터페이스입니다. + * Spring Data Redis를 통해 사용자 인증 캐시 데이터를 관리합니다. + */ +interface UserInfoRepository : CrudRepository diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/repository/UserRepository.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/repository/UserRepository.kt new file mode 100644 index 0000000..a179363 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/persistence/repository/UserRepository.kt @@ -0,0 +1,47 @@ +package hs.kr.entrydsm.user.domain.user.adapter.out.persistence.repository + +import hs.kr.entrydsm.user.domain.user.adapter.out.UserJpaEntity +import org.springframework.data.jpa.repository.JpaRepository +import java.time.LocalDateTime +import java.util.UUID + +/** + * 사용자 JPA 엔티티를 위한 저장소 인터페이스입니다. + * Spring Data JPA를 통해 기본 CRUD 작업과 커스텀 쿼리를 제공합니다. + */ +interface UserRepository : JpaRepository { + + + + + /** + * 전화번호로 사용자를 조회합니다. + * + * @param phoneNumberHash 조회할 전화번호 + * @return 조회된 사용자 엔티티 (없으면 null) + */ + fun findByPhoneNumberHash(phoneNumberHash: String): UserJpaEntity? + + /** + * 전화번호로 사용자 존재 여부를 확인합니다. + * + * @param phoneNumberHash 확인할 전화번호 + * @return 사용자 존재 여부 + */ + fun existsByPhoneNumberHash(phoneNumberHash: String): Boolean + + /** + * ID로 사용자를 삭제합니다. + * + * @param id 삭제할 사용자 ID + */ + fun deleteById(id: UUID?) + + /** + * 지정된 일시 이전에 탈퇴한 비활성 사용자들을 조회합니다. + * + * @param cutoffDate 기준 일시 + * @return 삭제 대상 사용자 엔티티 목록 + */ + fun findAllByIsActiveFalseAndWithdrawalAtBefore(cutoffDate: LocalDateTime): List +} From e6d6a1a3471cdc4fb3a4179d593320cf3520028c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Mon, 28 Jul 2025 14:52:34 +0900 Subject: [PATCH 17/22] =?UTF-8?q?fix=20(=20#18=20)=20:=20conflict=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- buildSrc/src/main/kotlin/Dependencies.kt | 10 ++ buildSrc/src/main/kotlin/DependencyVersion.kt | 5 + casper-user/build.gradle.kts | 23 ++-- .../in/web/dto/request/AdminLoginRequest.kt | 13 ++ .../application/service/AdminLoginService.kt | 52 +++++++ .../admin/exception/AdminNotFoundException.kt | 3 + .../exception/AdminUnauthorizedException.kt | 3 + .../user/domain/admin/facade/AdminFacade.kt | 34 +++-- .../service/QueryPassInfoService.kt | 59 ++++++++ .../InvalidOkCertConnectException.kt | 3 + .../auth/exception/InvalidPassException.kt | 4 + .../auth/exception/InvalidUrlException.kt | 3 + .../exception/PassInfoNotFoundException.kt | 4 + .../dto/request/PassPopupRequest.kt | 8 -- .../domain/auth/service/PassPopupService.kt | 106 --------------- .../user/global/config/LicenseConfig.kt | 12 ++ .../user/global/config/RedisConfig.kt | 27 +++- .../config/StaticRoutingConfiguration.kt | 8 ++ .../user/global/error/ErrorResponse.kt | 7 + .../global/error/GlobalExceptionFilter.kt | 27 +++- .../global/error/GlobalExceptionHandler.kt | 16 +++ .../global/error/exception/EquusException.kt | 6 + .../user/global/error/exception/ErrorCode.kt | 12 +- .../global/exception/ExpiredTokenException.kt | 4 + .../exception/InternalServerErrorException.kt | 4 + .../global/exception/InvalidTokenException.kt | 4 + .../user/global/security/FilterConfig.kt | 13 +- .../user/global/security/SecurityConfig.kt | 24 +++- .../security/auth/AdminDetailsService.kt | 14 +- .../user/global/security/auth/AuthDetails.kt | 11 ++ .../security/auth/AuthDetailsService.kt | 14 +- .../global/security/jwt/JwtTokenProvider.kt | 127 ++++++++++++++++-- .../user/global/utils/pass/PassUtil.kt | 30 ++++- .../global/utils/pass/RedirectUrlChecker.kt | 13 +- .../global/utils/token/dto/TokenResponse.kt | 7 + casper-user/src/main/proto/user.proto | 2 +- .../src/main/resources/application.properties | 1 - .../src/main/resources/application.yml | 8 ++ 38 files changed, 545 insertions(+), 176 deletions(-) create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/adapter/in/web/dto/request/AdminLoginRequest.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/application/service/AdminLoginService.kt create mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/application/service/QueryPassInfoService.kt delete mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/presentation/dto/request/PassPopupRequest.kt delete mode 100644 casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/service/PassPopupService.kt delete mode 100644 casper-user/src/main/resources/application.properties create mode 100644 casper-user/src/main/resources/application.yml diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index ac8c1e0..f96af89 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -38,4 +38,14 @@ object Dependencies { const val GRPC_KOTLIN_STUB = "io.grpc:grpc-kotlin-stub:${DependencyVersion.GRPC_KOTLIN}" const val PROTOBUF_KOTLIN = "com.google.protobuf:protobuf-kotlin:${DependencyVersion.PROTOBUF}" const val GRPC_TESTING = "io.grpc:grpc-testing:${DependencyVersion.GRPC}" + const val GRPC_SERVER_SPRING_BOOT_STARTER = "net.devh:grpc-server-spring-boot-starter:${DependencyVersion.GRPC_SPRING_BOOT_STARTER}" + + //OkCert + const val OKCERT_PATH = "src/main/webapp/WEB-INF/lib/OkCert3-java1.5-2.3.1.jar" + + //swagger + const val SWAGGER = "org.springdoc:springdoc-openapi-starter-webmvc-ui:${DependencyVersion.SWAGGER}" + + // Sentry + const val SENTRY_SPRING_BOOT_STARTER = "io.sentry:sentry-spring-boot-starter-jakarta:${DependencyVersion.SENTRY}" } \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/DependencyVersion.kt b/buildSrc/src/main/kotlin/DependencyVersion.kt index 83cce0a..e721bbc 100644 --- a/buildSrc/src/main/kotlin/DependencyVersion.kt +++ b/buildSrc/src/main/kotlin/DependencyVersion.kt @@ -12,4 +12,9 @@ object DependencyVersion { const val GRPC = "1.61.1" const val GRPC_KOTLIN = "1.4.1" const val PROTOBUF = "3.25.3" + const val GRPC_SPRING_BOOT_STARTER = "3.1.0.RELEASE" + + const val SWAGGER = "2.7.0" + + const val SENTRY = "7.14.0" } \ No newline at end of file diff --git a/casper-user/build.gradle.kts b/casper-user/build.gradle.kts index 7c683b8..fff5b3d 100644 --- a/casper-user/build.gradle.kts +++ b/casper-user/build.gradle.kts @@ -11,10 +11,6 @@ plugins { id(Plugin.PROTOBUF) version PluginVersion.PROTOBUF_VERSION } -repositories { - mavenCentral() -} - dependencies { // 스프링 부트 기본 기능 implementation(Dependencies.SPRING_BOOT_STARTER) @@ -57,15 +53,24 @@ dependencies { implementation(Dependencies.MAPSTRUCT) kapt(Dependencies.MAPSTRUCT_PROCESSOR) - //grpc + // grpc implementation(Dependencies.GRPC_NETTY_SHADED) implementation(Dependencies.GRPC_PROTOBUF) implementation(Dependencies.GRPC_STUB) implementation(Dependencies.GRPC_KOTLIN_STUB) implementation(Dependencies.PROTOBUF_KOTLIN) + implementation(Dependencies.GRPC_SERVER_SPRING_BOOT_STARTER) testImplementation(Dependencies.GRPC_TESTING) -} + // OkCert + implementation(files("$projectDir/${Dependencies.OKCERT_PATH}")) + + // swagger + implementation(Dependencies.SWAGGER) + + // Sentry + implementation(Dependencies.SENTRY_SPRING_BOOT_STARTER) +} protobuf { protoc { @@ -86,9 +91,11 @@ protobuf { create("grpckt") } } - + } } +repositories { + mavenCentral() } kotlin { @@ -103,4 +110,4 @@ tasks.withType { tasks.withType { useJUnitPlatform() -} \ No newline at end of file +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/adapter/in/web/dto/request/AdminLoginRequest.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/adapter/in/web/dto/request/AdminLoginRequest.kt new file mode 100644 index 0000000..3c567bd --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/adapter/in/web/dto/request/AdminLoginRequest.kt @@ -0,0 +1,13 @@ +package hs.kr.entrydsm.user.domain.admin.adapter.`in`.web.dto.request + +import jakarta.validation.constraints.NotBlank + +/** + * 관리자 로그인 요청 데이터를 담는 DTO 클래스입니다. + */ +data class AdminLoginRequest( + @NotBlank + val adminId: String, + @NotBlank + val password: String, +) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/application/service/AdminLoginService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/application/service/AdminLoginService.kt new file mode 100644 index 0000000..803d737 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/application/service/AdminLoginService.kt @@ -0,0 +1,52 @@ +package hs.kr.entrydsm.user.domain.admin.application.service + +import hs.kr.entrydsm.user.domain.admin.adapter.`in`.web.dto.request.AdminLoginRequest +import hs.kr.entrydsm.user.domain.admin.application.port.`in`.AdminLoginUseCase +import hs.kr.entrydsm.user.domain.admin.application.port.out.QueryAdminPort +import hs.kr.entrydsm.user.domain.admin.application.port.out.SaveAdminPort +import hs.kr.entrydsm.user.domain.admin.exception.AdminNotFoundException +import hs.kr.entrydsm.user.domain.user.adapter.out.domain.UserInfo +import hs.kr.entrydsm.user.domain.user.adapter.out.domain.UserRole +import hs.kr.entrydsm.user.domain.user.adapter.out.persistence.repository.UserInfoRepository +import hs.kr.entrydsm.user.domain.user.exception.PasswordNotValidException +import hs.kr.entrydsm.user.global.security.jwt.JwtProperties +import hs.kr.entrydsm.user.global.security.jwt.JwtTokenProvider +import hs.kr.entrydsm.user.global.utils.token.dto.TokenResponse +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 관리자 로그인 비즈니스 로직을 처리하는 서비스 클래스입니다. + */ +@Service +class AdminLoginService( + private val passwordEncoder: PasswordEncoder, + private val jwtTokenProvider: JwtTokenProvider, + private val userInfoRepository: UserInfoRepository, + private val jwtProperties: JwtProperties, + private val adminQueryAdminPort: QueryAdminPort, + private val adminSaveAdminPort: SaveAdminPort, +) : AdminLoginUseCase { + /** + * 관리자 로그인을 처리하고 JWT 토큰을 반환합니다. + */ + @Transactional + override fun login(adminLoginRequest: AdminLoginRequest): TokenResponse { + val admin = adminQueryAdminPort.findByAdminId(adminLoginRequest.adminId) ?: throw AdminNotFoundException + + if (!passwordEncoder.matches(adminLoginRequest.password, admin.password)) { + throw PasswordNotValidException + } + val tokenResponse = jwtTokenProvider.generateToken(admin.id.toString(), UserRole.ADMIN.toString()) + val userInfo = + UserInfo( + token = tokenResponse.accessToken, + userId = jwtTokenProvider.getSubjectWithExpiredCheck(tokenResponse.accessToken), + userRole = jwtTokenProvider.getRole(tokenResponse.accessToken), + ttl = jwtProperties.accessExp, + ) + userInfoRepository.save(userInfo) + return tokenResponse + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/exception/AdminNotFoundException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/exception/AdminNotFoundException.kt index 54da109..439cd13 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/exception/AdminNotFoundException.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/exception/AdminNotFoundException.kt @@ -3,6 +3,9 @@ package hs.kr.entrydsm.user.domain.admin.exception import hs.kr.entrydsm.user.global.error.exception.EquusException import hs.kr.entrydsm.user.global.error.exception.ErrorCode +/** + * 관리자를 찾을 수 없을 때 발생하는 예외입니다. + */ object AdminNotFoundException : EquusException( ErrorCode.ADMIN_NOT_FOUND, ) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/exception/AdminUnauthorizedException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/exception/AdminUnauthorizedException.kt index 4133a64..fdc0401 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/exception/AdminUnauthorizedException.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/exception/AdminUnauthorizedException.kt @@ -3,6 +3,9 @@ package hs.kr.entrydsm.user.domain.admin.exception import hs.kr.entrydsm.user.global.error.exception.EquusException import hs.kr.entrydsm.user.global.error.exception.ErrorCode +/** + * 관리자 권한이 없을 때 발생하는 예외입니다. + */ object AdminUnauthorizedException : EquusException( ErrorCode.ADMIN_UNAUTHORIZED, ) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/facade/AdminFacade.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/facade/AdminFacade.kt index 8ace64f..ede6dc0 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/facade/AdminFacade.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/admin/facade/AdminFacade.kt @@ -1,21 +1,39 @@ package hs.kr.entrydsm.user.domain.admin.facade -import hs.kr.entrydsm.user.domain.admin.domain.Admin -import hs.kr.entrydsm.user.domain.admin.domain.repository.AdminRepository +import hs.kr.entrydsm.user.domain.admin.application.port.`in`.AdminFacadeUseCase +import hs.kr.entrydsm.user.domain.admin.application.port.out.QueryAdminPort import hs.kr.entrydsm.user.domain.admin.exception.AdminNotFoundException -import org.springframework.data.repository.findByIdOrNull +import hs.kr.entrydsm.user.domain.admin.model.Admin import org.springframework.security.core.context.SecurityContextHolder import org.springframework.stereotype.Component import java.util.UUID +/** + * 관리자 관련 공통 기능을 제공하는 Facade 클래스입니다. + */ @Component class AdminFacade( - private val adminRepository: AdminRepository, -) { - fun getCurrentUser(): Admin { + private val queryAdminPort: QueryAdminPort, +) : AdminFacadeUseCase { + /** + * 현재 인증된 관리자의 사용자 정보를 조회합니다. + * + * @return 현재 인증된 관리자 정보 + * @throws AdminNotFoundException 관리자가 존재하지 않는 경우 + */ + override fun getCurrentUser(): Admin { val adminId = SecurityContextHolder.getContext().authentication.name - return adminRepository.findByIdOrNull(UUID.fromString(adminId)) ?: throw AdminNotFoundException + return queryAdminPort.findById(UUID.fromString(adminId)) ?: throw AdminNotFoundException } - fun getUserById(adminId: String): Admin = adminRepository.findByIdOrNull(UUID.fromString(adminId)) ?: throw AdminNotFoundException + /** + * 관리자 ID로 사용자 정보를 조회합니다. + * + * @param adminId 조회할 관리자의 UUID 문자열 + * @return 조회된 관리자 정보 + * @throws AdminNotFoundException 관리자가 존재하지 않는 경우 + */ + override fun getUserById(adminId: String): Admin { + return queryAdminPort.findById(UUID.fromString(adminId)) ?: throw AdminNotFoundException + } } diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/application/service/QueryPassInfoService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/application/service/QueryPassInfoService.kt new file mode 100644 index 0000000..809bf28 --- /dev/null +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/application/service/QueryPassInfoService.kt @@ -0,0 +1,59 @@ +package hs.kr.entrydsm.user.domain.auth.application.service + +import hs.kr.entrydsm.user.domain.auth.adapter.`in`.web.dto.resopnse.QueryPassInfoResponse +import hs.kr.entrydsm.user.domain.auth.adapter.out.PassInfo +import hs.kr.entrydsm.user.domain.auth.adapter.out.repository.PassInfoRepository +import hs.kr.entrydsm.user.domain.auth.application.port.`in`.QueryPassInfoUseCase +import hs.kr.entrydsm.user.domain.auth.exception.InvalidPassException +import hs.kr.entrydsm.user.global.utils.encryption.EncryptionUtil +import hs.kr.entrydsm.user.global.utils.pass.PassUtil +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 패스 인증 정보 조회 비즈니스 로직을 처리하는 서비스 클래스입니다. + */ +@Service +class QueryPassInfoService( + private val passInfoRepository: PassInfoRepository, + private val passUtil: PassUtil, + private val encryptionUtil: EncryptionUtil, +) : QueryPassInfoUseCase { + companion object { + private val RESULT_CODE = "RSLT_CD" + + private val RESULT_NAME = "RSLT_NAME" + + private val TEL_NO = "TEL_NO" + + private val RESULT_CODE_OK = "B000" + } + + @Value("\${pass.exp}") + private var exp: Long = 0L + + /** + * 토큰을 검증하고 패스 인증 정보를 조회합니다. + */ + @Transactional + override fun queryPassInfo(token: String?): QueryPassInfoResponse { + val resJson = passUtil.getResponseJson(token) + val resultCode = resJson!!.getString(RESULT_CODE) + if (RESULT_CODE_OK != resultCode) { + throw InvalidPassException + } + val name = resJson.getString(RESULT_NAME) + val phoneNumber = resJson.getString(TEL_NO) + + val passInfo = + PassInfo( + encryptionUtil.encrypt(name), + encryptionUtil.encrypt(phoneNumber), + exp, + ) + + passInfoRepository.save(passInfo) + return QueryPassInfoResponse(phoneNumber, name) + } +} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidOkCertConnectException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidOkCertConnectException.kt index 103418b..e3beb24 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidOkCertConnectException.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidOkCertConnectException.kt @@ -3,6 +3,9 @@ package hs.kr.entrydsm.user.domain.auth.exception import hs.kr.entrydsm.user.global.error.exception.EquusException import hs.kr.entrydsm.user.global.error.exception.ErrorCode +/** + * OkCert 연결이 유효하지 않을 때 발생하는 예외입니다. + */ object InvalidOkCertConnectException : EquusException( ErrorCode.INVALID_OKCERT_CONNECTION, ) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidPassException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidPassException.kt index 7c602f9..630d571 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidPassException.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidPassException.kt @@ -3,6 +3,10 @@ package hs.kr.entrydsm.user.domain.auth.exception import hs.kr.entrydsm.user.global.error.exception.EquusException import hs.kr.entrydsm.user.global.error.exception.ErrorCode +/** + * Pass 인증이 유효하지 않을 때 발생하는 예외입니다. + * Pass 인증 과정에서 검증에 실패한 경우 사용됩니다. + */ object InvalidPassException : EquusException( ErrorCode.INVALID_PASS, ) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidUrlException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidUrlException.kt index 895c274..1bdd34d 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidUrlException.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/InvalidUrlException.kt @@ -3,6 +3,9 @@ package hs.kr.entrydsm.user.domain.auth.exception import hs.kr.entrydsm.user.global.error.exception.EquusException import hs.kr.entrydsm.user.global.error.exception.ErrorCode +/** + * URL이 유효하지 않을 때 발생하는 예외입니다. + */ object InvalidUrlException : EquusException( ErrorCode.INVALID_URL, ) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/PassInfoNotFoundException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/PassInfoNotFoundException.kt index 998f9e8..b962709 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/PassInfoNotFoundException.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/exception/PassInfoNotFoundException.kt @@ -3,6 +3,10 @@ package hs.kr.entrydsm.user.domain.auth.exception import hs.kr.entrydsm.user.global.error.exception.EquusException import hs.kr.entrydsm.user.global.error.exception.ErrorCode +/** + * Pass 인증 정보를 찾을 수 없을 때 발생하는 예외입니다. + * Pass 인증을 통해 저장된 사용자 정보가 만료되었거나 존재하지 않는 경우 사용됩니다. + */ object PassInfoNotFoundException : EquusException( ErrorCode.PASS_INFO_NOT_FOUND, ) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/presentation/dto/request/PassPopupRequest.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/presentation/dto/request/PassPopupRequest.kt deleted file mode 100644 index a697d67..0000000 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/presentation/dto/request/PassPopupRequest.kt +++ /dev/null @@ -1,8 +0,0 @@ -package hs.kr.entrydsm.user.domain.auth.presentation.dto.request - -import jakarta.validation.constraints.NotBlank - -data class PassPopupRequest( - @NotBlank(message = "redirect_url은 Null 또는 공백 또는 띄어쓰기를 허용하지 않습니다.") - val redirectUrl: String, -) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/service/PassPopupService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/service/PassPopupService.kt deleted file mode 100644 index 6b777bd..0000000 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/service/PassPopupService.kt +++ /dev/null @@ -1,106 +0,0 @@ -package hs.kr.entrydsm.user.domain.auth.service - -import hs.kr.entrydsm.user.domain.auth.presentation.dto.request.PassPopupRequest -import hs.kr.entrydsm.user.global.exception.InternalServerErrorException -import hs.kr.entrydsm.user.global.utils.pass.RedirectUrlChecker -import kcb.module.v3.OkCert -import org.json.JSONObject -import org.springframework.beans.factory.annotation.Value -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional - -@Service -class PassPopupService( - private val redirectUrlChecker: RedirectUrlChecker, -) { - companion object { - private const val TARGET = "PROD" - } - - @Value("\${pass.site-name}") - private lateinit var siteName: String - - @Value("\${pass.site-url}") - private lateinit var siteUrl: String - - @Value("\${pass.popup-url}") - private lateinit var popupUrl: String - - @Value("\${pass.cp-cd}") - private lateinit var cpCd: String - - @Value("\${pass.license}") - private lateinit var license: String - - private val svcName = "IDS_HS_POPUP_START" - - private val rqstCausCd = "00" - - @Transactional - fun execute(passPopupRequest: PassPopupRequest): String { - redirectUrlChecker.checkRedirectUrl(passPopupRequest.redirectUrl) - try { - val reqJson = JSONObject() - reqJson.put("RETURN_URL", passPopupRequest.redirectUrl) - reqJson.put("SITE_NAME", siteName) - reqJson.put("SITE_URL", siteUrl) - reqJson.put("RQST_CAUS_CD", rqstCausCd) - - val reqStr: String = reqJson.toString() - - val okcert = OkCert() - - val resultStr: String = okcert.callOkCert(TARGET, cpCd, svcName, license, reqStr) - - val resJson = JSONObject(resultStr) - - val RSLT_CD: String = resJson.getString("RSLT_CD") - val RSLT_MSG: String = resJson.getString("RSLT_MSG") - var MDL_TKN = "" - - var succ = false - - if ("B000" == RSLT_CD && resJson.has("MDL_TKN")) { - MDL_TKN = resJson.getString("MDL_TKN") - succ = true - } - - val htmlBuilder = StringBuilder() - htmlBuilder.append("") - htmlBuilder.append("KCB ?��???본인?�인 ?�비???�플 2") - htmlBuilder.append("") - htmlBuilder.append("") - htmlBuilder.append("") - htmlBuilder.append("") - htmlBuilder.append("") - htmlBuilder.append("
") - htmlBuilder.append( - "", - ) - htmlBuilder.append("") - htmlBuilder.append("") - htmlBuilder.append("") - htmlBuilder.append("
") - htmlBuilder.append("") - htmlBuilder.append("") - htmlBuilder.append("") - - return htmlBuilder.toString() - } catch (e: Exception) { - println(e.message) - throw InternalServerErrorException - } - } -} diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/LicenseConfig.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/LicenseConfig.kt index 60e5c32..cdfc75a 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/LicenseConfig.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/LicenseConfig.kt @@ -8,11 +8,17 @@ import java.net.URL import java.nio.file.Files import java.nio.file.Paths +/** + * 패스 인증 라이센스 파일을 다운로드하고 설정하는 클래스입니다. + */ @Configuration class LicenseConfig( @Value("\${pass.license_file_url}") val licenseFileURl: String, ) { + /** + * 애플리케이션 시작 시 라이센스 파일을 다운로드합니다. + */ @PostConstruct fun initialize() { try { @@ -25,7 +31,13 @@ class LicenseConfig( } } + /** + * 라이센스 파일 경로 상수 + */ companion object { + /** + * 라이센스 파일 저장 경로 + */ private const val PATH = "./V61290000000_IDS_01_PROD_AES_license.dat" } } diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/RedisConfig.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/RedisConfig.kt index c58c350..7575acf 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/RedisConfig.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/RedisConfig.kt @@ -12,27 +12,42 @@ import org.springframework.data.redis.core.RedisTemplate import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer import org.springframework.data.redis.serializer.StringRedisSerializer +/** + * Redis 설정 클래스입니다. + * Redis 연결 및 직렬화 설정을 담당합니다. + */ @Configuration class RedisConfig { + /** + * Redis Template을 설정합니다. + * 문자열 키와 JSON 직렬화를 사용하는 Redis 템플릿을 구성합니다. + * + * @param connectionFactory Redis 연결 팩토리 + * @param objectMapper JSON 직렬화를 위한 ObjectMapper + * @return 구성된 RedisTemplate + */ @Bean fun redisTemplate( connectionFactory: RedisConnectionFactory, objectMapper: ObjectMapper, ): RedisTemplate { val template = RedisTemplate() - template.setConnectionFactory(connectionFactory) + template.connectionFactory = connectionFactory template.keySerializer = StringRedisSerializer() - template.valueSerializer = - Jackson2JsonRedisSerializer(Any::class.java).apply { - setObjectMapper(objectMapper) - } + template.valueSerializer = Jackson2JsonRedisSerializer(objectMapper, Any::class.java) return template } + /** + * JSON 직렬화를 위한 ObjectMapper를 설정합니다. + * Kotlin 모듈과 타입 정보를 포함한 JSON 직렬화를 지원합니다. + * + * @return 구성된 ObjectMapper + */ @Bean fun objectMapper(): ObjectMapper { val mapper = ObjectMapper() - mapper.registerModule(KotlinModule()) + mapper.registerModule(KotlinModule.Builder().build()) mapper.activateDefaultTyping( LaissezFaireSubTypeValidator(), ObjectMapper.DefaultTyping.NON_FINAL, diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/StaticRoutingConfiguration.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/StaticRoutingConfiguration.kt index d83df10..349544e 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/StaticRoutingConfiguration.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/config/StaticRoutingConfiguration.kt @@ -4,8 +4,16 @@ import org.springframework.context.annotation.Configuration import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry import org.springframework.web.servlet.config.annotation.WebMvcConfigurer +/** + * 정적 리소스 라우팅을 설정하는 클래스입니다. + */ @Configuration class StaticRoutingConfiguration : WebMvcConfigurer { + /** + * 정적 리소스 핸들러를 추가합니다. + * + * @param registry 리소스 핸들러를 등록할 레지스트리 + */ override fun addResourceHandlers(registry: ResourceHandlerRegistry) { registry.addResourceHandler("*/dist/**").addResourceLocations("classpath:/static/") registry.addResourceHandler("swagger-ui.html").addResourceLocations("classpath:/static/swagger-ui/") diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/ErrorResponse.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/ErrorResponse.kt index fba51be..1781c46 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/ErrorResponse.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/ErrorResponse.kt @@ -1,5 +1,12 @@ package hs.kr.entrydsm.user.global.error +/** + * API 오류 응답을 나타내는 데이터 클래스입니다. + * 클라이언트에게 일관된 형식의 오류 정보를 제공합니다. + * + * @property status HTTP 상태 코드 + * @property message 오류 메시지 + */ data class ErrorResponse( val status: Int, val message: String?, diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/GlobalExceptionFilter.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/GlobalExceptionFilter.kt index 9383529..bbc70ec 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/GlobalExceptionFilter.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/GlobalExceptionFilter.kt @@ -4,17 +4,31 @@ import com.fasterxml.jackson.databind.ObjectMapper import hs.kr.entrydsm.user.global.error.exception.EquusException import hs.kr.entrydsm.user.global.error.exception.ErrorCode import io.sentry.Sentry +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse import org.springframework.http.MediaType import org.springframework.web.filter.OncePerRequestFilter import java.io.IOException import java.nio.charset.StandardCharsets -import javax.servlet.FilterChain -import javax.servlet.http.HttpServletRequest -import javax.servlet.http.HttpServletResponse +/** + * 전역 예외 처리 필터 클래스입니다. + * Spring Security 필터 체인에서 발생하는 예외를 처리하여 일관된 오류 응답을 제공합니다. + * + * @property objectMapper JSON 직렬화를 위한 ObjectMapper + */ class GlobalExceptionFilter( private val objectMapper: ObjectMapper, ) : OncePerRequestFilter() { + /** + * 필터 체인에서 발생하는 예외를 처리합니다. + * EquusException과 일반 Exception을 구분하여 처리하고, Sentry로 예외를 추적합니다. + * + * @param request HTTP 요청 + * @param response HTTP 응답 + * @param filterChain 필터 체인 + */ override fun doFilterInternal( request: HttpServletRequest, response: HttpServletResponse, @@ -32,6 +46,13 @@ class GlobalExceptionFilter( } } + /** + * 에러 코드를 HTTP 응답으로 작성합니다. + * + * @param response HTTP 응답 객체 + * @param errorCode 발생한 에러 코드 + * @throws IOException 응답 작성 중 IO 오류 발생 시 + */ @Throws(IOException::class) private fun writerErrorCode( response: HttpServletResponse, diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/GlobalExceptionHandler.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/GlobalExceptionHandler.kt index 801f864..135032a 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/GlobalExceptionHandler.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/GlobalExceptionHandler.kt @@ -8,8 +8,18 @@ import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.RestControllerAdvice import kotlin.collections.get +/** + * 애플리케이션의 전역 예외 처리를 담당하는 클래스입니다. + * 모든 컨트롤러에서 발생하는 예외를 처리하여 일관된 오류 응답을 제공합니다. + */ @RestControllerAdvice class GlobalExceptionHandler { + /** + * Equus 애플리케이션의 커스텀 예외를 처리합니다. + * + * @param e EquusException 인스턴스 + * @return 에러 코드에 따른 응답 엔티티 + */ @ExceptionHandler(EquusException::class) fun handlingEquusException(e: EquusException): ResponseEntity { val code = e.errorCode @@ -19,6 +29,12 @@ class GlobalExceptionHandler { ) } + /** + * 유효성 검증 실패 예외를 처리합니다. + * + * @param e MethodArgumentNotValidException 인스턴스 + * @return 400 에러 응답 + */ @ExceptionHandler(MethodArgumentNotValidException::class) fun validatorExceptionHandler(e: MethodArgumentNotValidException): ResponseEntity { return ResponseEntity( diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/EquusException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/EquusException.kt index 11faa93..cddf727 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/EquusException.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/EquusException.kt @@ -2,6 +2,12 @@ package hs.kr.entrydsm.user.global.error.exception import java.lang.RuntimeException +/** + * Equus 애플리케이션의 모든 커스텀 예외의 기본 클래스입니다. + * 에러 코드를 포함하여 일관된 예외 처리를 제공합니다. + * + * @property errorCode 발생한 오류의 에러 코드 + */ abstract class EquusException( val errorCode: ErrorCode, ) : RuntimeException() diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/ErrorCode.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/ErrorCode.kt index 56b8acc..8b019fd 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/ErrorCode.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/ErrorCode.kt @@ -1,26 +1,30 @@ package hs.kr.entrydsm.user.global.error.exception +/** + * 애플리케이션에서 발생하는 오류 코드를 정의하는 열거형 클래스입니다. + * HTTP 상태 코드와 에러 메시지를 함께 관리합니다. + * + * @property status HTTP 상태 코드 + * @property message 에러 메시지 + */ enum class ErrorCode( val status: Int, val message: String, ) { - // UnAuthorization INVALID_TOKEN(401, "Invalid Token"), EXPIRED_TOKEN(401, "Expired Token"), INVALID_URL(401, "Invalid Url"), INVALID_PASS(401, "Invalid Pass"), INVALID_USER_PASSWORD(401, "Invalid User Password"), ADMIN_UNAUTHORIZED(401, "Admin UnAuthorized"), + PASSWORD_MISS_MATCH(401, "비밀번호가 일치하지 않습니다"), - // Internal Server Error INTERNAL_SERVER_ERROR(500, "Internal Server Error"), INVALID_OKCERT_CONNECTION(500, "Invalid OkCert Connection"), - // Not Found USER_NOT_FOUND(404, "User Not Found"), PASS_INFO_NOT_FOUND(404, "Pass Info Not Found"), ADMIN_NOT_FOUND(404, "Admin Not Found"), - // Conflict USER_ALREADY_EXISTS(409, "User Already Exists"), } diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/ExpiredTokenException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/ExpiredTokenException.kt index f5a5777..182c660 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/ExpiredTokenException.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/ExpiredTokenException.kt @@ -3,6 +3,10 @@ package hs.kr.entrydsm.user.global.exception import hs.kr.entrydsm.user.global.error.exception.EquusException import hs.kr.entrydsm.user.global.error.exception.ErrorCode +/** + * 토큰이 만료되었을 때 발생하는 예외입니다. + * JWT 토큰의 유효 기간이 만료된 경우 사용됩니다. + */ object ExpiredTokenException : EquusException( ErrorCode.EXPIRED_TOKEN, ) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/InternalServerErrorException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/InternalServerErrorException.kt index d3e07c6..803a1ef 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/InternalServerErrorException.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/InternalServerErrorException.kt @@ -3,6 +3,10 @@ package hs.kr.entrydsm.user.global.exception import hs.kr.entrydsm.user.global.error.exception.EquusException import hs.kr.entrydsm.user.global.error.exception.ErrorCode +/** + * 내부 서버 오류가 발생했을 때 사용하는 예외입니다. + * 예상치 못한 서버 측 오류가 발생한 경우 사용됩니다. + */ object InternalServerErrorException : EquusException( ErrorCode.INTERNAL_SERVER_ERROR, ) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/InvalidTokenException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/InvalidTokenException.kt index 474fc5a..e6b3fdc 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/InvalidTokenException.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/exception/InvalidTokenException.kt @@ -3,6 +3,10 @@ package hs.kr.entrydsm.user.global.exception import hs.kr.entrydsm.user.global.error.exception.EquusException import hs.kr.entrydsm.user.global.error.exception.ErrorCode +/** + * 토큰이 유효하지 않을 때 발생하는 예외입니다. + * JWT 토큰의 형식이 잘못되었거나 서명이 일치하지 않는 경우 사용됩니다. + */ object InvalidTokenException : EquusException( ErrorCode.INVALID_TOKEN, ) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/FilterConfig.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/FilterConfig.kt index a4855c7..eeae3ba 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/FilterConfig.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/FilterConfig.kt @@ -9,12 +9,21 @@ import org.springframework.security.web.DefaultSecurityFilterChain import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter import org.springframework.stereotype.Component +/** + * 보안 필터 체인을 설정하는 클래스입니다. + */ @Component class FilterConfig( - private val objectMapper: ObjectMapper + private val objectMapper: ObjectMapper, ) : SecurityConfigurerAdapter() { + /** + * 보안 필터를 설정합니다. + * JWT 필터와 전역 예외 필터를 Spring Security 필터 체인에 추가합니다. + * + * @param http HTTP 보안 설정 객체 + */ override fun configure(http: HttpSecurity) { - val jwtFilter = JwtFilter() + val jwtFilter = JwtFilter() val globalExceptionFilter = GlobalExceptionFilter(objectMapper) http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/SecurityConfig.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/SecurityConfig.kt index 0f48215..98839a8 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/SecurityConfig.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/SecurityConfig.kt @@ -9,16 +9,28 @@ import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.web.SecurityFilterChain +/** + * Spring Security 설정 클래스입니다. + * 애플리케이션의 보안 정책과 인증/인가 규칙을 정의합니다. + * + * @property objectMapper JSON 직렬화/역직렬화를 위한 ObjectMapper + */ @Configuration class SecurityConfig( - private val objectMapper: ObjectMapper + private val objectMapper: ObjectMapper, ) { - + /** + * Spring Security 필터 체인을 구성합니다. + * HTTP 보안 설정 및 경로별 접근 권한을 정의합니다. + * + * @param http HttpSecurity 객체 + * @return 구성된 SecurityFilterChain + */ @Bean protected fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { http .csrf { it.disable() } - .cors { } // 필요 시 CORS 설정 추가 + .cors { it.disable() } .formLogin { it.disable() } .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) @@ -39,12 +51,16 @@ class SecurityConfig( .requestMatchers(HttpMethod.GET, "/admin/").hasRole("ROOT") .anyRequest().authenticated() } - .with(FilterConfig(objectMapper)) { } return http.build() } + /** + * 비밀번호 암호화를 위한 BCrypt 인코더를 설정합니다. + * + * @return BCryptPasswordEncoder 인스턴스 + */ @Bean fun passwordEncoder() = BCryptPasswordEncoder() } diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AdminDetailsService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AdminDetailsService.kt index 5e52172..a969fc0 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AdminDetailsService.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AdminDetailsService.kt @@ -1,18 +1,24 @@ package hs.kr.entrydsm.user.global.security.auth -import hs.kr.entrydsm.user.domain.admin.domain.Admin +import hs.kr.entrydsm.user.domain.admin.application.port.`in`.AdminFacadeUseCase import hs.kr.entrydsm.user.domain.admin.exception.AdminUnauthorizedException -import hs.kr.entrydsm.user.domain.admin.facade.AdminFacade +import hs.kr.entrydsm.user.domain.admin.model.Admin import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.stereotype.Service +/** + * Spring Security 관리자 인증을 위한 사용자 상세 정보 로딩 서비스 클래스입니다. + */ @Service class AdminDetailsService( - private val adminFacade: AdminFacade, + private val adminFacadeUseCase: AdminFacadeUseCase, ) : UserDetailsService { + /** + * 관리자 ID로 관리자 정보를 로드합니다. + */ override fun loadUserByUsername(adminId: String?): UserDetails { - val admin: Admin = adminId?.let { adminFacade.getUserById(it) } ?: throw AdminUnauthorizedException + val admin: Admin = adminId?.let { adminFacadeUseCase.getUserById(it) } ?: throw AdminUnauthorizedException return AuthDetails(admin.adminId) } } diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AuthDetails.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AuthDetails.kt index 38f4a5d..bdcbef3 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AuthDetails.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AuthDetails.kt @@ -4,10 +4,21 @@ import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.userdetails.UserDetails +/** + * Spring Security 인증을 위한 사용자 상세 정보를 담는 클래스입니다. + * + * @property userId 사용자 ID + */ class AuthDetails( private val userId: String, ) : UserDetails { + /** + * 상수 정의 + */ companion object { + /** + * 기본 사용자 역할 + */ private val ROLE_USER = "ROLE_USER" } diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AuthDetailsService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AuthDetailsService.kt index 3a577d2..d2771dc 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AuthDetailsService.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/auth/AuthDetailsService.kt @@ -1,17 +1,23 @@ package hs.kr.entrydsm.user.global.security.auth -import hs.kr.entrydsm.user.domain.user.domain.User -import hs.kr.entrydsm.user.domain.user.facade.UserFacade +import hs.kr.entrydsm.user.domain.user.application.port.`in`.UserFacadeUseCase +import hs.kr.entrydsm.user.domain.user.model.User import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.stereotype.Service +/** + * Spring Security 인증을 위한 사용자 상세 정보 로딩 서비스 클래스입니다. + */ @Service class AuthDetailsService( - private val userFacade: UserFacade, + private val userFacadeUseCase: UserFacadeUseCase, ) : UserDetailsService { + /** + * 전화번호로 사용자 정보를 로드합니다. + */ override fun loadUserByUsername(phoneNumber: String?): UserDetails { - val user: User? = phoneNumber?.let { userFacade.getUserByPhoneNumber(it) } + val user: User? = phoneNumber?.let { userFacadeUseCase.getUserByPhoneNumber(it) } return AuthDetails(user!!.phoneNumber) } } diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/jwt/JwtTokenProvider.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/jwt/JwtTokenProvider.kt index 45cf5c3..1e2a912 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/jwt/JwtTokenProvider.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/security/jwt/JwtTokenProvider.kt @@ -1,9 +1,8 @@ package hs.kr.entrydsm.user.global.security.jwt -import hs.kr.entrydsm.user.domain.refreshtoken.domain.RefreshToken -import hs.kr.entrydsm.user.domain.refreshtoken.domain.repository.RefreshTokenRepository -import hs.kr.entrydsm.user.domain.user.domain.UserRole -import hs.kr.entrydsm.user.domain.user.domain.repository.UserRepository +import hs.kr.entrydsm.user.domain.refreshtoken.adapter.out.RefreshToken +import hs.kr.entrydsm.user.domain.refreshtoken.adapter.out.repository.RefreshTokenRepository +import hs.kr.entrydsm.user.domain.user.adapter.out.domain.UserRole import hs.kr.entrydsm.user.global.exception.ExpiredTokenException import hs.kr.entrydsm.user.global.exception.InvalidTokenException import hs.kr.entrydsm.user.global.security.auth.AdminDetailsService @@ -15,20 +14,30 @@ import io.jsonwebtoken.Jws import io.jsonwebtoken.JwtException import io.jsonwebtoken.Jwts import io.jsonwebtoken.SignatureAlgorithm +import io.jsonwebtoken.security.Keys +import jakarta.servlet.http.HttpServletRequest import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.Authentication import org.springframework.security.core.userdetails.UserDetails import org.springframework.stereotype.Component +import java.nio.charset.StandardCharsets import java.util.Date -import javax.servlet.http.HttpServletRequest -import kotlin.text.get - +import javax.crypto.SecretKey + +/** + * JWT 토큰의 생성, 검증, 파싱을 담당하는 클래스입니다. + * 액세스 토큰과 리프레시 토큰을 관리하며, 토큰 기반 인증을 처리합니다. + * + * @property jwtProperties JWT 관련 설정 프로퍼티 + * @property authDetailsService 사용자 상세 정보 서비스 + * @property refreshTokenRepository 리프레시 토큰 저장소 + * @property adminDetailsService 관리자 상세 정보 서비스 + */ @Component class JwtTokenProvider( private val jwtProperties: JwtProperties, private val authDetailsService: AuthDetailsService, private val refreshTokenRepository: RefreshTokenRepository, - private val userRepository: UserRepository, private val adminDetailsService: AdminDetailsService, ) { companion object { @@ -36,14 +45,35 @@ class JwtTokenProvider( private const val REFRESH_KEY = "refresh_token" } + private val secretKey: SecretKey by lazy { + Keys.hmacShaKeyFor(jwtProperties.secretKey.toByteArray(StandardCharsets.UTF_8)) + } + + /** + * 토큰에서 클레임을 추출합니다. + * + * @param token 파싱할 JWT 토큰 + * @return 토큰의 클레임 정보 + * @throws InvalidTokenException 토큰이 유효하지 않은 경우 + */ private fun getBody(token: String): Claims { return try { - Jwts.parser().setSigningKey(jwtProperties.secretKey).parseClaimsJws(token).body + Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token).body } catch (e: JwtException) { throw InvalidTokenException } } + /** + * 토큰에서 만료 시간을 확인하고 subject를 반환합니다. + * + * @param token 확인할 JWT 토큰 + * @return 토큰의 subject (사용자 ID) + * @throws ExpiredTokenException 토큰이 만료된 경우 + */ fun getSubjectWithExpiredCheck(token: String): String { val body = getBody(token) return if (body.expiration.before(Date())) { @@ -53,6 +83,13 @@ class JwtTokenProvider( } } + /** + * 리프레시 토큰을 이용하여 새로운 토큰을 발급합니다. + * + * @param refreshToken 기존 리프레시 토큰 + * @return 새로 발급된 토큰 응답 + * @throws InvalidTokenException 토큰이 유효하지 않은 경우 + */ fun reIssue(refreshToken: String): TokenResponse { if (!isRefreshToken(refreshToken)) { throw InvalidTokenException @@ -69,6 +106,13 @@ class JwtTokenProvider( } ?: throw InvalidTokenException } + /** + * 액세스 토큰과 리프레시 토큰을 생성합니다. + * + * @param userId 사용자 ID + * @param role 사용자 역할 + * @return 생성된 토큰 응답 + */ fun generateToken( userId: String, role: String, @@ -81,6 +125,15 @@ class JwtTokenProvider( return TokenResponse(accessToken, refreshToken) } + /** + * 액세스 토큰을 생성합니다. + * + * @param id 사용자 ID + * @param role 사용자 역할 + * @param type 토큰 타입 + * @param exp 만료 시간 (초) + * @return 생성된 액세스 토큰 + */ private fun generateAccessToken( id: String, role: String, @@ -91,11 +144,19 @@ class JwtTokenProvider( .setSubject(id) .setHeaderParam("typ", type) .claim("role", role) - .signWith(SignatureAlgorithm.HS256, jwtProperties.secretKey) + .signWith(secretKey, SignatureAlgorithm.HS256) .setExpiration(Date(System.currentTimeMillis() + exp * 1000)) .setIssuedAt(Date()) .compact() + /** + * 리프레시 토큰을 생성합니다. + * + * @param role 사용자 역할 + * @param type 토큰 타입 + * @param exp 만료 시간 (초) + * @return 생성된 리프레시 토큰 + */ private fun generateRefreshToken( role: String, type: String, @@ -104,11 +165,17 @@ class JwtTokenProvider( Jwts.builder() .setHeaderParam("typ", type) .claim("role", role) - .signWith(SignatureAlgorithm.HS256, jwtProperties.secretKey) + .signWith(secretKey, SignatureAlgorithm.HS256) .setExpiration(Date(System.currentTimeMillis() + exp * 1000)) .setIssuedAt(Date()) .compact() + /** + * HTTP 요청에서 토큰을 추출합니다. + * + * @param request HTTP 요청 객체 + * @return 추출된 토큰 (없으면 null) + */ fun resolveToken(request: HttpServletRequest): String? = request.getHeader(jwtProperties.header)?.also { if (it.startsWith(jwtProperties.prefix)) { @@ -116,6 +183,13 @@ class JwtTokenProvider( } } + /** + * 토큰으로부터 인증 객체를 생성합니다. + * + * @param token JWT 토큰 + * @return Spring Security 인증 객체 + * @throws InvalidTokenException 토큰이 유효하지 않은 경우 + */ fun authentication(token: String): Authentication? { val body: Claims = getJws(token).body if (!isRefreshToken(token)) throw InvalidTokenException @@ -123,9 +197,20 @@ class JwtTokenProvider( return UsernamePasswordAuthenticationToken(userDetails, "", userDetails.authorities) } + /** + * 토큰을 파싱하여 JWS 객체를 반환합니다. + * + * @param token 파싱할 JWT 토큰 + * @return 파싱된 JWS 객체 + * @throws ExpiredTokenException 토큰이 만료된 경우 + * @throws InvalidTokenException 토큰이 유효하지 않은 경우 + */ private fun getJws(token: String): Jws { return try { - Jwts.parser().setSigningKey(jwtProperties.secretKey).parseClaimsJws(token) + Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) } catch (e: ExpiredJwtException) { throw ExpiredTokenException } catch (e: Exception) { @@ -133,12 +218,30 @@ class JwtTokenProvider( } } + /** + * 토큰이 리프레시 토큰인지 확인합니다. + * + * @param token 확인할 토큰 + * @return 리프레시 토큰 여부 + */ private fun isRefreshToken(token: String?): Boolean { return REFRESH_KEY == getJws(token!!).header["typ"].toString() } + /** + * 토큰에서 역할 정보를 추출합니다. + * + * @param token JWT 토큰 + * @return 사용자 역할 + */ fun getRole(token: String) = getJws(token).body["role"].toString() + /** + * 클레임에서 사용자 상세 정보를 조회합니다. + * + * @param body 토큰의 클레임 정보 + * @return 사용자 상세 정보 + */ private fun getDetails(body: Claims): UserDetails { return if (UserRole.USER.toString() == body["role"].toString()) { authDetailsService.loadUserByUsername(body.subject) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/pass/PassUtil.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/pass/PassUtil.kt index bc04ac5..4ef8d7a 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/pass/PassUtil.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/pass/PassUtil.kt @@ -7,31 +7,55 @@ import kcb.org.json.JSONObject import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component +/** + * KCB 패스 인증 유틸리티 클래스입니다. + */ @Component class PassUtil { companion object { + /** + * KCB OkCert 인스턴스 + */ private val okCert = OkCert() + /** + * KCB 인증 대상 환경 (운영환경) + */ private val TARGET = "PROD" + /** + * KCB 서비스명 + */ private val SVC_NAME = "IDS_HS_POPUP_RESULT" + /** + * 모델 토큰 키명 + */ private val MODEL_TOKEN = "MDL_TKN" } + /** + * KCB CP 코드 + */ @Value("\${pass.cp-cd}") - private lateinit var CP_CD: String + private lateinit var cpCd: String + /** + * KCB 라이선스 키 + */ @Value("\${pass.license}") - private lateinit var LICENSE: String + private lateinit var license: String + /** + * 패스 인증 토큰으로부터 응답 JSON을 가져옵니다. + */ fun getResponseJson(token: String?): JSONObject? { val reqJson = JSONObject() reqJson.put(MODEL_TOKEN, token) val reqStr = reqJson.toString() val resultStr: String? = try { - okCert.callOkCert(TARGET, CP_CD, SVC_NAME, LICENSE, reqStr) + okCert.callOkCert(TARGET, cpCd, SVC_NAME, license, reqStr) } catch (e: OkCertException) { throw InvalidOkCertConnectException } diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/pass/RedirectUrlChecker.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/pass/RedirectUrlChecker.kt index a297663..8fd2cca 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/pass/RedirectUrlChecker.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/pass/RedirectUrlChecker.kt @@ -4,13 +4,22 @@ import hs.kr.entrydsm.user.domain.auth.exception.InvalidUrlException import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component +/** + * 리다이렉트 URL의 유효성을 검사하는 클래스입니다. + */ @Component class RedirectUrlChecker { + /** + * Pass 인증의 기본 URL + */ @Value("\${pass.base-url}") - private lateinit var BASE_URL: String + private lateinit var baseUrl: String + /** + * 리다이렉트 URL이 허용된 기본 URL로 시작하는지 확인합니다. + */ fun checkRedirectUrl(redirectUrl: String) { - if (!redirectUrl.startsWith(BASE_URL)) { + if (!redirectUrl.startsWith(baseUrl)) { throw InvalidUrlException } } diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/token/dto/TokenResponse.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/token/dto/TokenResponse.kt index ddd32dd..ae9595b 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/token/dto/TokenResponse.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/utils/token/dto/TokenResponse.kt @@ -1,5 +1,12 @@ package hs.kr.entrydsm.user.global.utils.token.dto +/** + * JWT 토큰 응답을 나타내는 데이터 클래스입니다. + * 로그인 및 토큰 갱신 시 클라이언트에게 반환되는 토큰 정보를 담습니다. + * + * @property accessToken 액세스 토큰 + * @property refreshToken 리프레시 토큰 + */ data class TokenResponse( val accessToken: String, val refreshToken: String, diff --git a/casper-user/src/main/proto/user.proto b/casper-user/src/main/proto/user.proto index 150e780..1f8b9fb 100644 --- a/casper-user/src/main/proto/user.proto +++ b/casper-user/src/main/proto/user.proto @@ -22,7 +22,7 @@ message GetUserInfoResponse{ } enum UserRole { - UNSPECIFIED = 0; // protobuf에서 필수 (0번 값) + UNSPECIFIED = 0; ROOT = 1; USER = 2; ADMIN = 3; diff --git a/casper-user/src/main/resources/application.properties b/casper-user/src/main/resources/application.properties deleted file mode 100644 index 0fb5027..0000000 --- a/casper-user/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=Casper-User diff --git a/casper-user/src/main/resources/application.yml b/casper-user/src/main/resources/application.yml new file mode 100644 index 0000000..980ed7b --- /dev/null +++ b/casper-user/src/main/resources/application.yml @@ -0,0 +1,8 @@ +grpc: + server: + port: 9090 + max-inbound-message-size: 3MB + +app: + encryption: + key: "@qkrwndnjs12" \ No newline at end of file From 673c1b1c32a333ee5bf05b8752eb54a562c82216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Tue, 29 Jul 2025 17:56:17 +0900 Subject: [PATCH 18/22] =?UTF-8?q?refactor=20(=20#9=20)=20:=20mapstruct?= =?UTF-8?q?=EA=B0=80=20=EC=9E=90=EB=8F=99=20=EA=B5=AC=ED=98=84=ED=95=A0=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20componentModel=20=3D?= =?UTF-8?q?=20"spring"=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entrydsm/user/domain/user/adapter/out/mapper/UserMapper.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/mapper/UserMapper.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/mapper/UserMapper.kt index 60f77d1..b32272f 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/mapper/UserMapper.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/out/mapper/UserMapper.kt @@ -10,7 +10,7 @@ import org.mapstruct.Mapping * User 도메인 모델과 UserJpaEntity 간의 변환을 담당하는 매퍼 클래스입니다. * MapStruct를 사용하여 도메인 계층과 인프라스트럭처 계층 간의 데이터 변환을 처리합니다. */ -@Mapper +@Mapper(componentModel = "spring") abstract class UserMapper : GenericMapper { @Mapping(target = "changePassword", ignore = true) From 4b9ba2284ea4c75ec2d1772c71361a0175c10f79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Tue, 29 Jul 2025 18:54:16 +0900 Subject: [PATCH 19/22] =?UTF-8?q?refactor=20(=20#9=20)=20:=20passInfo?= =?UTF-8?q?=EB=8F=84=20=EC=95=94=ED=98=B8=ED=99=94=20=EB=90=98=EC=96=B4?= =?UTF-8?q?=EC=95=BC=20=ED=95=98=EA=B8=B0=20=EB=95=8C=EB=AC=B8=EC=97=90=20?= =?UTF-8?q?Hash=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20find=20=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/application/service/QueryPassInfoService.kt | 5 +++-- .../domain/user/application/service/ChangePasswordService.kt | 4 +++- .../domain/user/application/service/UserSignupService.kt | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/application/service/QueryPassInfoService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/application/service/QueryPassInfoService.kt index 809bf28..edade91 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/application/service/QueryPassInfoService.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/application/service/QueryPassInfoService.kt @@ -48,9 +48,10 @@ class QueryPassInfoService( val passInfo = PassInfo( - encryptionUtil.encrypt(name), + HashUtil.sha256(phoneNumber) encryptionUtil.encrypt(phoneNumber), - exp, + encryptionUtil.encrypt(name), + exp ) passInfoRepository.save(passInfo) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/ChangePasswordService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/ChangePasswordService.kt index 3f66192..e6c9198 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/ChangePasswordService.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/ChangePasswordService.kt @@ -37,7 +37,9 @@ class ChangePasswordService( */ @Transactional override fun changePassword(request: ChangePasswordRequest) { - if (!passInfoRepository.existsByPhoneNumber(request.phoneNumber)) { + val phoneNumberHash = HashUtil.sha256(request.phoneNumber) + + if (!passInfoRepository.existsById(phoneNumberHash)) { throw PassInfoNotFoundException } diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserSignupService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserSignupService.kt index b75ce52..ae23b09 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserSignupService.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/application/service/UserSignupService.kt @@ -56,7 +56,7 @@ class UserSignupService( } val passInfo = - passInfoRepository.findByPhoneNumber(phoneNumber) + passInfoRepository.findById(phoneNumberHash) .orElseThrow { PassInfoNotFoundException } val user = From 9d1bcae65c225e1461e9d552f0385d4a20e0b797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Wed, 30 Jul 2025 15:42:32 +0900 Subject: [PATCH 20/22] =?UTF-8?q?refactor=20(=20#9=20)=20:=20=EC=89=BC?= =?UTF-8?q?=ED=91=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/application/service/QueryPassInfoService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/application/service/QueryPassInfoService.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/application/service/QueryPassInfoService.kt index edade91..8dafd3b 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/application/service/QueryPassInfoService.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/auth/application/service/QueryPassInfoService.kt @@ -48,7 +48,7 @@ class QueryPassInfoService( val passInfo = PassInfo( - HashUtil.sha256(phoneNumber) + HashUtil.sha256(phoneNumber), encryptionUtil.encrypt(phoneNumber), encryptionUtil.encrypt(name), exp From 3d4667fcb365964c19bbb222e032cfdeb48fd3c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Thu, 31 Jul 2025 16:56:00 +0900 Subject: [PATCH 21/22] =?UTF-8?q?chore=20(=20#9=20)=20:=20class=20?= =?UTF-8?q?=EB=84=A4=EC=9E=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../error/exception/{EquusException.kt => CasperException.kt} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/{EquusException.kt => CasperException.kt} (92%) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/EquusException.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/CasperException.kt similarity index 92% rename from casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/EquusException.kt rename to casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/CasperException.kt index cddf727..7d06b3f 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/EquusException.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/global/error/exception/CasperException.kt @@ -8,6 +8,6 @@ import java.lang.RuntimeException * * @property errorCode 발생한 오류의 에러 코드 */ -abstract class EquusException( +abstract class CasperException( val errorCode: ErrorCode, ) : RuntimeException() From c3994f9ef966b7d6682e643929b0bcdfbb863005 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A3=BC=EC=9B=90?= Date: Thu, 31 Jul 2025 16:59:48 +0900 Subject: [PATCH 22/22] =?UTF-8?q?chore=20(=20#9=20)=20:=20controller=20->?= =?UTF-8?q?=20webAdapter=EB=A1=9C=20=ED=81=AC=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/in/web/{UserController.kt => UserWebAdapter.kt} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/{UserController.kt => UserWebAdapter.kt} (99%) diff --git a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/UserController.kt b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/UserWebAdapter.kt similarity index 99% rename from casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/UserController.kt rename to casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/UserWebAdapter.kt index c2cefb4..a324a9e 100644 --- a/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/UserController.kt +++ b/casper-user/src/main/kotlin/hs/kr/entrydsm/user/domain/user/adapter/in/web/UserWebAdapter.kt @@ -47,7 +47,7 @@ import java.util.UUID */ @RequestMapping("/user") @RestController -class UserController( +class UserWebAdapter( private val userSignupUseCase: UserSignupUseCase, private val userLoginUseCase: UserLoginUseCase, private val changePasswordUseCase: ChangePasswordUseCase,