[FEAT/#368] 백오피스 회원관리 관련 API#371
Conversation
| @RequestParam(required = false) UserRole role, | ||
| @RequestParam(required = false) ActivationStatus status, | ||
| @RequestParam(required = false) Boolean deleted, |
There was a problem hiding this comment.
RequestParam이 많아질 때는 ModelAttribute를 사용하면 깔끔할 것 같습니다.
| private static String resolveName(Member member) { | ||
| return switch (member.getRole()) { | ||
| case STUDENT -> member.getStudentProfile() != null ? member.getStudentProfile().getName() : null; | ||
| case ADMIN -> member.getAdminProfile() != null ? member.getAdminProfile().getName() : null; | ||
| case PARTNER -> member.getPartnerProfile() != null ? member.getPartnerProfile().getName() : null; | ||
| case BACKOFFICE -> member.getBackofficeProfile() != null ? member.getBackofficeProfile().getName() : null; | ||
| }; | ||
| } |
There was a problem hiding this comment.
resolveName()을 Member 엔티티로 이동하는것도 고려해볼 수 있을 것 같아요.
역할에 따라 이름을 반환하는건 도메인 로직에 가깝다는 생각이 듭니다.
또한 나중에 다른 곳에서도 같은 패턴이 생길 수 있을 것 같아요
| @Override | ||
| public LoginResponseDTO loginCommon(CommonLoginRequestDTO request) { | ||
| Authentication authentication = authenticationManager.authenticate( | ||
| new LoginUsernamePasswordAuthenticationToken( | ||
| AuthRealm.COMMON, | ||
| request.email(), | ||
| request.password() | ||
| ) | ||
| ); | ||
|
|
||
| RealmAuthAdapter adapter = pickAdapter(AuthRealm.COMMON); | ||
|
|
||
| Member member = adapter.loadMember(authentication.getName()); | ||
| Member memberPreview = commonAuthRepository.findByEmail(authentication.getName()) | ||
| .orElseThrow(() -> new CustomAuthException(ErrorStatus.NO_SUCH_MEMBER)) | ||
| .getMember(); | ||
|
|
||
| if (memberPreview.getIsActivated() == ActivationStatus.SUSPEND) { | ||
| throw new CustomAuthException(ErrorStatus.MEMBER_PENDING_APPROVAL); | ||
| } | ||
|
|
||
| if (member.getRole() == UserRole.BACKOFFICE) { | ||
| if (memberPreview.getRole() == UserRole.BACKOFFICE) { | ||
| throw new GeneralException(ErrorStatus.BACKOFFICE_USE_DEDICATED_LOGIN); | ||
| } | ||
|
|
||
| // 토큰 발급 (Access 미저장, Refresh는 Redis 저장) | ||
| Member member = adapter.loadMember(authentication.getName()); | ||
| TokensDTO tokens = jwtUtil.issueTokens( | ||
| member.getId(), | ||
| authentication.getName(), | ||
| member.getRole(), | ||
| adapter.authRealmValue() | ||
| ); | ||
|
|
||
| return LoginResponseDTO.from(member, tokens); | ||
| } |
There was a problem hiding this comment.
한번의 로그인을 위해서 findByEmail이 loadUserDetails(), memberPreivew, loadMembers()에서 3번 호출되고 있어요. loadUserDetails에서 Member를 조회할 때 status와 role도 같이 담아 반환하면 SUSPEND 체크를 할 수 있을 것 같습니다!
| Member member = memberRepository.findById(memberId) | ||
| .orElseThrow(() -> new CustomAuthException(ErrorStatus.NO_SUCH_MEMBER)); | ||
| if (member.getRole() == UserRole.BACKOFFICE) { | ||
| throw new CustomAuthException(ErrorStatus.CANNOT_WITHDRAW_BACKOFFICE_MEMBER); | ||
| } | ||
| withdrawalService.withdrawMember(memberId); |
There was a problem hiding this comment.
findById가 위에서 한번, withdrawMember()함수에서도 한번 총 두번 실행되고있어서 하나로 줄일 수 있을 것 같습니다
| private void activatePartnerStore(Partner partner) { | ||
| storeRepository.findByPartner(partner).ifPresent(store -> { | ||
| store.setIsActivate(ActivationStatus.ACTIVE); | ||
| }); | ||
| } |
There was a problem hiding this comment.
ifPresent로 존재하면 ACTIVE로 바꾸고 있는데 예외처리 및 로깅도 추가하면 좋을 것 같습니다
There was a problem hiding this comment.
추가 개발 요청입니다!
SUSPEND로 회원가입을 했을 때 디스코드에 알림연결을 해두면 좋을 것 같습니다 (서버 배포 시 오는 알람 처럼)
지금 당장 필요한 기능은 아니지만 있으면 좋을 것 같아 요청드립니다
| EXISTED_STUDENT(HttpStatus.CONFLICT,"MEMBER_4009","이미 존재하는 학번입니다."), | ||
|
|
||
| MEMBER_ALREADY_WITHDRAWN(HttpStatus.BAD_REQUEST, "MEMBER_4010", "이미 탈퇴된 회원입니다."), | ||
| MEMBER_NOT_PENDING_APPROVAL(HttpStatus.BAD_REQUEST, "MEMBER_4012", "승인 대기 상태가 아닌 회원입니다."), |
There was a problem hiding this comment.
MEMBER_4011 이 뛰어넘어져서 하나씩 땡기면 좋을 것 같아요
| } | ||
|
|
||
| @Override | ||
| public BackofficeMemberSummaryDTO restoreMember(Long memberId) { |
There was a problem hiding this comment.
가입 거절된 (INACTIVE) 회원에 대해서는 복구가 안되는 로직으로 보입니다.
거절 -> 삭제 -> 복구 시에 대해서의 로직도 필요할 것 같습니다
#️⃣연관된 이슈
📝작업 내용
백오피스 운영자가 회원을 관리할 수 있는 API 6개 기능을 추가하고, 이에 맞춰 Admin/Partner 가입 플로우를 승인제로 변경했습니다.
1. 가입 플로우 변경 (승인제 전환)
isActivated = SUSPEND로 생성, JWT 미발급 (tokens = null)SUSPEND로 생성SUSPEND계정이 공통 로그인(POST /auth/commons/login) 시도 시 전용 에러 반환MEMBER_4018(403): "가입 승인 대기 중입니다. 백오피스 승인 후 로그인할 수 있습니다."2. 백오피스 회원 관리 API (
BackofficeMemberController)/backoffice/members/backoffice/members/deleted/backoffice/members/{memberId}/backoffice/members/{memberId}/approveMEMBER_APPROVE/backoffice/members/{memberId}/rejectMEMBER_REJECT/backoffice/members/{memberId}/force-withdrawMEMBER_FORCE_WITHDRAW/backoffice/members/{memberId}/restoreMEMBER_RESTORESUSPEND→ACTIVE. Partner는isLicenseVerified=true+ StoreACTIVE처리, Admin은isSignVerified=true동시 처리SUSPEND→INACTIVEWithdrawalService로직 재사용 (soft delete + refresh token 전체 삭제). BACKOFFICE 운영자는 대상 제외deletedAt = null(isActivated는 유지)role/status/deleted필터 + 페이지네이션 지원3. 서류 조회/검증 API
/backoffice/partners/{memberId}/license/backoffice/partners/{memberId}/license/verifyPARTNER_LICENSE_VERIFY/backoffice/admins/{memberId}/sign-image4. 리팩토링
MemberRepository쿼리 역할별 분리Member만 조회 → 역할별 batch 쿼리(IN절)로 프로필 보강 (페이지당 최대 3쿼리)findStudentWithProfileById(ssuAuth+studentProfile),findAdmin...(commonAuth+adminProfile),findPartner...(commonAuth+partnerProfile)base+student/admin/partnernested 구조BackofficeMemberBaseDetailDTO/BackofficeStudentProfileDetailDTO/BackofficeAdminProfileDetailDTO/BackofficePartnerProfileDetailDTO@JsonInclude(NON_NULL)로 해당 역할 프로필만 응답에 포함WithdrawalService.withdrawMember(Long)공개 메서드로 추출 (자진 탈퇴/강제 탈퇴 공통화)5. 기타
ErrorStatus추가:MEMBER_NOT_PENDING_APPROVAL(4012),CANNOT_WITHDRAW_BACKOFFICE_MEMBER(4013),MEMBER_NOT_DELETED(4014),LICENSE_NOT_FOUND(4015),SIGN_IMAGE_NOT_FOUND(4016),MEMBER_APPROVAL_NOT_SUPPORTED(4017),MEMBER_PENDING_APPROVAL(4018)@Operation명세 작성 (기존 컨벤션 준수)BackofficeMemberServiceTest(승인/강제탈퇴 가드/복구),LoginServiceSuspendTest(SUSPEND 로그인 차단),BackofficeSecurityIntegrationTest확장(RBAC + 감사 로그 insert 검증)🔎코드 설명
SUSPEND 로그인 차단 위치 —
CommonAuthAdapter에서SUSPEND도enabled=true로 통과시키고,LoginServiceImpl.loginCommon()에서 인증 성공 직후 상태를 검사해MEMBER_4018을 던집니다. Adapter의disabled처리로 막으면 비밀번호 검증 전에 차단되어 이메일 열거가 가능해지고 전용 에러 코드도 못 주기 때문입니다. 이때loadMember()(lastLoginAt 갱신, deletedAt 자동 복구) 부수효과도 승인 전에는 실행되지 않습니다.컨트롤러 단일 유지 + 서비스 내부 분기 — 회원 상세는
GET /members/{memberId}하나로 유지하고,BackofficeMemberServiceImpl이 role을 확인해 역할별 쿼리·DTO 매핑으로 분기합니다. 클라이언트가 role을 몰라도 memberId만으로 조회할 수 있습니다.💬고민사항 및 리뷰 요구사항 (Optional)
isLicenseVerified/isSignVerified를 함께 true 처리하도록 했습니다. 서류 검증과 계정 승인을 완전히 분리해야 한다면 approve에서 해당 처리를 제거하면 됩니다.CommonAuthAdapter.loadMember()의 기존 자동 복구 동작은 그대로 두고, 백오피스 복구 API를 별도 경로로 추가했습니다. 두 경로가 공존해도 괜찮은지 확인 부탁드립니다.tokens가 null이 되므로, 가입 직후 자동 로그인하던 클라이언트는 "승인 대기" 안내 화면으로 변경이 필요합니다. (MEMBER_4018코드로 분기 가능)비고 (Optional)
GET /backoffice/audit-logs)는 이번 범위에서 제외 (별도 작업 예정)ACTIVE유지 (승인 대상 아님), 백오피스 회원 목록 조회에는 포함됨student.major컬럼이 MySQLENUM타입으로 생성되어 Java enum과 목록이 어긋나면 조회가 실패합니다 (ddl-auto: update는 기존 컬럼을 수정하지 않음).VARCHAR로 변경 후 데이터 보정으로 해결했으며, 다른 enum 컬럼도 동일 잠재 이슈가 있습니다.