From d0dfe82dd2618a60f18afabacc362876a7174b78 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 6e9e5d4..a4ee164 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 396752d226a230765077af0576f6824e326fd8de 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 435470f..1499cc0 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 5ab3453..ea36649 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 9eedb5d..1ae5820 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 639494b..8ba129f 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 680401f5ef27b8b7d0c6ac157cfaecee639c1f9b 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 0000000..f6d718a --- /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 0000000..1ddde68 --- /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 0000000..d6aaeac --- /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 0000000..1bf65b3 --- /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 0000000..d7be93e --- /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 0000000..882386d --- /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 0000000..5bfb226 --- /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 0000000..69934cb --- /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 0000000..c8f74cc --- /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 0000000..a8a0b35 --- /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 0000000..21b0618 --- /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 0000000..93bb10b --- /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 0000000..3eb8ee7 --- /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 0000000..3b9ee15 --- /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 0000000..86b0b0f --- /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 0000000..410c326 --- /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 0000000..505c740 --- /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 0000000..bff393e --- /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 0000000..8b8da0c --- /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 0000000..1e05c57 --- /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 0000000..58387cf --- /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 38ea0856b2513fe360b9286bc8ca4ab37dd0ed26 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 a2c8fc2..3607eb3 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 84ef809..a07aabb 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 0000000..9c12e97 --- /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 0000000..736a9d4 --- /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 ac78694d90b2e518db6f49fdaf2ce94ff771e542 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 a8a0b35..75903d5 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 410c326..f958359 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 58387cf..6edcddf 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 0000000..cd990f7 --- /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 21d3271fc91ccd3ae657f747a1645c037dba59e5 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Fri, 6 Feb 2026 16:17:50 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EA=B3=B5=EA=B3=A0=20=EA=B4=80=EB=A6=AC=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AdminPostingController.java | 86 ++++++++++++ .../AdminPostingControllerSpec.java | 124 +++++++++++++++++ .../dto/AdminPostingDetailResponseDto.java | 76 ++++++++++ .../posting/dto/AdminPostingKeywordDto.java | 30 ++++ .../dto/AdminPostingListFilterDto.java | 28 ++++ .../dto/AdminPostingListResponseDto.java | 55 ++++++++ .../posting/dto/AdminPostingScheduleDto.java | 59 ++++++++ .../posting/dto/AdminPostingWorkspaceDto.java | 38 +++++ .../AdminUpdatePostingStatusRequestDto.java | 19 +++ .../AdminPostingQueryRepositoryImpl.java | 131 ++++++++++++++++++ .../readonly/AdminPostingDetailResponse.java | 45 ++++++ .../readonly/AdminPostingListResponse.java | 20 +++ .../posting/usecase/AdminDeletePosting.java | 32 +++++ .../usecase/AdminGetPostingDetail.java | 25 ++++ .../posting/usecase/AdminGetPostingList.java | 47 +++++++ .../usecase/AdminUpdatePostingStatus.java | 37 +++++ .../inbound/AdminDeletePostingUseCase.java | 7 + .../inbound/AdminGetPostingDetailUseCase.java | 8 ++ .../inbound/AdminGetPostingListUseCase.java | 15 ++ .../AdminUpdatePostingStatusUseCase.java | 8 ++ .../outbound/AdminPostingQueryRepository.java | 23 +++ .../domain/posting/type/PaymentType.java | 11 ++ 22 files changed, 924 insertions(+) create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/controller/AdminPostingController.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/controller/AdminPostingControllerSpec.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingDetailResponseDto.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingKeywordDto.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingListFilterDto.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingListResponseDto.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingScheduleDto.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingWorkspaceDto.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminUpdatePostingStatusRequestDto.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/AdminPostingQueryRepositoryImpl.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/AdminPostingDetailResponse.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/AdminPostingListResponse.java create mode 100644 src/main/java/com/dreamteam/alter/application/posting/usecase/AdminDeletePosting.java create mode 100644 src/main/java/com/dreamteam/alter/application/posting/usecase/AdminGetPostingDetail.java create mode 100644 src/main/java/com/dreamteam/alter/application/posting/usecase/AdminGetPostingList.java create mode 100644 src/main/java/com/dreamteam/alter/application/posting/usecase/AdminUpdatePostingStatus.java create mode 100644 src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminDeletePostingUseCase.java create mode 100644 src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminGetPostingDetailUseCase.java create mode 100644 src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminGetPostingListUseCase.java create mode 100644 src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminUpdatePostingStatusUseCase.java create mode 100644 src/main/java/com/dreamteam/alter/domain/posting/port/outbound/AdminPostingQueryRepository.java diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/controller/AdminPostingController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/controller/AdminPostingController.java new file mode 100644 index 0000000..9b20d77 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/controller/AdminPostingController.java @@ -0,0 +1,86 @@ +package com.dreamteam.alter.adapter.inbound.admin.posting.controller; + +import com.dreamteam.alter.adapter.inbound.admin.posting.dto.AdminPostingDetailResponseDto; +import com.dreamteam.alter.adapter.inbound.admin.posting.dto.AdminPostingListFilterDto; +import com.dreamteam.alter.adapter.inbound.admin.posting.dto.AdminPostingListResponseDto; +import com.dreamteam.alter.adapter.inbound.admin.posting.dto.AdminUpdatePostingStatusRequestDto; +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.posting.port.inbound.AdminDeletePostingUseCase; +import com.dreamteam.alter.domain.posting.port.inbound.AdminGetPostingDetailUseCase; +import com.dreamteam.alter.domain.posting.port.inbound.AdminGetPostingListUseCase; +import com.dreamteam.alter.domain.posting.port.inbound.AdminUpdatePostingStatusUseCase; +import com.dreamteam.alter.domain.user.context.AdminActor; +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/postings") +@PreAuthorize("hasAnyRole('ADMIN')") +@RequiredArgsConstructor +@Validated +public class AdminPostingController implements AdminPostingControllerSpec { + + @Resource(name = "adminGetPostingList") + private final AdminGetPostingListUseCase adminGetPostingList; + + @Resource(name = "adminGetPostingDetail") + private final AdminGetPostingDetailUseCase adminGetPostingDetail; + + @Resource(name = "adminUpdatePostingStatus") + private final AdminUpdatePostingStatusUseCase adminUpdatePostingStatus; + + @Resource(name = "adminDeletePosting") + private final AdminDeletePostingUseCase adminDeletePosting; + + @Override + @GetMapping + public ResponseEntity> getPostingList( + PageRequestDto request, + AdminPostingListFilterDto filter + ) { + AdminActor actor = AdminActionContext.getInstance().getActor(); + + return ResponseEntity.ok(adminGetPostingList.execute(request, filter, actor)); + } + + @Override + @GetMapping("/{postingId}") + public ResponseEntity> getPostingDetail( + @PathVariable Long postingId + ) { + AdminActor actor = AdminActionContext.getInstance().getActor(); + + return ResponseEntity.ok(CommonApiResponse.of(adminGetPostingDetail.execute(postingId, actor))); + } + + @Override + @PutMapping("/{postingId}/status") + public ResponseEntity> updatePostingStatus( + @PathVariable Long postingId, + @Valid @RequestBody AdminUpdatePostingStatusRequestDto request + ) { + AdminActor actor = AdminActionContext.getInstance().getActor(); + + adminUpdatePostingStatus.execute(postingId, request, actor); + return ResponseEntity.ok(CommonApiResponse.empty()); + } + + @Override + @DeleteMapping("/{postingId}") + public ResponseEntity> deletePosting( + @PathVariable Long postingId + ) { + AdminActor actor = AdminActionContext.getInstance().getActor(); + + adminDeletePosting.execute(postingId, actor); + return ResponseEntity.ok(CommonApiResponse.empty()); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/controller/AdminPostingControllerSpec.java b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/controller/AdminPostingControllerSpec.java new file mode 100644 index 0000000..a1fb3f5 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/controller/AdminPostingControllerSpec.java @@ -0,0 +1,124 @@ +package com.dreamteam.alter.adapter.inbound.admin.posting.controller; + +import com.dreamteam.alter.adapter.inbound.admin.posting.dto.AdminPostingDetailResponseDto; +import com.dreamteam.alter.adapter.inbound.admin.posting.dto.AdminPostingListFilterDto; +import com.dreamteam.alter.adapter.inbound.admin.posting.dto.AdminPostingListResponseDto; +import com.dreamteam.alter.adapter.inbound.admin.posting.dto.AdminUpdatePostingStatusRequestDto; +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 AdminPostingControllerSpec { + + @Operation(summary = "공고 목록 조회", description = "관리자가 공고 목록을 오프셋 페이징으로 조회합니다. 상태, 제목, 업장 ID로 필터링할 수 있습니다.") + @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> getPostingList( + PageRequestDto request, + AdminPostingListFilterDto 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\" : \"B007\"}" + ) + })) + }) + ResponseEntity> getPostingDetail( + @Parameter(description = "공고 ID", example = "1") @PathVariable Long postingId + ); + + @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\" : \"B007\"}" + ), + @ExampleObject( + name = "잘못된 요청 (유효하지 않은 상태 값 등)", + value = "{\"code\" : \"B001\"}" + ) + })), + @ApiResponse(responseCode = "409", description = "상태 충돌", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "현재 상태와 동일한 상태로 변경 시도", + value = "{\"code\" : \"B020\"}" + ) + })) + }) + ResponseEntity> updatePostingStatus( + @Parameter(description = "공고 ID", example = "1") @PathVariable Long postingId, + @Valid @RequestBody AdminUpdatePostingStatusRequestDto 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\" : \"B007\"}" + ) + })), + @ApiResponse(responseCode = "409", description = "상태 충돌", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "이미 삭제된 공고", + value = "{\"code\" : \"B020\"}" + ) + })) + }) + ResponseEntity> deletePosting( + @Parameter(description = "공고 ID", example = "1") @PathVariable Long postingId + ); +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingDetailResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingDetailResponseDto.java new file mode 100644 index 0000000..1704ecb --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingDetailResponseDto.java @@ -0,0 +1,76 @@ +package com.dreamteam.alter.adapter.inbound.admin.posting.dto; + +import com.dreamteam.alter.adapter.inbound.common.dto.DescribedEnumDto; +import com.dreamteam.alter.adapter.outbound.posting.persistence.readonly.AdminPostingDetailResponse; +import com.dreamteam.alter.domain.posting.type.PaymentType; +import com.dreamteam.alter.domain.posting.type.PostingStatus; +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; +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +@Schema(description = "공고 상세 응답 DTO") +public class AdminPostingDetailResponseDto { + + @Schema(description = "공고 ID", example = "1") + private Long id; + + @Schema(description = "공고 제목", example = "주말 알바 모집") + private String title; + + @Schema(description = "공고 설명") + private String description; + + @Schema(description = "급여 금액", example = "10000") + private int payAmount; + + @Schema(description = "급여 유형") + private DescribedEnumDto paymentType; + + @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 AdminPostingWorkspaceDto workspace; + + @Schema(description = "스케줄 목록") + private List schedules; + + @Schema(description = "키워드 목록") + private List keywords; + + public static AdminPostingDetailResponseDto from(AdminPostingDetailResponse response) { + return AdminPostingDetailResponseDto.builder() + .id(response.getId()) + .title(response.getTitle()) + .description(response.getDescription()) + .payAmount(response.getPayAmount()) + .paymentType(DescribedEnumDto.of(response.getPaymentType(), PaymentType.describe())) + .status(DescribedEnumDto.of(response.getStatus(), PostingStatus.describe())) + .createdAt(response.getCreatedAt()) + .updatedAt(response.getUpdatedAt()) + .workspace(AdminPostingWorkspaceDto.from(response.getWorkspace())) + .schedules(response.getSchedules().stream() + .map(AdminPostingScheduleDto::from) + .toList()) + .keywords(response.getKeywords().stream() + .map(AdminPostingKeywordDto::from) + .toList()) + .build(); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingKeywordDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingKeywordDto.java new file mode 100644 index 0000000..333d674 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingKeywordDto.java @@ -0,0 +1,30 @@ +package com.dreamteam.alter.adapter.inbound.admin.posting.dto; + +import com.dreamteam.alter.domain.posting.entity.PostingKeyword; +import io.swagger.v3.oas.annotations.media.Schema; +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 AdminPostingKeywordDto { + + @Schema(description = "키워드 ID", example = "1") + private Long id; + + @Schema(description = "키워드명", example = "카페") + private String name; + + public static AdminPostingKeywordDto from(PostingKeyword keyword) { + return AdminPostingKeywordDto.builder() + .id(keyword.getId()) + .name(keyword.getName()) + .build(); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingListFilterDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingListFilterDto.java new file mode 100644 index 0000000..ff581f4 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingListFilterDto.java @@ -0,0 +1,28 @@ +package com.dreamteam.alter.adapter.inbound.admin.posting.dto; + +import com.dreamteam.alter.domain.posting.type.PostingStatus; +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 AdminPostingListFilterDto { + + @Parameter(description = "공고 상태") + private PostingStatus status; + + @Parameter(description = "공고 제목 검색어") + private String title; + + @Parameter(description = "업장 ID") + private Long workspaceId; +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingListResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingListResponseDto.java new file mode 100644 index 0000000..733edf1 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingListResponseDto.java @@ -0,0 +1,55 @@ +package com.dreamteam.alter.adapter.inbound.admin.posting.dto; + +import com.dreamteam.alter.adapter.inbound.common.dto.DescribedEnumDto; +import com.dreamteam.alter.adapter.outbound.posting.persistence.readonly.AdminPostingListResponse; +import com.dreamteam.alter.domain.posting.type.PaymentType; +import com.dreamteam.alter.domain.posting.type.PostingStatus; +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 AdminPostingListResponseDto { + + @Schema(description = "공고 ID", example = "1") + private Long id; + + @Schema(description = "공고 제목", example = "주말 알바 모집") + private String title; + + @Schema(description = "급여 금액", example = "10000") + private int payAmount; + + @Schema(description = "급여 유형") + private DescribedEnumDto paymentType; + + @Schema(description = "공고 상태") + private DescribedEnumDto status; + + @Schema(description = "업장명", example = "스타벅스 강남점") + private String workspaceName; + + @Schema(description = "생성일시", example = "2025-01-01T12:00:00") + private LocalDateTime createdAt; + + public static AdminPostingListResponseDto from(AdminPostingListResponse response) { + return AdminPostingListResponseDto.builder() + .id(response.getId()) + .title(response.getTitle()) + .payAmount(response.getPayAmount()) + .paymentType(DescribedEnumDto.of(response.getPaymentType(), PaymentType.describe())) + .status(DescribedEnumDto.of(response.getStatus(), PostingStatus.describe())) + .workspaceName(response.getWorkspaceName()) + .createdAt(response.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingScheduleDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingScheduleDto.java new file mode 100644 index 0000000..00be093 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingScheduleDto.java @@ -0,0 +1,59 @@ +package com.dreamteam.alter.adapter.inbound.admin.posting.dto; + +import com.dreamteam.alter.domain.posting.entity.PostingSchedule; +import com.dreamteam.alter.domain.posting.type.PostingStatus; +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.DayOfWeek; +import java.time.LocalTime; +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +@Schema(description = "공고 상세 - 스케줄 정보 DTO") +public class AdminPostingScheduleDto { + + @Schema(description = "스케줄 ID", example = "1") + private Long id; + + @Schema(description = "근무 요일") + private List workingDays; + + @Schema(description = "시작 시간", example = "09:00") + private LocalTime startTime; + + @Schema(description = "종료 시간", example = "18:00") + private LocalTime endTime; + + @Schema(description = "필요 인원", example = "3") + private int positionsNeeded; + + @Schema(description = "남은 인원", example = "2") + private int positionsAvailable; + + @Schema(description = "포지션", example = "홀 서빙") + private String position; + + @Schema(description = "스케줄 상태") + private PostingStatus status; + + public static AdminPostingScheduleDto from(PostingSchedule schedule) { + return AdminPostingScheduleDto.builder() + .id(schedule.getId()) + .workingDays(schedule.getWorkingDays()) + .startTime(schedule.getStartTime()) + .endTime(schedule.getEndTime()) + .positionsNeeded(schedule.getPositionsNeeded()) + .positionsAvailable(schedule.getPositionsAvailable()) + .position(schedule.getPosition()) + .status(schedule.getStatus()) + .build(); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingWorkspaceDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingWorkspaceDto.java new file mode 100644 index 0000000..b7a1f8c --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingWorkspaceDto.java @@ -0,0 +1,38 @@ +package com.dreamteam.alter.adapter.inbound.admin.posting.dto; + +import com.dreamteam.alter.domain.workspace.entity.Workspace; +import io.swagger.v3.oas.annotations.media.Schema; +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 AdminPostingWorkspaceDto { + + @Schema(description = "업장 ID", example = "1") + private Long id; + + @Schema(description = "상호명", example = "스타벅스 강남점") + private String businessName; + + @Schema(description = "주소", example = "서울시 강남구 역삼동 123-45") + private String fullAddress; + + @Schema(description = "연락처", example = "02-1234-5678") + private String contact; + + public static AdminPostingWorkspaceDto from(Workspace workspace) { + return AdminPostingWorkspaceDto.builder() + .id(workspace.getId()) + .businessName(workspace.getBusinessName()) + .fullAddress(workspace.getFullAddress()) + .contact(workspace.getContact()) + .build(); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminUpdatePostingStatusRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminUpdatePostingStatusRequestDto.java new file mode 100644 index 0000000..a933c37 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminUpdatePostingStatusRequestDto.java @@ -0,0 +1,19 @@ +package com.dreamteam.alter.adapter.inbound.admin.posting.dto; + +import com.dreamteam.alter.domain.posting.type.PostingStatus; +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 AdminUpdatePostingStatusRequestDto { + + @NotNull + @Schema(description = "변경할 상태") + private PostingStatus status; +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/AdminPostingQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/AdminPostingQueryRepositoryImpl.java new file mode 100644 index 0000000..4885ba2 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/AdminPostingQueryRepositoryImpl.java @@ -0,0 +1,131 @@ +package com.dreamteam.alter.adapter.outbound.posting.persistence; + +import com.dreamteam.alter.adapter.inbound.admin.posting.dto.AdminPostingListFilterDto; +import com.dreamteam.alter.adapter.inbound.common.dto.PageRequestDto; +import com.dreamteam.alter.adapter.outbound.posting.persistence.readonly.AdminPostingDetailResponse; +import com.dreamteam.alter.adapter.outbound.posting.persistence.readonly.AdminPostingListResponse; +import com.dreamteam.alter.domain.posting.entity.Posting; +import com.dreamteam.alter.domain.posting.entity.PostingKeyword; +import com.dreamteam.alter.domain.posting.entity.QPosting; +import com.dreamteam.alter.domain.posting.entity.QPostingKeywordMap; +import com.dreamteam.alter.domain.posting.type.PostingStatus; +import com.dreamteam.alter.domain.posting.port.outbound.AdminPostingQueryRepository; +import com.dreamteam.alter.domain.workspace.entity.QWorkspace; +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 AdminPostingQueryRepositoryImpl implements AdminPostingQueryRepository { + + private final JPAQueryFactory queryFactory; + private final QPosting posting = QPosting.posting; + private final QWorkspace workspace = QWorkspace.workspace; + private final QPostingKeywordMap postingKeywordMap = QPostingKeywordMap.postingKeywordMap; + + @Override + public long getPostingCount(AdminPostingListFilterDto filter) { + Long count = queryFactory + .select(posting.count()) + .from(posting) + .join(posting.workspace, workspace) + .where( + posting.status.ne(PostingStatus.DELETED), + eqStatus(filter.getStatus()), + containsTitle(filter.getTitle()), + eqWorkspaceId(filter.getWorkspaceId()) + ) + .fetchOne(); + + return count != null ? count : 0L; + } + + @Override + public List getPostingListUsingPagination( + PageRequestDto request, + AdminPostingListFilterDto filter + ) { + return queryFactory + .select(Projections.constructor( + AdminPostingListResponse.class, + posting.id, + posting.title, + posting.payAmount, + posting.paymentType, + posting.status, + workspace.businessName, + posting.createdAt + )) + .from(posting) + .join(posting.workspace, workspace) + .where( + posting.status.ne(PostingStatus.DELETED), + eqStatus(filter.getStatus()), + containsTitle(filter.getTitle()), + eqWorkspaceId(filter.getWorkspaceId()) + ) + .orderBy(posting.createdAt.desc(), posting.id.desc()) + .offset(request.getOffset()) + .limit(request.getLimit()) + .fetch(); + } + + @Override + public Optional getPostingDetail(Long postingId) { + Posting foundPosting = queryFactory + .selectFrom(posting) + .join(posting.workspace, workspace).fetchJoin() + .where( + posting.id.eq(postingId), + posting.status.ne(PostingStatus.DELETED) + ) + .fetchOne(); + + if (foundPosting == null) { + return Optional.empty(); + } + + List keywords = queryFactory + .select(postingKeywordMap.postingKeyword) + .from(postingKeywordMap) + .join(postingKeywordMap.postingKeyword) + .where(postingKeywordMap.posting.id.eq(postingId)) + .fetch(); + + return Optional.of(AdminPostingDetailResponse.of(foundPosting, keywords)); + } + + @Override + public Optional findById(Long postingId) { + Posting foundPosting = queryFactory + .selectFrom(posting) + .where( + posting.id.eq(postingId), + posting.status.ne(PostingStatus.DELETED) + ) + .fetchOne(); + + return Optional.ofNullable(foundPosting); + } + + private BooleanExpression eqStatus(PostingStatus status) { + return ObjectUtils.isNotEmpty(status) ? posting.status.eq(status) : null; + } + + private BooleanExpression containsTitle(String title) { + return ObjectUtils.isNotEmpty(title) + ? posting.title.containsIgnoreCase(title) : null; + } + + private BooleanExpression eqWorkspaceId(Long workspaceId) { + return ObjectUtils.isNotEmpty(workspaceId) + ? posting.workspace.id.eq(workspaceId) : null; + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/AdminPostingDetailResponse.java b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/AdminPostingDetailResponse.java new file mode 100644 index 0000000..e45d782 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/AdminPostingDetailResponse.java @@ -0,0 +1,45 @@ +package com.dreamteam.alter.adapter.outbound.posting.persistence.readonly; + +import com.dreamteam.alter.domain.posting.entity.Posting; +import com.dreamteam.alter.domain.posting.entity.PostingKeyword; +import com.dreamteam.alter.domain.posting.entity.PostingSchedule; +import com.dreamteam.alter.domain.posting.type.PaymentType; +import com.dreamteam.alter.domain.posting.type.PostingStatus; +import com.dreamteam.alter.domain.workspace.entity.Workspace; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@AllArgsConstructor +public class AdminPostingDetailResponse { + private Long id; + private String title; + private String description; + private int payAmount; + private PaymentType paymentType; + private PostingStatus status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Workspace workspace; + private List schedules; + private List keywords; + + public static AdminPostingDetailResponse of(Posting posting, List keywords) { + return new AdminPostingDetailResponse( + posting.getId(), + posting.getTitle(), + posting.getDescription(), + posting.getPayAmount(), + posting.getPaymentType(), + posting.getStatus(), + posting.getCreatedAt(), + posting.getUpdatedAt(), + posting.getWorkspace(), + posting.getSchedules(), + keywords + ); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/AdminPostingListResponse.java b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/AdminPostingListResponse.java new file mode 100644 index 0000000..179f446 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/AdminPostingListResponse.java @@ -0,0 +1,20 @@ +package com.dreamteam.alter.adapter.outbound.posting.persistence.readonly; + +import com.dreamteam.alter.domain.posting.type.PaymentType; +import com.dreamteam.alter.domain.posting.type.PostingStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor +public class AdminPostingListResponse { + private Long id; + private String title; + private int payAmount; + private PaymentType paymentType; + private PostingStatus status; + private String workspaceName; + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminDeletePosting.java b/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminDeletePosting.java new file mode 100644 index 0000000..e0818aa --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminDeletePosting.java @@ -0,0 +1,32 @@ +package com.dreamteam.alter.application.posting.usecase; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.posting.entity.Posting; +import com.dreamteam.alter.domain.posting.port.inbound.AdminDeletePostingUseCase; +import com.dreamteam.alter.domain.posting.port.outbound.AdminPostingQueryRepository; +import com.dreamteam.alter.domain.posting.type.PostingStatus; +import com.dreamteam.alter.domain.user.context.AdminActor; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("adminDeletePosting") +@RequiredArgsConstructor +@Transactional +public class AdminDeletePosting implements AdminDeletePostingUseCase { + + private final AdminPostingQueryRepository adminPostingQueryRepository; + + @Override + public void execute(Long postingId, AdminActor actor) { + Posting posting = adminPostingQueryRepository.findById(postingId) + .orElseThrow(() -> new CustomException(ErrorCode.POSTING_NOT_FOUND)); + + if (PostingStatus.DELETED.equals(posting.getStatus())) { + throw new CustomException(ErrorCode.CONFLICT, "이미 삭제된 공고입니다."); + } + + posting.updateStatus(PostingStatus.DELETED); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminGetPostingDetail.java b/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminGetPostingDetail.java new file mode 100644 index 0000000..eca3292 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminGetPostingDetail.java @@ -0,0 +1,25 @@ +package com.dreamteam.alter.application.posting.usecase; + +import com.dreamteam.alter.adapter.inbound.admin.posting.dto.AdminPostingDetailResponseDto; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.posting.port.inbound.AdminGetPostingDetailUseCase; +import com.dreamteam.alter.domain.posting.port.outbound.AdminPostingQueryRepository; +import com.dreamteam.alter.domain.user.context.AdminActor; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("adminGetPostingDetail") +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminGetPostingDetail implements AdminGetPostingDetailUseCase { + + private final AdminPostingQueryRepository adminPostingQueryRepository; + + @Override + public AdminPostingDetailResponseDto execute(Long postingId, AdminActor actor) { + return AdminPostingDetailResponseDto.from(adminPostingQueryRepository.getPostingDetail(postingId) + .orElseThrow(() -> new CustomException(ErrorCode.POSTING_NOT_FOUND))); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminGetPostingList.java b/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminGetPostingList.java new file mode 100644 index 0000000..1777cb9 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminGetPostingList.java @@ -0,0 +1,47 @@ +package com.dreamteam.alter.application.posting.usecase; + +import com.dreamteam.alter.adapter.inbound.admin.posting.dto.AdminPostingListFilterDto; +import com.dreamteam.alter.adapter.inbound.admin.posting.dto.AdminPostingListResponseDto; +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.posting.persistence.readonly.AdminPostingListResponse; +import com.dreamteam.alter.domain.posting.port.inbound.AdminGetPostingListUseCase; +import com.dreamteam.alter.domain.posting.port.outbound.AdminPostingQueryRepository; +import com.dreamteam.alter.domain.user.context.AdminActor; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service("adminGetPostingList") +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminGetPostingList implements AdminGetPostingListUseCase { + + private final AdminPostingQueryRepository adminPostingQueryRepository; + + @Override + public PaginatedResponseDto execute( + PageRequestDto request, + AdminPostingListFilterDto filter, + AdminActor actor + ) { + long count = adminPostingQueryRepository.getPostingCount(filter); + if (count == 0) { + return PaginatedResponseDto.empty(PageResponseDto.empty(request)); + } + + List postings = adminPostingQueryRepository.getPostingListUsingPagination(request, filter); + + PageResponseDto pageResponseDto = PageResponseDto.of(request, (int) count); + + return PaginatedResponseDto.of( + pageResponseDto, + postings.stream() + .map(AdminPostingListResponseDto::from) + .toList() + ); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminUpdatePostingStatus.java b/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminUpdatePostingStatus.java new file mode 100644 index 0000000..9cfad89 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminUpdatePostingStatus.java @@ -0,0 +1,37 @@ +package com.dreamteam.alter.application.posting.usecase; + +import com.dreamteam.alter.adapter.inbound.admin.posting.dto.AdminUpdatePostingStatusRequestDto; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.posting.entity.Posting; +import com.dreamteam.alter.domain.posting.port.inbound.AdminUpdatePostingStatusUseCase; +import com.dreamteam.alter.domain.posting.port.outbound.AdminPostingQueryRepository; +import com.dreamteam.alter.domain.posting.type.PostingStatus; +import com.dreamteam.alter.domain.user.context.AdminActor; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("adminUpdatePostingStatus") +@RequiredArgsConstructor +@Transactional +public class AdminUpdatePostingStatus implements AdminUpdatePostingStatusUseCase { + + private final AdminPostingQueryRepository adminPostingQueryRepository; + + @Override + public void execute(Long postingId, AdminUpdatePostingStatusRequestDto request, AdminActor actor) { + if (PostingStatus.DELETED.equals(request.getStatus())) { + throw new CustomException(ErrorCode.ILLEGAL_ARGUMENT, "변경 가능한 상태가 아닙니다."); + } + + Posting posting = adminPostingQueryRepository.findById(postingId) + .orElseThrow(() -> new CustomException(ErrorCode.POSTING_NOT_FOUND)); + + if (posting.getStatus().equals(request.getStatus())) { + throw new CustomException(ErrorCode.CONFLICT, "현재 상태가 변경하고자 하는 상태와 동일합니다."); + } + + posting.updateStatus(request.getStatus()); + } +} diff --git a/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminDeletePostingUseCase.java b/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminDeletePostingUseCase.java new file mode 100644 index 0000000..6ce2675 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminDeletePostingUseCase.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.domain.posting.port.inbound; + +import com.dreamteam.alter.domain.user.context.AdminActor; + +public interface AdminDeletePostingUseCase { + void execute(Long postingId, AdminActor actor); +} diff --git a/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminGetPostingDetailUseCase.java b/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminGetPostingDetailUseCase.java new file mode 100644 index 0000000..52a7534 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminGetPostingDetailUseCase.java @@ -0,0 +1,8 @@ +package com.dreamteam.alter.domain.posting.port.inbound; + +import com.dreamteam.alter.adapter.inbound.admin.posting.dto.AdminPostingDetailResponseDto; +import com.dreamteam.alter.domain.user.context.AdminActor; + +public interface AdminGetPostingDetailUseCase { + AdminPostingDetailResponseDto execute(Long postingId, AdminActor actor); +} diff --git a/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminGetPostingListUseCase.java b/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminGetPostingListUseCase.java new file mode 100644 index 0000000..702f526 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminGetPostingListUseCase.java @@ -0,0 +1,15 @@ +package com.dreamteam.alter.domain.posting.port.inbound; + +import com.dreamteam.alter.adapter.inbound.admin.posting.dto.AdminPostingListFilterDto; +import com.dreamteam.alter.adapter.inbound.admin.posting.dto.AdminPostingListResponseDto; +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 AdminGetPostingListUseCase { + PaginatedResponseDto execute( + PageRequestDto request, + AdminPostingListFilterDto filter, + AdminActor actor + ); +} diff --git a/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminUpdatePostingStatusUseCase.java b/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminUpdatePostingStatusUseCase.java new file mode 100644 index 0000000..e5c07ad --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminUpdatePostingStatusUseCase.java @@ -0,0 +1,8 @@ +package com.dreamteam.alter.domain.posting.port.inbound; + +import com.dreamteam.alter.adapter.inbound.admin.posting.dto.AdminUpdatePostingStatusRequestDto; +import com.dreamteam.alter.domain.user.context.AdminActor; + +public interface AdminUpdatePostingStatusUseCase { + void execute(Long postingId, AdminUpdatePostingStatusRequestDto request, AdminActor actor); +} diff --git a/src/main/java/com/dreamteam/alter/domain/posting/port/outbound/AdminPostingQueryRepository.java b/src/main/java/com/dreamteam/alter/domain/posting/port/outbound/AdminPostingQueryRepository.java new file mode 100644 index 0000000..6769761 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/posting/port/outbound/AdminPostingQueryRepository.java @@ -0,0 +1,23 @@ +package com.dreamteam.alter.domain.posting.port.outbound; + +import com.dreamteam.alter.adapter.inbound.admin.posting.dto.AdminPostingListFilterDto; +import com.dreamteam.alter.adapter.inbound.common.dto.PageRequestDto; +import com.dreamteam.alter.adapter.outbound.posting.persistence.readonly.AdminPostingDetailResponse; +import com.dreamteam.alter.adapter.outbound.posting.persistence.readonly.AdminPostingListResponse; +import com.dreamteam.alter.domain.posting.entity.Posting; + +import java.util.List; +import java.util.Optional; + +public interface AdminPostingQueryRepository { + long getPostingCount(AdminPostingListFilterDto filter); + + List getPostingListUsingPagination( + PageRequestDto request, + AdminPostingListFilterDto filter + ); + + Optional getPostingDetail(Long postingId); + + Optional findById(Long postingId); +} diff --git a/src/main/java/com/dreamteam/alter/domain/posting/type/PaymentType.java b/src/main/java/com/dreamteam/alter/domain/posting/type/PaymentType.java index 9b2c7f4..2485489 100644 --- a/src/main/java/com/dreamteam/alter/domain/posting/type/PaymentType.java +++ b/src/main/java/com/dreamteam/alter/domain/posting/type/PaymentType.java @@ -1,9 +1,20 @@ package com.dreamteam.alter.domain.posting.type; +import java.util.Map; + public enum PaymentType { HOURLY, DAILY, WEEKLY, MONTHLY, ; + + public static Map describe() { + return Map.of( + PaymentType.HOURLY, "시급", + PaymentType.DAILY, "일급", + PaymentType.WEEKLY, "주급", + PaymentType.MONTHLY, "월급" + ); + } } From 4e9df05bfb7356db103fa6746a5de59fc907da47 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Fri, 6 Feb 2026 16:17:58 +0900 Subject: [PATCH 7/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 f74e402..309fde8 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 1412909..cf1b614 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()