diff --git a/module-app/src/main/resources/logback-spring.xml b/module-app/src/main/resources/logback-spring.xml index 53a5324f..72d9f8df 100644 --- a/module-app/src/main/resources/logback-spring.xml +++ b/module-app/src/main/resources/logback-spring.xml @@ -1,16 +1,15 @@ + + converterClass="co.kr.pinhouse.common.logging.SanitizedMessageConverter"/> - %d{yyyy-MM-dd HH:mm:ss.SSS} level=%-5level [%thread] [%X{traceId:-},%X{spanId:-}] %logger{36} - - %sanitizedMsg%n - + %d{yyyy-MM-dd HH:mm:ss.SSS} level=%-5level [%thread] [%X{traceId:-},%X{spanId:-}] %logger{36} - %sanitizedMsg%n @@ -29,3 +28,4 @@ + diff --git a/module-common/src/main/java/co/kr/pinhouse/common/exception/code/UserErrorCode.java b/module-common/src/main/java/co/kr/pinhouse/common/exception/code/UserErrorCode.java index 60aee473..08cb7e1f 100644 --- a/module-common/src/main/java/co/kr/pinhouse/common/exception/code/UserErrorCode.java +++ b/module-common/src/main/java/co/kr/pinhouse/common/exception/code/UserErrorCode.java @@ -28,6 +28,7 @@ public enum UserErrorCode implements ErrorCode { // ======================== // 403 Forbidden // ======================== + FORBIDDEN_SELF_ROLE_CHANGE(403_200, HttpStatus.FORBIDDEN, "자신의 권한은 변경할 수 없습니다."), // ======================== // 404 Not Found diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/domain/entity/AdminAuditTargetType.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/domain/entity/AdminAuditTargetType.java index d15b3986..c3da509e 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/domain/entity/AdminAuditTargetType.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/domain/entity/AdminAuditTargetType.java @@ -2,6 +2,7 @@ public enum AdminAuditTargetType { NOTICE, + USER, CS_INQUIRY, ADVERTISEMENT } diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/user/application/dto/request/UpdateAdminUserRoleRequest.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/user/application/dto/request/UpdateAdminUserRoleRequest.java new file mode 100644 index 00000000..02242a32 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/user/application/dto/request/UpdateAdminUserRoleRequest.java @@ -0,0 +1,9 @@ +package co.kr.pinhouse.domain.admin.user.application.dto.request; + +import co.kr.pinhouse.domain.user.domain.entity.Role; +import jakarta.validation.constraints.NotNull; + +public record UpdateAdminUserRoleRequest( + @NotNull Role role +) { +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/user/application/service/AdminUserService.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/user/application/service/AdminUserService.java index a0df83aa..bb275f99 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/user/application/service/AdminUserService.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/user/application/service/AdminUserService.java @@ -11,14 +11,20 @@ import co.kr.pinhouse.common.response.CustomException; import co.kr.pinhouse.common.response.pageable.SliceRequest; import co.kr.pinhouse.common.response.pageable.SliceResponse; +import co.kr.pinhouse.domain.admin.application.usecase.AdminSessionUseCase; +import co.kr.pinhouse.domain.admin.audit.application.usecase.AdminAuditLogUseCase; +import co.kr.pinhouse.domain.admin.audit.domain.entity.AdminAuditActionType; +import co.kr.pinhouse.domain.admin.audit.domain.entity.AdminAuditTargetType; import co.kr.pinhouse.domain.admin.user.application.dto.response.AdminUserDetailResponse; import co.kr.pinhouse.domain.admin.user.application.dto.response.AdminUserSummaryResponse; import co.kr.pinhouse.domain.admin.user.application.usecase.AdminUserUseCase; import co.kr.pinhouse.domain.diagnostic.diagnosis.domain.repository.DiagnosisJpaRepository; import co.kr.pinhouse.domain.like.domain.LikeJpaRepository; import co.kr.pinhouse.domain.pinpoint.domain.repository.PinPointMongoRepository; +import co.kr.pinhouse.domain.user.domain.entity.Role; import co.kr.pinhouse.domain.user.domain.entity.User; import co.kr.pinhouse.domain.user.domain.repository.UserJpaRepository; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; @Service @@ -29,6 +35,8 @@ public class AdminUserService implements AdminUserUseCase { private final LikeJpaRepository likeRepository; private final PinPointMongoRepository pinPointRepository; private final DiagnosisJpaRepository diagnosisRepository; + private final AdminSessionUseCase adminSessionService; + private final AdminAuditLogUseCase adminAuditLogService; // ================= // 퍼블릭 로직 @@ -57,23 +65,72 @@ public SliceResponse getUsers(String keyword, SliceReq @Transactional(readOnly = true) @Override public AdminUserDetailResponse getUser(UUID userId) { - User user = userRepository.findWithFacilityTypesById(userId) - .orElseThrow(() -> new CustomException(UserErrorCode.NOT_FOUND_USER)); + User user = loadUserWithFacilityTypes(userId); + + return toAdminUserDetailResponse(user); + } + + /// 관리자 유저 권한 변경 + @Transactional + @Override + public AdminUserDetailResponse updateUserRole( + UUID userId, + Role role, + UUID adminId, + HttpServletRequest httpServletRequest + ) { + adminSessionService.loadAdmin(adminId); + + if (adminId.equals(userId)) { + throw new CustomException(UserErrorCode.FORBIDDEN_SELF_ROLE_CHANGE); + } + + User user = loadUserWithFacilityTypes(userId); + AdminUserDetailResponse before = toAdminUserDetailResponse(user); + + if (user.getRole() == role) { + return before; + } + + user.changeRole(role); + AdminUserDetailResponse after = toAdminUserDetailResponse(user); + + adminAuditLogService.log( + adminId, + AdminAuditActionType.UPDATE, + AdminAuditTargetType.USER, + userId.toString(), + "유저 권한 변경", + before, + after, + httpServletRequest + ); + + return after; + } + + // ================= + // 내부 로직 + // ================= + /// 관리자 유저 상세 응답 변환 + private AdminUserDetailResponse toAdminUserDetailResponse(User user) { return AdminUserDetailResponse.of( user, maskName(user.getName()), maskEmail(user.getEmail()), maskPhone(user.getPhoneNumber()), - likeRepository.countByUser_Id(userId), - pinPointRepository.countByUserId(userId.toString()), - diagnosisRepository.countByUser_Id(userId) + likeRepository.countByUser_Id(user.getId()), + pinPointRepository.countByUserId(user.getId().toString()), + diagnosisRepository.countByUser_Id(user.getId()) ); } - // ================= - // 내부 로직 - // ================= + /// 유저 상세 조회 + private User loadUserWithFacilityTypes(UUID userId) { + return userRepository.findWithFacilityTypesById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.NOT_FOUND_USER)); + } /// 이름 마스킹 private String maskName(String name) { diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/user/application/usecase/AdminUserUseCase.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/user/application/usecase/AdminUserUseCase.java index c0a6a2f4..2afbe596 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/user/application/usecase/AdminUserUseCase.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/user/application/usecase/AdminUserUseCase.java @@ -6,6 +6,8 @@ import co.kr.pinhouse.common.response.pageable.SliceResponse; import co.kr.pinhouse.domain.admin.user.application.dto.response.AdminUserDetailResponse; import co.kr.pinhouse.domain.admin.user.application.dto.response.AdminUserSummaryResponse; +import co.kr.pinhouse.domain.user.domain.entity.Role; +import jakarta.servlet.http.HttpServletRequest; public interface AdminUserUseCase { @@ -18,4 +20,7 @@ public interface AdminUserUseCase { /// 관리자 유저 상세 조회 AdminUserDetailResponse getUser(UUID userId); + + /// 관리자 유저 권한 변경 + AdminUserDetailResponse updateUserRole(UUID userId, Role role, UUID adminId, HttpServletRequest httpServletRequest); } diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/user/domain/entity/Role.java b/module-domain/src/main/java/co/kr/pinhouse/domain/user/domain/entity/Role.java index 78aeef05..22ac65f3 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/user/domain/entity/Role.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/user/domain/entity/Role.java @@ -19,8 +19,13 @@ public enum Role { /// JSON에서 생성 @JsonCreator public static Role fromLabel(String label) { + if (label == null || label.isBlank()) { + throw new CustomException(UserErrorCode.BAD_REQUEST_ROLE); + } + + String normalizedLabel = label.trim(); for (Role userRole : values()) { - if (userRole.label.equals(label)) { + if (userRole.label.equals(normalizedLabel) || userRole.name().equalsIgnoreCase(normalizedLabel)) { return userRole; } } diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/user/domain/entity/User.java b/module-domain/src/main/java/co/kr/pinhouse/domain/user/domain/entity/User.java index fb231371..cdb2d923 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/user/domain/entity/User.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/user/domain/entity/User.java @@ -159,4 +159,11 @@ public void updateFacilityTypes(List facilityTypes) { this.facilityTypes = new ArrayList<>(facilityTypes); } } + + /// 권한 업데이트 + public void changeRole(Role role) { + if (role != null) { + this.role = role; + } + } } diff --git a/module-app/src/main/java/co/kr/pinhouse/app/logging/SanitizedMessageConverter.java b/module-infrastructure/src/main/java/co/kr/pinhouse/common/logging/SanitizedMessageConverter.java similarity index 90% rename from module-app/src/main/java/co/kr/pinhouse/app/logging/SanitizedMessageConverter.java rename to module-infrastructure/src/main/java/co/kr/pinhouse/common/logging/SanitizedMessageConverter.java index 68f6931c..5467804d 100644 --- a/module-app/src/main/java/co/kr/pinhouse/app/logging/SanitizedMessageConverter.java +++ b/module-infrastructure/src/main/java/co/kr/pinhouse/common/logging/SanitizedMessageConverter.java @@ -1,4 +1,4 @@ -package co.kr.pinhouse.app.logging; +package co.kr.pinhouse.common.logging; import ch.qos.logback.classic.pattern.MessageConverter; import ch.qos.logback.classic.spi.ILoggingEvent; diff --git a/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/user/AdminUserApi.java b/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/user/AdminUserApi.java index 17d0be49..669b8ce0 100644 --- a/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/user/AdminUserApi.java +++ b/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/user/AdminUserApi.java @@ -3,19 +3,25 @@ import java.util.UUID; 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.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import co.kr.pinhouse.common.auth.CurrentUserId; import co.kr.pinhouse.common.response.ApiResponse; import co.kr.pinhouse.common.response.pageable.SliceRequest; import co.kr.pinhouse.common.response.pageable.SliceResponse; +import co.kr.pinhouse.domain.admin.user.application.dto.request.UpdateAdminUserRoleRequest; import co.kr.pinhouse.domain.admin.user.application.dto.response.AdminUserDetailResponse; import co.kr.pinhouse.domain.admin.user.application.dto.response.AdminUserSummaryResponse; import co.kr.pinhouse.domain.admin.user.application.usecase.AdminUserUseCase; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @RestController @@ -42,4 +48,16 @@ public ApiResponse> getUsers( public ApiResponse getUser(@PathVariable UUID userId) { return ApiResponse.ok(adminUserService.getUser(userId)); } + + /// 관리자 유저 권한 변경 + @PatchMapping("/{userId}/role") + @Operation(summary = "관리자 유저 권한 변경", description = "유저의 권한을 변경합니다.") + public ApiResponse updateUserRole( + @PathVariable UUID userId, + @RequestBody @Valid UpdateAdminUserRoleRequest request, + @CurrentUserId(required = true) UUID adminId, + HttpServletRequest httpServletRequest + ) { + return ApiResponse.ok(adminUserService.updateUserRole(userId, request.role(), adminId, httpServletRequest)); + } } diff --git a/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/response/AuthExchangeResponse.java b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/response/AuthExchangeResponse.java index 7136d5d2..a3f48687 100644 --- a/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/response/AuthExchangeResponse.java +++ b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/response/AuthExchangeResponse.java @@ -1,39 +1,48 @@ package co.kr.pinhouse.security.auth.application.dto.response; -import com.fasterxml.jackson.annotation.JsonInclude; - import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Builder; - -@JsonInclude(JsonInclude.Include.NON_NULL) -@Schema(name = "[응답][인증] Exchange Code 교환 응답", description = "Exchange Code 해석 결과를 반환합니다.") -@Builder -public record AuthExchangeResponse( - @Schema(description = "Exchange Code 처리 결과", example = "TOKEN_ISSUED") - AuthExchangeResultType result, - - @Schema(description = "발급된 액세스 토큰", example = "eyJhbGciOiJIUzI1NiJ9...") - String accessToken, - - @Schema(description = "발급된 리프레쉬 토큰", example = "eyJhbGciOiJIUzI1NiJ9...") - String refreshToken, - - @Schema(description = "회원가입이 필요한 경우 사용할 임시 키", example = "OAUTH2_TEMP_USER:1234...") - String tempKey -) { - - public static AuthExchangeResponse tokenIssued(String accessToken, String refreshToken) { - return AuthExchangeResponse.builder() - .result(AuthExchangeResultType.TOKEN_ISSUED) - .accessToken(accessToken) - .refreshToken(refreshToken) - .build(); + +@Schema( + name = "[응답][인증] Exchange Code 교환 응답", + description = "Exchange Code 해석 결과를 반환합니다.", + oneOf = { + TokenIssuedAuthExchangeResponse.class, + SignupRequiredAuthExchangeResponse.class + } +) +public interface AuthExchangeResponse { + + AuthExchangeResultType result(); + + default String accessToken() { + return null; + } + + default String refreshToken() { + return null; + } + + default String pinpointId() { + return null; + } + + default String tempKey() { + return null; + } + + static AuthExchangeResponse tokenIssued(String accessToken, String refreshToken, String pinpointId) { + return new TokenIssuedAuthExchangeResponse( + AuthExchangeResultType.TOKEN_ISSUED, + accessToken, + refreshToken, + pinpointId + ); } - public static AuthExchangeResponse signupRequired(String tempKey) { - return AuthExchangeResponse.builder() - .result(AuthExchangeResultType.SIGNUP_REQUIRED) - .tempKey(tempKey) - .build(); + static AuthExchangeResponse signupRequired(String tempKey) { + return new SignupRequiredAuthExchangeResponse( + AuthExchangeResultType.SIGNUP_REQUIRED, + tempKey + ); } } diff --git a/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/response/SignupRequiredAuthExchangeResponse.java b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/response/SignupRequiredAuthExchangeResponse.java new file mode 100644 index 00000000..ae29f150 --- /dev/null +++ b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/response/SignupRequiredAuthExchangeResponse.java @@ -0,0 +1,16 @@ +package co.kr.pinhouse.security.auth.application.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(name = "[응답][인증] 회원가입 필요 응답", description = "회원가입이 필요한 상태를 반환합니다.") +public record SignupRequiredAuthExchangeResponse( + @Schema(description = "Exchange Code 처리 결과", example = "SIGNUP_REQUIRED") + AuthExchangeResultType result, + + @Schema(description = "회원가입이 필요한 경우 사용할 임시 키", example = "OAUTH2_TEMP_USER:1234...") + String tempKey +) implements AuthExchangeResponse { +} diff --git a/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/response/TokenIssuedAuthExchangeResponse.java b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/response/TokenIssuedAuthExchangeResponse.java new file mode 100644 index 00000000..f03fcddb --- /dev/null +++ b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/response/TokenIssuedAuthExchangeResponse.java @@ -0,0 +1,23 @@ +package co.kr.pinhouse.security.auth.application.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(name = "[응답][인증] 토큰 발급 응답", description = "토큰 발급 완료 상태를 반환합니다.") +public record TokenIssuedAuthExchangeResponse( + @Schema(description = "Exchange Code 처리 결과", example = "TOKEN_ISSUED") + AuthExchangeResultType result, + + @Schema(description = "발급된 액세스 토큰", example = "eyJhbGciOiJIUzI1NiJ9...") + String accessToken, + + @Schema(description = "발급된 리프레쉬 토큰", example = "eyJhbGciOiJIUzI1NiJ9...") + String refreshToken, + + @Schema(description = "로그인 완료 시 대표 PinPoint ID", example = "f47ac10b-58cc-4372-a567-0e02b2c3d479") + @JsonInclude(JsonInclude.Include.ALWAYS) + String pinpointId +) implements AuthExchangeResponse { +} diff --git a/module-security/src/main/java/co/kr/pinhouse/security/auth/application/service/AuthService.java b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/service/AuthService.java index 53289073..6451613e 100644 --- a/module-security/src/main/java/co/kr/pinhouse/security/auth/application/service/AuthService.java +++ b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/service/AuthService.java @@ -14,6 +14,7 @@ import co.kr.pinhouse.common.exception.code.SecurityErrorCode; import co.kr.pinhouse.common.response.CustomException; import co.kr.pinhouse.common.util.KeyUtil; +import co.kr.pinhouse.domain.pinpoint.application.usecase.PinPointUseCase; import co.kr.pinhouse.domain.user.domain.entity.User; import co.kr.pinhouse.domain.user.domain.onboarding.TempUserInfo; import co.kr.pinhouse.domain.user.domain.repository.UserJpaRepository; @@ -38,6 +39,7 @@ public class AuthService implements AuthUseCase { /// 유저 저장소 private final UserJpaRepository repository; + private final PinPointUseCase pinPointUseCase; /// 토큰 의존성 private final JwtValidator jwtValidator; @@ -183,7 +185,20 @@ public AuthExchangeResponse exchangeCode(String exchangeCode) { /// 토큰 발급 JwtTokenResponse tokenResponse = issueTokens(user); - return AuthExchangeResponse.tokenIssued(tokenResponse.accessToken(), tokenResponse.refreshToken()); + String pinpointId = resolvePrimaryPinPointId(user.getId()); + return AuthExchangeResponse.tokenIssued( + tokenResponse.accessToken(), + tokenResponse.refreshToken(), + pinpointId + ); + } + + private String resolvePrimaryPinPointId(UUID userId) { + return pinPointUseCase.loadPinPoints(userId).pinPoints().stream() + .filter(pinPoint -> pinPoint.isFirst()) + .findFirst() + .map(pinPoint -> pinPoint.id()) + .orElse(null); } }