From eb5520293f65fe3c6b2f3828fc2ab6ffbf6ee86a Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Sun, 1 Feb 2026 23:42:17 +0900 Subject: [PATCH 1/7] =?UTF-8?q?chore:=20claude=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20gitignore=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6e9e5d44..a4ee1643 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,8 @@ src/main/generated/ /.cursor/ ### AntiGravity ### -/.agent/ \ No newline at end of file +/.agent/ + +### Claude ### +/.claude/ +CLAUDE.md From 85ab07b1e178876ec0896dd1051f2056efa20169 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Thu, 5 Feb 2026 21:15:15 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20User=20=EC=97=94=ED=8B=B0=ED=8B=B0?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=20=EB=A9=94=EC=86=8C?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=20=ED=91=9C=ED=98=84=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - User 엔티티에 상태 변경 메소드 추가 - UserGender, UserRole, UserStatus에 설명 메소드 추가 --- .../com/dreamteam/alter/domain/user/entity/User.java | 12 ++++++++++++ .../dreamteam/alter/domain/user/type/UserGender.java | 9 +++++++++ .../dreamteam/alter/domain/user/type/UserRole.java | 10 ++++++++++ .../dreamteam/alter/domain/user/type/UserStatus.java | 10 ++++++++++ 4 files changed, 41 insertions(+) diff --git a/src/main/java/com/dreamteam/alter/domain/user/entity/User.java b/src/main/java/com/dreamteam/alter/domain/user/entity/User.java index 435470f3..1499cc03 100644 --- a/src/main/java/com/dreamteam/alter/domain/user/entity/User.java +++ b/src/main/java/com/dreamteam/alter/domain/user/entity/User.java @@ -115,4 +115,16 @@ public void addUserSocial(UserSocial userSocial) { public void updatePassword(String encodedPassword) { this.password = encodedPassword; } + + /** + * 회원 상태를 변경합니다. + * + * @param newStatus 변경할 상태 + */ + public void updateStatus(UserStatus newStatus) { + if (this.status.equals(newStatus)) { + throw new IllegalArgumentException("이미 동일한 상태입니다."); + } + this.status = newStatus; + } } diff --git a/src/main/java/com/dreamteam/alter/domain/user/type/UserGender.java b/src/main/java/com/dreamteam/alter/domain/user/type/UserGender.java index 5ab34532..ea36649f 100644 --- a/src/main/java/com/dreamteam/alter/domain/user/type/UserGender.java +++ b/src/main/java/com/dreamteam/alter/domain/user/type/UserGender.java @@ -1,7 +1,16 @@ package com.dreamteam.alter.domain.user.type; +import java.util.Map; + public enum UserGender { GENDER_MALE, GENDER_FEMALE ; + + public static Map describe() { + return Map.of( + UserGender.GENDER_MALE, "남성", + UserGender.GENDER_FEMALE, "여성" + ); + } } diff --git a/src/main/java/com/dreamteam/alter/domain/user/type/UserRole.java b/src/main/java/com/dreamteam/alter/domain/user/type/UserRole.java index 9eedb5d1..1ae5820a 100644 --- a/src/main/java/com/dreamteam/alter/domain/user/type/UserRole.java +++ b/src/main/java/com/dreamteam/alter/domain/user/type/UserRole.java @@ -1,8 +1,18 @@ package com.dreamteam.alter.domain.user.type; +import java.util.Map; + public enum UserRole { ROLE_USER, ROLE_MANAGER, ROLE_ADMIN ; + + public static Map describe() { + return Map.of( + UserRole.ROLE_USER, "일반 사용자", + UserRole.ROLE_MANAGER, "매니저", + UserRole.ROLE_ADMIN, "관리자" + ); + } } diff --git a/src/main/java/com/dreamteam/alter/domain/user/type/UserStatus.java b/src/main/java/com/dreamteam/alter/domain/user/type/UserStatus.java index 639494b9..8ba129fa 100644 --- a/src/main/java/com/dreamteam/alter/domain/user/type/UserStatus.java +++ b/src/main/java/com/dreamteam/alter/domain/user/type/UserStatus.java @@ -1,8 +1,18 @@ package com.dreamteam.alter.domain.user.type; +import java.util.Map; + public enum UserStatus { ACTIVE, SUSPENDED, DELETED ; + + public static Map describe() { + return Map.of( + UserStatus.ACTIVE, "활성", + UserStatus.SUSPENDED, "정지", + UserStatus.DELETED, "삭제됨" + ); + } } From 8f83d1bb662dbbc061976540ff1ecf808a8ef620 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Thu, 5 Feb 2026 21:15:41 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EA=B4=80=EB=A6=AC=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사용자 목록 조회 (필터링, 페이징) - 사용자 상세 조회 (평판 키워드 포함) - 사용자 비밀번호 변경 - 사용자 상태 변경 (활성/정지) --- .../user/controller/AdminUserController.java | 88 +++++++++++ .../controller/AdminUserControllerSpec.java | 114 ++++++++++++++ .../AdminUpdateUserPasswordRequestDto.java | 18 +++ .../dto/AdminUpdateUserStatusRequestDto.java | 19 +++ .../user/dto/AdminUserDetailResponseDto.java | 76 ++++++++++ .../user/dto/AdminUserListFilterDto.java | 38 +++++ .../user/dto/AdminUserListResponseDto.java | 55 +++++++ .../dto/AdminUserReputationKeywordDto.java | 44 ++++++ .../dto/AdminUserReputationSummaryDto.java | 33 ++++ .../AdminUserQueryRepositoryImpl.java | 142 ++++++++++++++++++ .../readonly/AdminUserDetailResponse.java | 27 ++++ .../readonly/AdminUserListResponse.java | 20 +++ .../user/usecase/AdminGetUserDetail.java | 25 +++ .../user/usecase/AdminGetUserList.java | 47 ++++++ .../user/usecase/AdminUpdateUserPassword.java | 38 +++++ .../user/usecase/AdminUpdateUserStatus.java | 32 ++++ .../inbound/AdminGetUserDetailUseCase.java | 8 + .../port/inbound/AdminGetUserListUseCase.java | 15 ++ .../AdminUpdateUserPasswordUseCase.java | 8 + .../inbound/AdminUpdateUserStatusUseCase.java | 8 + .../outbound/AdminUserQueryRepository.java | 20 +++ 21 files changed, 875 insertions(+) create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/controller/AdminUserController.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/controller/AdminUserControllerSpec.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUpdateUserPasswordRequestDto.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUpdateUserStatusRequestDto.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUserDetailResponseDto.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUserListFilterDto.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUserListResponseDto.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUserReputationKeywordDto.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUserReputationSummaryDto.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/AdminUserQueryRepositoryImpl.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/readonly/AdminUserDetailResponse.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/readonly/AdminUserListResponse.java create mode 100644 src/main/java/com/dreamteam/alter/application/user/usecase/AdminGetUserDetail.java create mode 100644 src/main/java/com/dreamteam/alter/application/user/usecase/AdminGetUserList.java create mode 100644 src/main/java/com/dreamteam/alter/application/user/usecase/AdminUpdateUserPassword.java create mode 100644 src/main/java/com/dreamteam/alter/application/user/usecase/AdminUpdateUserStatus.java create mode 100644 src/main/java/com/dreamteam/alter/domain/user/port/inbound/AdminGetUserDetailUseCase.java create mode 100644 src/main/java/com/dreamteam/alter/domain/user/port/inbound/AdminGetUserListUseCase.java create mode 100644 src/main/java/com/dreamteam/alter/domain/user/port/inbound/AdminUpdateUserPasswordUseCase.java create mode 100644 src/main/java/com/dreamteam/alter/domain/user/port/inbound/AdminUpdateUserStatusUseCase.java create mode 100644 src/main/java/com/dreamteam/alter/domain/user/port/outbound/AdminUserQueryRepository.java diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/controller/AdminUserController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/controller/AdminUserController.java new file mode 100644 index 00000000..f6d718ac --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/controller/AdminUserController.java @@ -0,0 +1,88 @@ +package com.dreamteam.alter.adapter.inbound.admin.user.controller; + +import com.dreamteam.alter.adapter.inbound.admin.user.dto.AdminUpdateUserPasswordRequestDto; +import com.dreamteam.alter.adapter.inbound.admin.user.dto.AdminUpdateUserStatusRequestDto; +import com.dreamteam.alter.adapter.inbound.admin.user.dto.AdminUserDetailResponseDto; +import com.dreamteam.alter.adapter.inbound.admin.user.dto.AdminUserListFilterDto; +import com.dreamteam.alter.adapter.inbound.admin.user.dto.AdminUserListResponseDto; +import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse; +import com.dreamteam.alter.adapter.inbound.common.dto.PageRequestDto; +import com.dreamteam.alter.adapter.inbound.common.dto.PaginatedResponseDto; +import com.dreamteam.alter.application.aop.AdminActionContext; +import com.dreamteam.alter.domain.user.context.AdminActor; +import com.dreamteam.alter.domain.user.port.inbound.AdminGetUserDetailUseCase; +import com.dreamteam.alter.domain.user.port.inbound.AdminGetUserListUseCase; +import com.dreamteam.alter.domain.user.port.inbound.AdminUpdateUserPasswordUseCase; +import com.dreamteam.alter.domain.user.port.inbound.AdminUpdateUserStatusUseCase; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/admin/users") +@PreAuthorize("hasAnyRole('ADMIN')") +@RequiredArgsConstructor +@Validated +public class AdminUserController implements AdminUserControllerSpec { + + @Resource(name = "adminGetUserList") + private final AdminGetUserListUseCase adminGetUserList; + + @Resource(name = "adminGetUserDetail") + private final AdminGetUserDetailUseCase adminGetUserDetail; + + @Resource(name = "adminUpdateUserPassword") + private final AdminUpdateUserPasswordUseCase adminUpdateUserPassword; + + @Resource(name = "adminUpdateUserStatus") + private final AdminUpdateUserStatusUseCase adminUpdateUserStatus; + + @Override + @GetMapping + public ResponseEntity> getUserList( + PageRequestDto request, + AdminUserListFilterDto filter + ) { + AdminActor actor = AdminActionContext.getInstance().getActor(); + + return ResponseEntity.ok(adminGetUserList.execute(request, filter, actor)); + } + + @Override + @GetMapping("/{userId}") + public ResponseEntity> getUserDetail( + @PathVariable Long userId + ) { + AdminActor actor = AdminActionContext.getInstance().getActor(); + + return ResponseEntity.ok(CommonApiResponse.of(adminGetUserDetail.execute(userId, actor))); + } + + @Override + @PutMapping("/{userId}/password") + public ResponseEntity> updateUserPassword( + @PathVariable Long userId, + @Valid @RequestBody AdminUpdateUserPasswordRequestDto request + ) { + AdminActor actor = AdminActionContext.getInstance().getActor(); + + adminUpdateUserPassword.execute(userId, request, actor); + return ResponseEntity.ok(CommonApiResponse.empty()); + } + + @Override + @PutMapping("/{userId}/status") + public ResponseEntity> updateUserStatus( + @PathVariable Long userId, + @Valid @RequestBody AdminUpdateUserStatusRequestDto request + ) { + AdminActor actor = AdminActionContext.getInstance().getActor(); + + adminUpdateUserStatus.execute(userId, request, actor); + return ResponseEntity.ok(CommonApiResponse.empty()); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/controller/AdminUserControllerSpec.java b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/controller/AdminUserControllerSpec.java new file mode 100644 index 00000000..1ddde687 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/controller/AdminUserControllerSpec.java @@ -0,0 +1,114 @@ +package com.dreamteam.alter.adapter.inbound.admin.user.controller; + +import com.dreamteam.alter.adapter.inbound.admin.user.dto.AdminUpdateUserPasswordRequestDto; +import com.dreamteam.alter.adapter.inbound.admin.user.dto.AdminUpdateUserStatusRequestDto; +import com.dreamteam.alter.adapter.inbound.admin.user.dto.AdminUserDetailResponseDto; +import com.dreamteam.alter.adapter.inbound.admin.user.dto.AdminUserListFilterDto; +import com.dreamteam.alter.adapter.inbound.admin.user.dto.AdminUserListResponseDto; +import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse; +import com.dreamteam.alter.adapter.inbound.common.dto.ErrorResponse; +import com.dreamteam.alter.adapter.inbound.common.dto.PageRequestDto; +import com.dreamteam.alter.adapter.inbound.common.dto.PaginatedResponseDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "ADMIN - 관리자 회원 관리 API") +public interface AdminUserControllerSpec { + + @Operation(summary = "회원 목록 조회", description = "관리자가 회원 목록을 오프셋 페이징으로 조회합니다. 상태, 역할, 키워드로 필터링할 수 있습니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "회원 목록 조회 성공"), + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "잘못된 요청 (유효하지 않은 페이지, 페이지 크기 등)", + value = "{\"code\" : \"B001\"}" + ) + })) + }) + ResponseEntity> getUserList( + PageRequestDto request, + AdminUserListFilterDto filter + ); + + @Operation(summary = "회원 상세 조회", description = "관리자가 회원 상세 정보를 조회합니다. 평판 정보가 포함됩니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "회원 상세 조회 성공"), + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "존재하지 않는 회원", + value = "{\"code\" : \"B011\"}" + ) + })) + }) + ResponseEntity> getUserDetail( + @Parameter(description = "회원 ID", example = "1") @PathVariable Long userId + ); + + @Operation(summary = "회원 비밀번호 변경", description = "관리자가 회원 비밀번호를 변경합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "회원 비밀번호 변경 성공"), + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "존재하지 않는 회원", + value = "{\"code\" : \"B011\"}" + ), + @ExampleObject( + name = "잘못된 비밀번호 형식", + value = "{\"code\" : \"B002\"}" + ), + @ExampleObject( + name = "잘못된 요청 (유효성 검증 실패)", + value = "{\"code\" : \"B001\"}" + ) + })) + }) + ResponseEntity> updateUserPassword( + @Parameter(description = "회원 ID", example = "1") @PathVariable Long userId, + @Valid @RequestBody AdminUpdateUserPasswordRequestDto request + ); + + @Operation(summary = "회원 상태 변경", description = "관리자가 회원 상태를 변경합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "회원 상태 변경 성공"), + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "존재하지 않는 회원", + value = "{\"code\" : \"B011\"}" + ), + @ExampleObject( + name = "잘못된 요청 (유효하지 않은 상태 값 등)", + value = "{\"code\" : \"B001\"}" + ) + })) + }) + ResponseEntity> updateUserStatus( + @Parameter(description = "회원 ID", example = "1") @PathVariable Long userId, + @Valid @RequestBody AdminUpdateUserStatusRequestDto request + ); +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUpdateUserPasswordRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUpdateUserPasswordRequestDto.java new file mode 100644 index 00000000..d6aaeac0 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUpdateUserPasswordRequestDto.java @@ -0,0 +1,18 @@ +package com.dreamteam.alter.adapter.inbound.admin.user.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "회원 비밀번호 갱신 요청 DTO") +public class AdminUpdateUserPasswordRequestDto { + + @NotBlank + @Schema(description = "새 비밀번호 (영문, 숫자, 특수문자 포함 8-20자)") + private String newPassword; +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUpdateUserStatusRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUpdateUserStatusRequestDto.java new file mode 100644 index 00000000..1bf65b33 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUpdateUserStatusRequestDto.java @@ -0,0 +1,19 @@ +package com.dreamteam.alter.adapter.inbound.admin.user.dto; + +import com.dreamteam.alter.domain.user.type.UserStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "회원 상태 변경 요청 DTO") +public class AdminUpdateUserStatusRequestDto { + + @NotNull + @Schema(description = "변경할 상태") + private UserStatus status; +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUserDetailResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUserDetailResponseDto.java new file mode 100644 index 00000000..d7be93e3 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUserDetailResponseDto.java @@ -0,0 +1,76 @@ +package com.dreamteam.alter.adapter.inbound.admin.user.dto; + +import com.dreamteam.alter.adapter.inbound.common.dto.DescribedEnumDto; +import com.dreamteam.alter.adapter.outbound.user.persistence.readonly.AdminUserDetailResponse; +import com.dreamteam.alter.domain.user.type.UserGender; +import com.dreamteam.alter.domain.user.type.UserRole; +import com.dreamteam.alter.domain.user.type.UserStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +@Schema(description = "회원 상세 응답 DTO") +public class AdminUserDetailResponseDto { + + @Schema(description = "회원 ID", example = "1") + private Long id; + + @Schema(description = "이메일", example = "user@example.com") + private String email; + + @Schema(description = "이름", example = "홍길동") + private String name; + + @Schema(description = "닉네임", example = "알터유저") + private String nickname; + + @Schema(description = "연락처", example = "010-1234-5678") + private String contact; + + @Schema(description = "생년월일", example = "19900101") + private String birthday; + + @Schema(description = "성별") + private DescribedEnumDto gender; + + @Schema(description = "역할") + private DescribedEnumDto role; + + @Schema(description = "상태") + private DescribedEnumDto status; + + @Schema(description = "생성일시", example = "2025-01-01T12:00:00") + private LocalDateTime createdAt; + + @Schema(description = "수정일시", example = "2025-01-01T12:00:00") + private LocalDateTime updatedAt; + + @Schema(description = "평판 요약") + private AdminUserReputationSummaryDto reputationSummary; + + public static AdminUserDetailResponseDto from(AdminUserDetailResponse response) { + return AdminUserDetailResponseDto.builder() + .id(response.getId()) + .email(response.getEmail()) + .name(response.getName()) + .nickname(response.getNickname()) + .contact(response.getContact()) + .birthday(response.getBirthday()) + .gender(DescribedEnumDto.of(response.getGender(), UserGender.describe())) + .role(DescribedEnumDto.of(response.getRole(), UserRole.describe())) + .status(DescribedEnumDto.of(response.getStatus(), UserStatus.describe())) + .createdAt(response.getCreatedAt()) + .updatedAt(response.getUpdatedAt()) + .reputationSummary(AdminUserReputationSummaryDto.from(response.getReputationSummary())) + .build(); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUserListFilterDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUserListFilterDto.java new file mode 100644 index 00000000..882386d4 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUserListFilterDto.java @@ -0,0 +1,38 @@ +package com.dreamteam.alter.adapter.inbound.admin.user.dto; + +import com.dreamteam.alter.domain.user.type.UserRole; +import com.dreamteam.alter.domain.user.type.UserStatus; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springdoc.core.annotations.ParameterObject; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@ParameterObject +@Schema(description = "회원 목록 필터 DTO") +public class AdminUserListFilterDto { + + @Parameter(description = "회원 상태") + private UserStatus status; + + @Parameter(description = "회원 역할") + private UserRole role; + + @Parameter(description = "이메일 검색어") + private String email; + + @Parameter(description = "이름 검색어") + private String name; + + @Parameter(description = "닉네임 검색어") + private String nickname; + + @Parameter(description = "연락처 검색어") + private String contact; +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUserListResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUserListResponseDto.java new file mode 100644 index 00000000..5bfb226a --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUserListResponseDto.java @@ -0,0 +1,55 @@ +package com.dreamteam.alter.adapter.inbound.admin.user.dto; + +import com.dreamteam.alter.adapter.inbound.common.dto.DescribedEnumDto; +import com.dreamteam.alter.adapter.outbound.user.persistence.readonly.AdminUserListResponse; +import com.dreamteam.alter.domain.user.type.UserRole; +import com.dreamteam.alter.domain.user.type.UserStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +@Schema(description = "회원 목록 응답 DTO") +public class AdminUserListResponseDto { + + @Schema(description = "회원 ID", example = "1") + private Long id; + + @Schema(description = "이메일", example = "user@example.com") + private String email; + + @Schema(description = "이름", example = "홍길동") + private String name; + + @Schema(description = "닉네임", example = "알터유저") + private String nickname; + + @Schema(description = "역할") + private DescribedEnumDto role; + + @Schema(description = "상태") + private DescribedEnumDto status; + + @Schema(description = "가입일시", example = "2025-01-01T12:00:00") + private LocalDateTime createdAt; + + public static AdminUserListResponseDto from(AdminUserListResponse response) { + return AdminUserListResponseDto.builder() + .id(response.getId()) + .email(response.getEmail()) + .name(response.getName()) + .nickname(response.getNickname()) + .role(DescribedEnumDto.of(response.getRole(), UserRole.describe())) + .status(DescribedEnumDto.of(response.getStatus(), UserStatus.describe())) + .createdAt(response.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUserReputationKeywordDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUserReputationKeywordDto.java new file mode 100644 index 00000000..69934cb4 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUserReputationKeywordDto.java @@ -0,0 +1,44 @@ +package com.dreamteam.alter.adapter.inbound.admin.user.dto; + +import com.dreamteam.alter.adapter.inbound.common.dto.reputation.KeywordSummaryDto; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +@Schema(description = "관리자용 평판 키워드 DTO") +public class AdminUserReputationKeywordDto { + + @NotNull + @Schema(description = "키워드 ID", example = "KIND01") + private String id; + + @NotBlank + @Schema(description = "이모지", example = "😊") + private String emoji; + + @NotBlank + @Schema(description = "키워드 설명", example = "친절해요") + private String description; + + @NotNull + @Schema(description = "개수", example = "5") + private Integer count; + + public static AdminUserReputationKeywordDto from(KeywordSummaryDto keywordSummary) { + return AdminUserReputationKeywordDto.builder() + .id(keywordSummary.getKeywordId()) + .emoji(keywordSummary.getEmoji()) + .description(keywordSummary.getDescription()) + .count(keywordSummary.getCount()) + .build(); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUserReputationSummaryDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUserReputationSummaryDto.java new file mode 100644 index 00000000..c8f74ccd --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/user/dto/AdminUserReputationSummaryDto.java @@ -0,0 +1,33 @@ +package com.dreamteam.alter.adapter.inbound.admin.user.dto; + +import com.dreamteam.alter.domain.reputation.entity.ReputationSummary; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; +import org.apache.commons.lang3.ObjectUtils; + +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +@Schema(description = "관리자용 회원 평판 요약 DTO") +public class AdminUserReputationSummaryDto { + + @Schema(description = "상위 5개 키워드") + private List topKeywords; + + public static AdminUserReputationSummaryDto from(ReputationSummary reputationSummary) { + if (ObjectUtils.isEmpty(reputationSummary) || ObjectUtils.isEmpty(reputationSummary.getTopKeywords())) { + return null; + } + + return AdminUserReputationSummaryDto.builder() + .topKeywords( + reputationSummary.getTopKeywords().stream() + .map(AdminUserReputationKeywordDto::from) + .toList() + ) + .build(); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/AdminUserQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/AdminUserQueryRepositoryImpl.java new file mode 100644 index 00000000..a8a0b358 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/AdminUserQueryRepositoryImpl.java @@ -0,0 +1,142 @@ +package com.dreamteam.alter.adapter.outbound.user.persistence; + +import com.dreamteam.alter.adapter.inbound.admin.user.dto.AdminUserListFilterDto; +import com.dreamteam.alter.adapter.inbound.common.dto.PageRequestDto; +import com.dreamteam.alter.adapter.outbound.user.persistence.readonly.AdminUserDetailResponse; +import com.dreamteam.alter.adapter.outbound.user.persistence.readonly.AdminUserListResponse; +import com.dreamteam.alter.domain.reputation.entity.QReputationSummary; +import com.dreamteam.alter.domain.reputation.type.ReputationType; +import com.dreamteam.alter.domain.user.entity.QUser; +import com.dreamteam.alter.domain.user.port.outbound.AdminUserQueryRepository; +import com.dreamteam.alter.domain.user.type.UserRole; +import com.dreamteam.alter.domain.user.type.UserStatus; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.ObjectUtils; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class AdminUserQueryRepositoryImpl implements AdminUserQueryRepository { + + private final JPAQueryFactory queryFactory; + private final QUser user = QUser.user; + private final QReputationSummary reputationSummary = QReputationSummary.reputationSummary; + + @Override + public long getUserCount(AdminUserListFilterDto filter) { + Long count = queryFactory + .select(user.count()) + .from(user) + .where( + user.status.ne(UserStatus.DELETED), + eqStatus(filter.getStatus()), + eqRole(filter.getRole()), + containsEmail(filter.getEmail()), + containsName(filter.getName()), + containsNickname(filter.getNickname()), + containsContact(filter.getContact()) + ) + .fetchOne(); + + return count != null ? count : 0L; + } + + @Override + public List getUserListUsingPagination( + PageRequestDto pageRequest, + AdminUserListFilterDto filter + ) { + return queryFactory + .select(Projections.constructor( + AdminUserListResponse.class, + user.id, + user.email, + user.name, + user.nickname, + user.role, + user.status, + user.createdAt + )) + .from(user) + .where( + user.status.ne(UserStatus.DELETED), + eqStatus(filter.getStatus()), + eqRole(filter.getRole()), + containsEmail(filter.getEmail()), + containsName(filter.getName()), + containsNickname(filter.getNickname()), + containsContact(filter.getContact()) + ) + .orderBy(user.createdAt.desc(), user.id.desc()) + .offset(pageRequest.getOffset()) + .limit(pageRequest.getLimit()) + .fetch(); + } + + @Override + public Optional getUserDetail(Long userId) { + AdminUserDetailResponse response = queryFactory + .select(Projections.constructor( + AdminUserDetailResponse.class, + user.id, + user.email, + user.name, + user.nickname, + user.contact, + user.birthday, + user.gender, + user.role, + user.status, + user.createdAt, + user.updatedAt, + reputationSummary + )) + .from(user) + .leftJoin(reputationSummary) + .on( + reputationSummary.targetType.eq(ReputationType.USER), + reputationSummary.targetId.eq(user.id) + ) + .where( + user.id.eq(userId), + user.status.ne(UserStatus.DELETED) + ) + .fetchOne(); + + return Optional.ofNullable(response); + } + + private BooleanExpression eqStatus(UserStatus status) { + return ObjectUtils.isNotEmpty(status) ? user.status.eq(status) : null; + } + + private BooleanExpression eqRole(UserRole role) { + return ObjectUtils.isNotEmpty(role) ? user.role.eq(role) : null; + } + + private BooleanExpression containsEmail(String email) { + return ObjectUtils.isNotEmpty(email) + ? user.email.containsIgnoreCase(email) : null; + } + + private BooleanExpression containsName(String name) { + return ObjectUtils.isNotEmpty(name) + ? user.name.containsIgnoreCase(name) : null; + } + + private BooleanExpression containsNickname(String nickname) { + return ObjectUtils.isNotEmpty(nickname) + ? user.nickname.containsIgnoreCase(nickname) : null; + } + + private BooleanExpression containsContact(String contact) { + return ObjectUtils.isNotEmpty(contact) + ? user.contact.containsIgnoreCase(contact) : null; + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/readonly/AdminUserDetailResponse.java b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/readonly/AdminUserDetailResponse.java new file mode 100644 index 00000000..21b06188 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/readonly/AdminUserDetailResponse.java @@ -0,0 +1,27 @@ +package com.dreamteam.alter.adapter.outbound.user.persistence.readonly; + +import com.dreamteam.alter.domain.reputation.entity.ReputationSummary; +import com.dreamteam.alter.domain.user.type.UserGender; +import com.dreamteam.alter.domain.user.type.UserRole; +import com.dreamteam.alter.domain.user.type.UserStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor +public class AdminUserDetailResponse { + private Long id; + private String email; + private String name; + private String nickname; + private String contact; + private String birthday; + private UserGender gender; + private UserRole role; + private UserStatus status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private ReputationSummary reputationSummary; +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/readonly/AdminUserListResponse.java b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/readonly/AdminUserListResponse.java new file mode 100644 index 00000000..93bb10bb --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/readonly/AdminUserListResponse.java @@ -0,0 +1,20 @@ +package com.dreamteam.alter.adapter.outbound.user.persistence.readonly; + +import com.dreamteam.alter.domain.user.type.UserRole; +import com.dreamteam.alter.domain.user.type.UserStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor +public class AdminUserListResponse { + private Long id; + private String email; + private String name; + private String nickname; + private UserRole role; + private UserStatus status; + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/AdminGetUserDetail.java b/src/main/java/com/dreamteam/alter/application/user/usecase/AdminGetUserDetail.java new file mode 100644 index 00000000..3eb8ee7b --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/AdminGetUserDetail.java @@ -0,0 +1,25 @@ +package com.dreamteam.alter.application.user.usecase; + +import com.dreamteam.alter.adapter.inbound.admin.user.dto.AdminUserDetailResponseDto; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.user.context.AdminActor; +import com.dreamteam.alter.domain.user.port.inbound.AdminGetUserDetailUseCase; +import com.dreamteam.alter.domain.user.port.outbound.AdminUserQueryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("adminGetUserDetail") +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminGetUserDetail implements AdminGetUserDetailUseCase { + + private final AdminUserQueryRepository adminUserQueryRepository; + + @Override + public AdminUserDetailResponseDto execute(Long userId, AdminActor actor) { + return AdminUserDetailResponseDto.from(adminUserQueryRepository.getUserDetail(userId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND, "사용자를 찾을 수 없습니다."))); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/AdminGetUserList.java b/src/main/java/com/dreamteam/alter/application/user/usecase/AdminGetUserList.java new file mode 100644 index 00000000..3b9ee152 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/AdminGetUserList.java @@ -0,0 +1,47 @@ +package com.dreamteam.alter.application.user.usecase; + +import com.dreamteam.alter.adapter.inbound.admin.user.dto.AdminUserListFilterDto; +import com.dreamteam.alter.adapter.inbound.admin.user.dto.AdminUserListResponseDto; +import com.dreamteam.alter.adapter.inbound.common.dto.PageRequestDto; +import com.dreamteam.alter.adapter.inbound.common.dto.PageResponseDto; +import com.dreamteam.alter.adapter.inbound.common.dto.PaginatedResponseDto; +import com.dreamteam.alter.adapter.outbound.user.persistence.readonly.AdminUserListResponse; +import com.dreamteam.alter.domain.user.context.AdminActor; +import com.dreamteam.alter.domain.user.port.inbound.AdminGetUserListUseCase; +import com.dreamteam.alter.domain.user.port.outbound.AdminUserQueryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service("adminGetUserList") +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminGetUserList implements AdminGetUserListUseCase { + + private final AdminUserQueryRepository adminUserQueryRepository; + + @Override + public PaginatedResponseDto execute( + PageRequestDto request, + AdminUserListFilterDto filter, + AdminActor actor + ) { + long count = adminUserQueryRepository.getUserCount(filter); + if (count == 0) { + return PaginatedResponseDto.empty(PageResponseDto.empty(request)); + } + + List users = adminUserQueryRepository.getUserListUsingPagination(request, filter); + + PageResponseDto pageResponseDto = PageResponseDto.of(request, (int) count); + + return PaginatedResponseDto.of( + pageResponseDto, + users.stream() + .map(AdminUserListResponseDto::from) + .toList() + ); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/AdminUpdateUserPassword.java b/src/main/java/com/dreamteam/alter/application/user/usecase/AdminUpdateUserPassword.java new file mode 100644 index 00000000..86b0b0f9 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/AdminUpdateUserPassword.java @@ -0,0 +1,38 @@ +package com.dreamteam.alter.application.user.usecase; + +import com.dreamteam.alter.adapter.inbound.admin.user.dto.AdminUpdateUserPasswordRequestDto; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.common.util.PasswordValidator; +import com.dreamteam.alter.domain.user.context.AdminActor; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.user.port.inbound.AdminUpdateUserPasswordUseCase; +import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("adminUpdateUserPassword") +@RequiredArgsConstructor +@Transactional +public class AdminUpdateUserPassword implements AdminUpdateUserPasswordUseCase { + + private final UserQueryRepository userQueryRepository; + private final PasswordEncoder passwordEncoder; + + @Override + public void execute(Long userId, AdminUpdateUserPasswordRequestDto request, AdminActor actor) { + // 사용자 조회 + User user = userQueryRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // 비밀번호 형식 검증 + if (!PasswordValidator.isValid(request.getNewPassword())) { + throw new CustomException(ErrorCode.INVALID_PASSWORD_FORMAT); + } + + // 비밀번호 업데이트 + user.updatePassword(passwordEncoder.encode(request.getNewPassword())); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/AdminUpdateUserStatus.java b/src/main/java/com/dreamteam/alter/application/user/usecase/AdminUpdateUserStatus.java new file mode 100644 index 00000000..410c3262 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/AdminUpdateUserStatus.java @@ -0,0 +1,32 @@ +package com.dreamteam.alter.application.user.usecase; + +import com.dreamteam.alter.adapter.inbound.admin.user.dto.AdminUpdateUserStatusRequestDto; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.user.context.AdminActor; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.user.port.inbound.AdminUpdateUserStatusUseCase; +import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("adminUpdateUserStatus") +@RequiredArgsConstructor +@Transactional +public class AdminUpdateUserStatus implements AdminUpdateUserStatusUseCase { + + private final UserQueryRepository userQueryRepository; + + @Override + public void execute(Long userId, AdminUpdateUserStatusRequestDto request, AdminActor actor) { + User user = userQueryRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND, "회원을 찾을 수 없습니다.")); + + try { + user.updateStatus(request.getStatus()); + } catch (IllegalArgumentException e) { + throw new CustomException(ErrorCode.CONFLICT, "현재 상태가 변경하고자 하는 상태와 동일합니다."); + } + } +} diff --git a/src/main/java/com/dreamteam/alter/domain/user/port/inbound/AdminGetUserDetailUseCase.java b/src/main/java/com/dreamteam/alter/domain/user/port/inbound/AdminGetUserDetailUseCase.java new file mode 100644 index 00000000..505c740b --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/user/port/inbound/AdminGetUserDetailUseCase.java @@ -0,0 +1,8 @@ +package com.dreamteam.alter.domain.user.port.inbound; + +import com.dreamteam.alter.adapter.inbound.admin.user.dto.AdminUserDetailResponseDto; +import com.dreamteam.alter.domain.user.context.AdminActor; + +public interface AdminGetUserDetailUseCase { + AdminUserDetailResponseDto execute(Long userId, AdminActor actor); +} diff --git a/src/main/java/com/dreamteam/alter/domain/user/port/inbound/AdminGetUserListUseCase.java b/src/main/java/com/dreamteam/alter/domain/user/port/inbound/AdminGetUserListUseCase.java new file mode 100644 index 00000000..bff393ee --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/user/port/inbound/AdminGetUserListUseCase.java @@ -0,0 +1,15 @@ +package com.dreamteam.alter.domain.user.port.inbound; + +import com.dreamteam.alter.adapter.inbound.admin.user.dto.AdminUserListFilterDto; +import com.dreamteam.alter.adapter.inbound.admin.user.dto.AdminUserListResponseDto; +import com.dreamteam.alter.adapter.inbound.common.dto.PageRequestDto; +import com.dreamteam.alter.adapter.inbound.common.dto.PaginatedResponseDto; +import com.dreamteam.alter.domain.user.context.AdminActor; + +public interface AdminGetUserListUseCase { + PaginatedResponseDto execute( + PageRequestDto request, + AdminUserListFilterDto filter, + AdminActor actor + ); +} diff --git a/src/main/java/com/dreamteam/alter/domain/user/port/inbound/AdminUpdateUserPasswordUseCase.java b/src/main/java/com/dreamteam/alter/domain/user/port/inbound/AdminUpdateUserPasswordUseCase.java new file mode 100644 index 00000000..8b8da0ca --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/user/port/inbound/AdminUpdateUserPasswordUseCase.java @@ -0,0 +1,8 @@ +package com.dreamteam.alter.domain.user.port.inbound; + +import com.dreamteam.alter.adapter.inbound.admin.user.dto.AdminUpdateUserPasswordRequestDto; +import com.dreamteam.alter.domain.user.context.AdminActor; + +public interface AdminUpdateUserPasswordUseCase { + void execute(Long userId, AdminUpdateUserPasswordRequestDto request, AdminActor actor); +} diff --git a/src/main/java/com/dreamteam/alter/domain/user/port/inbound/AdminUpdateUserStatusUseCase.java b/src/main/java/com/dreamteam/alter/domain/user/port/inbound/AdminUpdateUserStatusUseCase.java new file mode 100644 index 00000000..1e05c576 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/user/port/inbound/AdminUpdateUserStatusUseCase.java @@ -0,0 +1,8 @@ +package com.dreamteam.alter.domain.user.port.inbound; + +import com.dreamteam.alter.adapter.inbound.admin.user.dto.AdminUpdateUserStatusRequestDto; +import com.dreamteam.alter.domain.user.context.AdminActor; + +public interface AdminUpdateUserStatusUseCase { + void execute(Long userId, AdminUpdateUserStatusRequestDto request, AdminActor actor); +} diff --git a/src/main/java/com/dreamteam/alter/domain/user/port/outbound/AdminUserQueryRepository.java b/src/main/java/com/dreamteam/alter/domain/user/port/outbound/AdminUserQueryRepository.java new file mode 100644 index 00000000..58387cff --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/user/port/outbound/AdminUserQueryRepository.java @@ -0,0 +1,20 @@ +package com.dreamteam.alter.domain.user.port.outbound; + +import com.dreamteam.alter.adapter.inbound.admin.user.dto.AdminUserListFilterDto; +import com.dreamteam.alter.adapter.inbound.common.dto.PageRequestDto; +import com.dreamteam.alter.adapter.outbound.user.persistence.readonly.AdminUserDetailResponse; +import com.dreamteam.alter.adapter.outbound.user.persistence.readonly.AdminUserListResponse; + +import java.util.List; +import java.util.Optional; + +public interface AdminUserQueryRepository { + long getUserCount(AdminUserListFilterDto filter); + + List getUserListUsingPagination( + PageRequestDto pageRequest, + AdminUserListFilterDto filter + ); + + Optional getUserDetail(Long userId); +} From c2db98bf8c44cd8d28907f221bac895f28362c57 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Fri, 6 Feb 2026 02:09:04 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20SUSPENDED/DELETED=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 소셜 로그인 시 사용자 상태 검증 추가 - 비밀번호 로그인 시 사용자 상태 검증 추가 - 관련 테스트 케이스 작성 --- .../user/usecase/LoginWithPassword.java | 5 + .../user/usecase/LoginWithSocial.java | 5 + .../user/usecase/LoginWithPasswordTests.java | 209 +++++++++++++++++ .../user/usecase/LoginWithSocialTests.java | 216 ++++++++++++++++++ 4 files changed, 435 insertions(+) create mode 100644 src/test/java/com/dreamteam/alter/application/user/usecase/LoginWithPasswordTests.java create mode 100644 src/test/java/com/dreamteam/alter/application/user/usecase/LoginWithSocialTests.java diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/LoginWithPassword.java b/src/main/java/com/dreamteam/alter/application/user/usecase/LoginWithPassword.java index a2c8fc29..3607eb30 100644 --- a/src/main/java/com/dreamteam/alter/application/user/usecase/LoginWithPassword.java +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/LoginWithPassword.java @@ -32,6 +32,11 @@ public GenerateTokenResponseDto execute(LoginWithPasswordRequestDto request) { throw new CustomException(ErrorCode.INVALID_LOGIN_INFO); } + switch (user.getStatus()) { + case SUSPENDED -> throw new CustomException(ErrorCode.SUSPENDED_USER); + case DELETED -> throw new CustomException(ErrorCode.DELETED_USER); + } + // 기존 인가 정보 정리 authService.revokeAllExistingAuthorizations(user); diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/LoginWithSocial.java b/src/main/java/com/dreamteam/alter/application/user/usecase/LoginWithSocial.java index 84ef809a..a07aabb4 100644 --- a/src/main/java/com/dreamteam/alter/application/user/usecase/LoginWithSocial.java +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/LoginWithSocial.java @@ -37,6 +37,11 @@ public GenerateTokenResponseDto execute(SocialLoginRequestDto request) { User user = userSocial.getUser(); + switch (user.getStatus()) { + case SUSPENDED -> throw new CustomException(ErrorCode.SUSPENDED_USER); + case DELETED -> throw new CustomException(ErrorCode.DELETED_USER); + } + userSocial.updateRefreshToken(socialAuthInfo.getRefreshToken()); // 기존 인가 정보 정리 diff --git a/src/test/java/com/dreamteam/alter/application/user/usecase/LoginWithPasswordTests.java b/src/test/java/com/dreamteam/alter/application/user/usecase/LoginWithPasswordTests.java new file mode 100644 index 00000000..9c12e97e --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/user/usecase/LoginWithPasswordTests.java @@ -0,0 +1,209 @@ +package com.dreamteam.alter.application.user.usecase; + +import com.dreamteam.alter.adapter.inbound.general.user.dto.GenerateTokenResponseDto; +import com.dreamteam.alter.adapter.inbound.general.user.dto.LoginWithPasswordRequestDto; +import com.dreamteam.alter.application.auth.service.AuthService; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.auth.entity.Authorization; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; +import com.dreamteam.alter.domain.user.type.UserRole; +import com.dreamteam.alter.domain.user.type.UserStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("LoginWithPassword 테스트") +class LoginWithPasswordTests { + + @Mock + private UserQueryRepository userQueryRepository; + + @Mock + private AuthService authService; + + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private LoginWithPassword loginWithPassword; + + private LoginWithPasswordRequestDto request; + + @BeforeEach + void setUp() { + request = new LoginWithPasswordRequestDto("test@example.com", "password123!"); + } + + private User createMockUser(UserStatus status, UserRole role, String encodedPassword) { + User user = mock(User.class); + given(user.getStatus()).willReturn(status); + given(user.getRole()).willReturn(role); + given(user.getPassword()).willReturn(encodedPassword); + return user; + } + + @Nested + @DisplayName("execute") + class ExecuteTests { + + @Test + @DisplayName("존재하지 않는 이메일로 로그인 시 INVALID_LOGIN_INFO 예외 발생") + void fails_whenEmailNotFound() { + // given + given(userQueryRepository.findByEmail("test@example.com")).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> loginWithPassword.execute(request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> { + CustomException customEx = (CustomException) ex; + assertThat(customEx.getErrorCode()).isEqualTo(ErrorCode.INVALID_LOGIN_INFO); + }); + + then(passwordEncoder).shouldHaveNoInteractions(); + then(authService).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("비밀번호 불일치 시 INVALID_LOGIN_INFO 예외 발생") + void fails_whenPasswordNotMatch() { + // given + User user = mock(User.class); + given(user.getPassword()).willReturn("encodedPassword"); + given(userQueryRepository.findByEmail("test@example.com")).willReturn(Optional.of(user)); + given(passwordEncoder.matches("password123!", "encodedPassword")).willReturn(false); + + // when & then + assertThatThrownBy(() -> loginWithPassword.execute(request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> { + CustomException customEx = (CustomException) ex; + assertThat(customEx.getErrorCode()).isEqualTo(ErrorCode.INVALID_LOGIN_INFO); + }); + + then(authService).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("SUSPENDED 사용자 로그인 시 SUSPENDED_USER 예외 발생") + void fails_whenUserIsSuspended() { + // given + User user = mock(User.class); + given(user.getPassword()).willReturn("encodedPassword"); + given(user.getStatus()).willReturn(UserStatus.SUSPENDED); + given(userQueryRepository.findByEmail("test@example.com")).willReturn(Optional.of(user)); + given(passwordEncoder.matches("password123!", "encodedPassword")).willReturn(true); + + // when & then + assertThatThrownBy(() -> loginWithPassword.execute(request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> { + CustomException customEx = (CustomException) ex; + assertThat(customEx.getErrorCode()).isEqualTo(ErrorCode.SUSPENDED_USER); + }); + + then(authService).should(never()).revokeAllExistingAuthorizations(any()); + then(authService).should(never()).generateAuthorization(any(), any()); + } + + @Test + @DisplayName("DELETED 사용자 로그인 시 DELETED_USER 예외 발생") + void fails_whenUserIsDeleted() { + // given + User user = mock(User.class); + given(user.getPassword()).willReturn("encodedPassword"); + given(user.getStatus()).willReturn(UserStatus.DELETED); + given(userQueryRepository.findByEmail("test@example.com")).willReturn(Optional.of(user)); + given(passwordEncoder.matches("password123!", "encodedPassword")).willReturn(true); + + // when & then + assertThatThrownBy(() -> loginWithPassword.execute(request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> { + CustomException customEx = (CustomException) ex; + assertThat(customEx.getErrorCode()).isEqualTo(ErrorCode.DELETED_USER); + }); + + then(authService).should(never()).revokeAllExistingAuthorizations(any()); + then(authService).should(never()).generateAuthorization(any(), any()); + } + + @Test + @DisplayName("ACTIVE 사용자 로그인 성공") + void succeeds_whenUserIsActive() { + // given + User user = createMockUser(UserStatus.ACTIVE, UserRole.ROLE_USER, "encodedPassword"); + Authorization authorization = mock(Authorization.class); + + given(userQueryRepository.findByEmail("test@example.com")).willReturn(Optional.of(user)); + given(passwordEncoder.matches("password123!", "encodedPassword")).willReturn(true); + given(authService.generateAuthorization(user, TokenScope.APP)).willReturn(authorization); + + // when + GenerateTokenResponseDto result = loginWithPassword.execute(request); + + // then + assertThat(result).isNotNull(); + then(authService).should().revokeAllExistingAuthorizations(user); + then(authService).should().generateAuthorization(user, TokenScope.APP); + } + + @Test + @DisplayName("ADMIN 역할 사용자 로그인 시 ADMIN scope 토큰 발급") + void succeeds_withAdminScope_whenUserIsAdmin() { + // given + User user = createMockUser(UserStatus.ACTIVE, UserRole.ROLE_ADMIN, "encodedPassword"); + Authorization authorization = mock(Authorization.class); + + given(userQueryRepository.findByEmail("test@example.com")).willReturn(Optional.of(user)); + given(passwordEncoder.matches("password123!", "encodedPassword")).willReturn(true); + given(authService.generateAuthorization(user, TokenScope.ADMIN)).willReturn(authorization); + + // when + GenerateTokenResponseDto result = loginWithPassword.execute(request); + + // then + assertThat(result).isNotNull(); + then(authService).should().generateAuthorization(user, TokenScope.ADMIN); + } + + @Test + @DisplayName("MANAGER 역할 사용자 로그인 시 MANAGER scope 토큰 발급") + void succeeds_withManagerScope_whenUserIsManager() { + // given + User user = createMockUser(UserStatus.ACTIVE, UserRole.ROLE_MANAGER, "encodedPassword"); + Authorization authorization = mock(Authorization.class); + + given(userQueryRepository.findByEmail("test@example.com")).willReturn(Optional.of(user)); + given(passwordEncoder.matches("password123!", "encodedPassword")).willReturn(true); + given(authService.generateAuthorization(user, TokenScope.MANAGER)).willReturn(authorization); + + // when + GenerateTokenResponseDto result = loginWithPassword.execute(request); + + // then + assertThat(result).isNotNull(); + then(authService).should().generateAuthorization(user, TokenScope.MANAGER); + } + } +} diff --git a/src/test/java/com/dreamteam/alter/application/user/usecase/LoginWithSocialTests.java b/src/test/java/com/dreamteam/alter/application/user/usecase/LoginWithSocialTests.java new file mode 100644 index 00000000..736a9d49 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/user/usecase/LoginWithSocialTests.java @@ -0,0 +1,216 @@ +package com.dreamteam.alter.application.user.usecase; + +import com.dreamteam.alter.adapter.inbound.general.auth.dto.SocialAuthInfo; +import com.dreamteam.alter.adapter.inbound.general.user.dto.GenerateTokenResponseDto; +import com.dreamteam.alter.adapter.inbound.general.user.dto.SocialLoginRequestDto; +import com.dreamteam.alter.application.auth.manager.SocialAuthenticationManager; +import com.dreamteam.alter.application.auth.service.AuthService; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.auth.entity.Authorization; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.user.entity.UserSocial; +import com.dreamteam.alter.domain.user.port.outbound.UserSocialQueryRepository; +import com.dreamteam.alter.domain.user.type.PlatformType; +import com.dreamteam.alter.domain.user.type.SocialProvider; +import com.dreamteam.alter.domain.user.type.UserRole; +import com.dreamteam.alter.domain.user.type.UserStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("LoginWithSocial 테스트") +class LoginWithSocialTests { + + @Mock + private SocialAuthenticationManager socialAuthenticationManager; + + @Mock + private UserSocialQueryRepository userSocialQueryRepository; + + @Mock + private AuthService authService; + + @InjectMocks + private LoginWithSocial loginWithSocial; + + private SocialLoginRequestDto request; + + @BeforeEach + void setUp() { + request = new SocialLoginRequestDto( + SocialProvider.KAKAO, + null, + "authCode", + PlatformType.WEB + ); + } + + private SocialAuthInfo createSocialAuthInfo() { + SocialAuthInfo authInfo = mock(SocialAuthInfo.class); + given(authInfo.getProvider()).willReturn(SocialProvider.KAKAO); + given(authInfo.getSocialId()).willReturn("social-123"); + given(authInfo.getRefreshToken()).willReturn("refresh-token"); + return authInfo; + } + + private SocialAuthInfo createSocialAuthInfoWithoutRefreshToken() { + SocialAuthInfo authInfo = mock(SocialAuthInfo.class); + given(authInfo.getProvider()).willReturn(SocialProvider.KAKAO); + given(authInfo.getSocialId()).willReturn("social-123"); + return authInfo; + } + + private UserSocial createMockUserSocial(User user) { + UserSocial userSocial = mock(UserSocial.class); + given(userSocial.getUser()).willReturn(user); + return userSocial; + } + + @Nested + @DisplayName("execute") + class ExecuteTests { + + @Test + @DisplayName("소셜 계정이 존재하지 않을 경우 USER_NOT_FOUND 예외 발생") + void fails_whenUserSocialNotFound() { + // given + SocialAuthInfo authInfo = createSocialAuthInfoWithoutRefreshToken(); + given(socialAuthenticationManager.authenticate(request)).willReturn(authInfo); + given(userSocialQueryRepository.findBySocialProviderAndSocialId( + SocialProvider.KAKAO, "social-123" + )).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> loginWithSocial.execute(request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> { + CustomException customEx = (CustomException) ex; + assertThat(customEx.getErrorCode()).isEqualTo(ErrorCode.USER_NOT_FOUND); + }); + + then(authService).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("SUSPENDED 사용자 로그인 시 SUSPENDED_USER 예외 발생") + void fails_whenUserIsSuspended() { + // given + SocialAuthInfo authInfo = createSocialAuthInfoWithoutRefreshToken(); + User user = mock(User.class); + given(user.getStatus()).willReturn(UserStatus.SUSPENDED); + UserSocial userSocial = createMockUserSocial(user); + + given(socialAuthenticationManager.authenticate(request)).willReturn(authInfo); + given(userSocialQueryRepository.findBySocialProviderAndSocialId( + SocialProvider.KAKAO, "social-123" + )).willReturn(Optional.of(userSocial)); + + // when & then + assertThatThrownBy(() -> loginWithSocial.execute(request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> { + CustomException customEx = (CustomException) ex; + assertThat(customEx.getErrorCode()).isEqualTo(ErrorCode.SUSPENDED_USER); + }); + + then(authService).should(never()).revokeAllExistingAuthorizations(any()); + then(authService).should(never()).generateAuthorization(any(), any()); + } + + @Test + @DisplayName("DELETED 사용자 로그인 시 DELETED_USER 예외 발생") + void fails_whenUserIsDeleted() { + // given + SocialAuthInfo authInfo = createSocialAuthInfoWithoutRefreshToken(); + User user = mock(User.class); + given(user.getStatus()).willReturn(UserStatus.DELETED); + UserSocial userSocial = createMockUserSocial(user); + + given(socialAuthenticationManager.authenticate(request)).willReturn(authInfo); + given(userSocialQueryRepository.findBySocialProviderAndSocialId( + SocialProvider.KAKAO, "social-123" + )).willReturn(Optional.of(userSocial)); + + // when & then + assertThatThrownBy(() -> loginWithSocial.execute(request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> { + CustomException customEx = (CustomException) ex; + assertThat(customEx.getErrorCode()).isEqualTo(ErrorCode.DELETED_USER); + }); + + then(authService).should(never()).revokeAllExistingAuthorizations(any()); + then(authService).should(never()).generateAuthorization(any(), any()); + } + + @Test + @DisplayName("ACTIVE 사용자 로그인 성공") + void succeeds_whenUserIsActive() { + // given + SocialAuthInfo authInfo = createSocialAuthInfo(); + User user = mock(User.class); + given(user.getStatus()).willReturn(UserStatus.ACTIVE); + given(user.getRole()).willReturn(UserRole.ROLE_USER); + UserSocial userSocial = createMockUserSocial(user); + Authorization authorization = mock(Authorization.class); + + given(socialAuthenticationManager.authenticate(request)).willReturn(authInfo); + given(userSocialQueryRepository.findBySocialProviderAndSocialId( + SocialProvider.KAKAO, "social-123" + )).willReturn(Optional.of(userSocial)); + given(authService.generateAuthorization(user, TokenScope.APP)).willReturn(authorization); + + // when + GenerateTokenResponseDto result = loginWithSocial.execute(request); + + // then + assertThat(result).isNotNull(); + then(userSocial).should().updateRefreshToken("refresh-token"); + then(authService).should().revokeAllExistingAuthorizations(user); + then(authService).should().generateAuthorization(user, TokenScope.APP); + } + + @Test + @DisplayName("MANAGER 역할 사용자 로그인 시 MANAGER scope 토큰 발급") + void succeeds_withManagerScope_whenUserIsManager() { + // given + SocialAuthInfo authInfo = createSocialAuthInfo(); + User user = mock(User.class); + given(user.getStatus()).willReturn(UserStatus.ACTIVE); + given(user.getRole()).willReturn(UserRole.ROLE_MANAGER); + UserSocial userSocial = createMockUserSocial(user); + Authorization authorization = mock(Authorization.class); + + given(socialAuthenticationManager.authenticate(request)).willReturn(authInfo); + given(userSocialQueryRepository.findBySocialProviderAndSocialId( + SocialProvider.KAKAO, "social-123" + )).willReturn(Optional.of(userSocial)); + given(authService.generateAuthorization(user, TokenScope.MANAGER)).willReturn(authorization); + + // when + GenerateTokenResponseDto result = loginWithSocial.execute(request); + + // then + assertThat(result).isNotNull(); + then(authService).should().generateAuthorization(user, TokenScope.MANAGER); + } + } +} From 858f7c7578e5b1efe178269958b014a0b34cadf5 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Fri, 6 Feb 2026 10:58:53 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=83=81=ED=83=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EC=8B=9C=20=EC=9D=B8=EA=B0=80=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=EB=A7=8C=EB=A3=8C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SUSPENDED로 상태 변경 시 기존 토큰 revoke 처리 - AdminUserQueryRepository에 findById 메서드 추가 - 관리자는 SUSPENDED 사용자도 조회 가능하도록 개선 - 관련 테스트 케이스 작성 --- .../AdminUserQueryRepositoryImpl.java | 14 ++ .../user/usecase/AdminUpdateUserStatus.java | 17 +- .../outbound/AdminUserQueryRepository.java | 3 + .../usecase/AdminUpdateUserStatusTests.java | 153 ++++++++++++++++++ 4 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 src/test/java/com/dreamteam/alter/application/user/usecase/AdminUpdateUserStatusTests.java diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/AdminUserQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/AdminUserQueryRepositoryImpl.java index a8a0b358..75903d55 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/AdminUserQueryRepositoryImpl.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/AdminUserQueryRepositoryImpl.java @@ -7,6 +7,7 @@ import com.dreamteam.alter.domain.reputation.entity.QReputationSummary; import com.dreamteam.alter.domain.reputation.type.ReputationType; import com.dreamteam.alter.domain.user.entity.QUser; +import com.dreamteam.alter.domain.user.entity.User; import com.dreamteam.alter.domain.user.port.outbound.AdminUserQueryRepository; import com.dreamteam.alter.domain.user.type.UserRole; import com.dreamteam.alter.domain.user.type.UserStatus; @@ -112,6 +113,19 @@ public Optional getUserDetail(Long userId) { return Optional.ofNullable(response); } + @Override + public Optional findById(Long userId) { + User foundUser = queryFactory + .selectFrom(user) + .where( + user.id.eq(userId), + user.status.ne(UserStatus.DELETED) + ) + .fetchOne(); + + return Optional.ofNullable(foundUser); + } + private BooleanExpression eqStatus(UserStatus status) { return ObjectUtils.isNotEmpty(status) ? user.status.eq(status) : null; } diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/AdminUpdateUserStatus.java b/src/main/java/com/dreamteam/alter/application/user/usecase/AdminUpdateUserStatus.java index 410c3262..f9583597 100644 --- a/src/main/java/com/dreamteam/alter/application/user/usecase/AdminUpdateUserStatus.java +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/AdminUpdateUserStatus.java @@ -1,12 +1,14 @@ package com.dreamteam.alter.application.user.usecase; import com.dreamteam.alter.adapter.inbound.admin.user.dto.AdminUpdateUserStatusRequestDto; +import com.dreamteam.alter.application.auth.service.AuthService; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; import com.dreamteam.alter.domain.user.context.AdminActor; import com.dreamteam.alter.domain.user.entity.User; import com.dreamteam.alter.domain.user.port.inbound.AdminUpdateUserStatusUseCase; -import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; +import com.dreamteam.alter.domain.user.port.outbound.AdminUserQueryRepository; +import com.dreamteam.alter.domain.user.type.UserStatus; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,11 +18,16 @@ @Transactional public class AdminUpdateUserStatus implements AdminUpdateUserStatusUseCase { - private final UserQueryRepository userQueryRepository; + private final AdminUserQueryRepository adminUserQueryRepository; + private final AuthService authService; @Override public void execute(Long userId, AdminUpdateUserStatusRequestDto request, AdminActor actor) { - User user = userQueryRepository.findById(userId) + if (UserStatus.DELETED.equals(request.getStatus())) { + throw new CustomException(ErrorCode.ILLEGAL_ARGUMENT, "변경 가능한 상태가 아닙니다."); + } + + User user = adminUserQueryRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND, "회원을 찾을 수 없습니다.")); try { @@ -28,5 +35,9 @@ public void execute(Long userId, AdminUpdateUserStatusRequestDto request, AdminA } catch (IllegalArgumentException e) { throw new CustomException(ErrorCode.CONFLICT, "현재 상태가 변경하고자 하는 상태와 동일합니다."); } + + if (UserStatus.SUSPENDED.equals(request.getStatus())) { + authService.revokeAllExistingAuthorizations(user); + } } } diff --git a/src/main/java/com/dreamteam/alter/domain/user/port/outbound/AdminUserQueryRepository.java b/src/main/java/com/dreamteam/alter/domain/user/port/outbound/AdminUserQueryRepository.java index 58387cff..6edcddfd 100644 --- a/src/main/java/com/dreamteam/alter/domain/user/port/outbound/AdminUserQueryRepository.java +++ b/src/main/java/com/dreamteam/alter/domain/user/port/outbound/AdminUserQueryRepository.java @@ -4,6 +4,7 @@ import com.dreamteam.alter.adapter.inbound.common.dto.PageRequestDto; import com.dreamteam.alter.adapter.outbound.user.persistence.readonly.AdminUserDetailResponse; import com.dreamteam.alter.adapter.outbound.user.persistence.readonly.AdminUserListResponse; +import com.dreamteam.alter.domain.user.entity.User; import java.util.List; import java.util.Optional; @@ -17,4 +18,6 @@ List getUserListUsingPagination( ); Optional getUserDetail(Long userId); + + Optional findById(Long userId); } diff --git a/src/test/java/com/dreamteam/alter/application/user/usecase/AdminUpdateUserStatusTests.java b/src/test/java/com/dreamteam/alter/application/user/usecase/AdminUpdateUserStatusTests.java new file mode 100644 index 00000000..cd990f76 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/user/usecase/AdminUpdateUserStatusTests.java @@ -0,0 +1,153 @@ +package com.dreamteam.alter.application.user.usecase; + +import com.dreamteam.alter.adapter.inbound.admin.user.dto.AdminUpdateUserStatusRequestDto; +import com.dreamteam.alter.application.auth.service.AuthService; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.user.context.AdminActor; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.user.port.outbound.AdminUserQueryRepository; +import com.dreamteam.alter.domain.user.type.UserStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AdminUpdateUserStatus 테스트") +class AdminUpdateUserStatusTests { + + @Mock + private AdminUserQueryRepository adminUserQueryRepository; + + @Mock + private AuthService authService; + + @InjectMocks + private AdminUpdateUserStatus adminUpdateUserStatus; + + private AdminActor actor; + + @BeforeEach + void setUp() { + actor = mock(AdminActor.class); + } + + private User createMockUser(UserStatus status) { + User user = mock(User.class); + given(user.getStatus()).willReturn(status); + return user; + } + + @Nested + @DisplayName("execute") + class ExecuteTests { + + @Test + @DisplayName("DELETED 상태로 변경 시도 시 ILLEGAL_ARGUMENT 예외 발생") + void fails_whenStatusIsDeleted() { + // given + Long userId = 1L; + AdminUpdateUserStatusRequestDto request = new AdminUpdateUserStatusRequestDto(UserStatus.DELETED); + + // when & then + assertThatThrownBy(() -> adminUpdateUserStatus.execute(userId, request, actor)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> { + CustomException customEx = (CustomException) ex; + assertThat(customEx.getErrorCode()).isEqualTo(ErrorCode.ILLEGAL_ARGUMENT); + }); + + then(adminUserQueryRepository).shouldHaveNoInteractions(); + then(authService).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("존재하지 않는 사용자일 경우 NOT_FOUND 예외 발생") + void fails_whenUserNotFound() { + // given + Long userId = 1L; + AdminUpdateUserStatusRequestDto request = new AdminUpdateUserStatusRequestDto(UserStatus.SUSPENDED); + given(adminUserQueryRepository.findById(userId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> adminUpdateUserStatus.execute(userId, request, actor)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> { + CustomException customEx = (CustomException) ex; + assertThat(customEx.getErrorCode()).isEqualTo(ErrorCode.NOT_FOUND); + }); + + then(authService).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("현재 상태와 동일한 상태로 변경 시도 시 CONFLICT 예외 발생") + void fails_whenSameStatus() { + // given + Long userId = 1L; + AdminUpdateUserStatusRequestDto request = new AdminUpdateUserStatusRequestDto(UserStatus.SUSPENDED); + User user = mock(User.class); + given(adminUserQueryRepository.findById(userId)).willReturn(Optional.of(user)); + willThrow(new IllegalArgumentException()).given(user).updateStatus(UserStatus.SUSPENDED); + + // when & then + assertThatThrownBy(() -> adminUpdateUserStatus.execute(userId, request, actor)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> { + CustomException customEx = (CustomException) ex; + assertThat(customEx.getErrorCode()).isEqualTo(ErrorCode.CONFLICT); + }); + + then(authService).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("SUSPENDED로 상태 변경 시 기존 인가 정보 revoke 호출") + void succeeds_andRevokesAuthorizations_whenStatusIsSuspended() { + // given + Long userId = 1L; + AdminUpdateUserStatusRequestDto request = new AdminUpdateUserStatusRequestDto(UserStatus.SUSPENDED); + User user = mock(User.class); + given(adminUserQueryRepository.findById(userId)).willReturn(Optional.of(user)); + + // when + adminUpdateUserStatus.execute(userId, request, actor); + + // then + then(user).should().updateStatus(UserStatus.SUSPENDED); + then(authService).should().revokeAllExistingAuthorizations(user); + } + + @Test + @DisplayName("ACTIVE로 상태 변경 시 인가 정보 revoke 호출하지 않음") + void succeeds_withoutRevoke_whenStatusIsActive() { + // given + Long userId = 1L; + AdminUpdateUserStatusRequestDto request = new AdminUpdateUserStatusRequestDto(UserStatus.ACTIVE); + User user = mock(User.class); + given(adminUserQueryRepository.findById(userId)).willReturn(Optional.of(user)); + + // when + adminUpdateUserStatus.execute(userId, request, actor); + + // then + then(user).should().updateStatus(UserStatus.ACTIVE); + then(authService).should(never()).revokeAllExistingAuthorizations(user); + } + } +} From b3f7f515fbca1a88c0bab1b7582e5a7fe669624d Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Fri, 6 Feb 2026 16:17:58 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=EC=82=AC=EC=9A=A9=EC=9E=90=EC=9D=98?= =?UTF-8?q?=20DELETED=20=EC=83=81=ED=83=9C=20=EA=B3=B5=EA=B3=A0=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EC=A7=80=EC=9B=90=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../posting/persistence/PostingQueryRepositoryImpl.java | 5 ++++- .../posting/usecase/CreatePostingApplication.java | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java index f74e4021..309fde81 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java @@ -336,7 +336,10 @@ public PostingDetailResponse getPostingDetail(Long postingId, User user) { .selectFrom(qPosting) .leftJoin(qPosting.schedules, qPostingSchedule).fetchJoin() .leftJoin(qPosting.workspace, qWorkspace).fetchJoin() - .where(qPosting.id.eq(postingId)) + .where( + qPosting.id.eq(postingId), + qPosting.status.eq(PostingStatus.OPEN) + ) .fetchOne(); if (ObjectUtils.isEmpty(posting)) { diff --git a/src/main/java/com/dreamteam/alter/application/posting/usecase/CreatePostingApplication.java b/src/main/java/com/dreamteam/alter/application/posting/usecase/CreatePostingApplication.java index 14129092..cf1b6147 100644 --- a/src/main/java/com/dreamteam/alter/application/posting/usecase/CreatePostingApplication.java +++ b/src/main/java/com/dreamteam/alter/application/posting/usecase/CreatePostingApplication.java @@ -8,9 +8,11 @@ import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.domain.auth.type.TokenScope; import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.posting.entity.Posting; import com.dreamteam.alter.domain.posting.entity.PostingApplication; import com.dreamteam.alter.domain.posting.entity.PostingSchedule; import com.dreamteam.alter.domain.posting.port.inbound.CreatePostingApplicationUseCase; +import com.dreamteam.alter.domain.posting.type.PostingStatus; import com.dreamteam.alter.domain.posting.port.outbound.PostingApplicationRepository; import com.dreamteam.alter.domain.posting.port.outbound.PostingScheduleQueryRepository; import com.dreamteam.alter.domain.user.context.AppActor; @@ -29,12 +31,18 @@ public class CreatePostingApplication implements CreatePostingApplicationUseCase private final WorkspaceWorkerQueryRepository workspaceWorkerQueryRepository; private final NotificationService notificationService; + // TODO: postingId, postingScheduleId 둘 다 인자로 받아 확인하도록 수정 필요 @Override public void execute(AppActor actor, Long postingId, CreatePostingApplicationRequestDto request) { PostingSchedule postingSchedule = postingScheduleQueryRepository.findByIdAndPostingId(postingId, request.getPostingScheduleId()) .orElseThrow(() -> new CustomException(ErrorCode.POSTING_SCHEDULE_NOT_FOUND)); + Posting posting = postingSchedule.getPosting(); + if (PostingStatus.OPEN.equals(posting.getStatus())) { + throw new CustomException(ErrorCode.ILLEGAL_ARGUMENT, "모집이 종료된 공고입니다."); + } + if (workspaceWorkerQueryRepository.findActiveWorkerByWorkspaceAndUser( postingSchedule.getPosting().getWorkspace(), actor.getUser() From 604ddb3e4feda198e24c2fcd52237f39f024de4b Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 18 Feb 2026 17:31:40 +0900 Subject: [PATCH 7/7] =?UTF-8?q?refactor:=20AdminUpdateUserPassword?= =?UTF-8?q?=EC=97=90=EC=84=9C=20AdminUserQueryRepository=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserQueryRepository 대신 AdminUserQueryRepository를 사용하여 관리자 전용 리포지토리 일관성 확보 --- .../application/user/usecase/AdminUpdateUserPassword.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/AdminUpdateUserPassword.java b/src/main/java/com/dreamteam/alter/application/user/usecase/AdminUpdateUserPassword.java index 86b0b0f9..52251312 100644 --- a/src/main/java/com/dreamteam/alter/application/user/usecase/AdminUpdateUserPassword.java +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/AdminUpdateUserPassword.java @@ -7,7 +7,7 @@ import com.dreamteam.alter.domain.user.context.AdminActor; import com.dreamteam.alter.domain.user.entity.User; import com.dreamteam.alter.domain.user.port.inbound.AdminUpdateUserPasswordUseCase; -import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; +import com.dreamteam.alter.domain.user.port.outbound.AdminUserQueryRepository; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -18,13 +18,13 @@ @Transactional public class AdminUpdateUserPassword implements AdminUpdateUserPasswordUseCase { - private final UserQueryRepository userQueryRepository; + private final AdminUserQueryRepository adminUserQueryRepository; private final PasswordEncoder passwordEncoder; @Override public void execute(Long userId, AdminUpdateUserPasswordRequestDto request, AdminActor actor) { // 사용자 조회 - User user = userQueryRepository.findById(userId) + User user = adminUserQueryRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); // 비밀번호 형식 검증