Skip to content

[FEAT/#368] 백오피스 회원관리 관련 API#371

Open
2ghrms wants to merge 7 commits into
developfrom
feat/#368-member-backoffice-api
Open

[FEAT/#368] 백오피스 회원관리 관련 API#371
2ghrms wants to merge 7 commits into
developfrom
feat/#368-member-backoffice-api

Conversation

@2ghrms

@2ghrms 2ghrms commented Jul 4, 2026

Copy link
Copy Markdown
Member

#️⃣연관된 이슈

#368

📝작업 내용

백오피스 운영자가 회원을 관리할 수 있는 API 6개 기능을 추가하고, 이에 맞춰 Admin/Partner 가입 플로우를 승인제로 변경했습니다.

1. 가입 플로우 변경 (승인제 전환)

  • Admin/Partner 가입 시 isActivated = SUSPEND로 생성, JWT 미발급 (tokens = null)
  • Partner 가입 시 생성되는 Store도 SUSPEND로 생성
  • SUSPEND 계정이 공통 로그인(POST /auth/commons/login) 시도 시 전용 에러 반환
    • MEMBER_4018 (403): "가입 승인 대기 중입니다. 백오피스 승인 후 로그인할 수 있습니다."
    • 비밀번호 검증 상태를 확인하므로 이메일만으로 승인 대기 여부를 알 수 없음 (계정 열거 방지)

2. 백오피스 회원 관리 API (BackofficeMemberController)

Method Path Audit Action
GET /backoffice/members -
GET /backoffice/members/deleted -
GET /backoffice/members/{memberId} -
PATCH /backoffice/members/{memberId}/approve MEMBER_APPROVE
PATCH /backoffice/members/{memberId}/reject MEMBER_REJECT
PATCH /backoffice/members/{memberId}/force-withdraw MEMBER_FORCE_WITHDRAW
PATCH /backoffice/members/{memberId}/restore MEMBER_RESTORE
  • 승인: SUSPENDACTIVE. Partner는 isLicenseVerified=true + Store ACTIVE 처리, Admin은 isSignVerified=true 동시 처리
  • 거절: SUSPENDINACTIVE
  • 강제 탈퇴: 기존 WithdrawalService 로직 재사용 (soft delete + refresh token 전체 삭제). BACKOFFICE 운영자는 대상 제외
  • 복구: deletedAt = null (isActivated는 유지)
  • 목록 조회는 role / status / deleted 필터 + 페이지네이션 지원

3. 서류 조회/검증 API

Method Path Audit Action
GET /backoffice/partners/{memberId}/license -
PATCH /backoffice/partners/{memberId}/license/verify PARTNER_LICENSE_VERIFY
GET /backoffice/admins/{memberId}/sign-image -
  • S3 key → presigned URL(10분) 로 변환해 반환
  • 사업자등록증 검증은 가입 승인과 독립적으로 수행 가능 (승인 전 서류 선검토 플로우 지원)

4. 리팩토링

  • MemberRepository 쿼리 역할별 분리
    • 목록: Member만 조회 → 역할별 batch 쿼리(IN 절)로 프로필 보강 (페이지당 최대 3쿼리)
    • 상세/변경: findStudentWithProfileById(ssuAuth+studentProfile), findAdmin...(commonAuth+adminProfile), findPartner...(commonAuth+partnerProfile)
  • 상세 DTO 역할별 분리: flat 구조 → base + student/admin/partner nested 구조
    • 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)
  • 신규/변경 API Swagger @Operation 명세 작성 (기존 컨벤션 준수)
  • 테스트: BackofficeMemberServiceTest(승인/강제탈퇴 가드/복구), LoginServiceSuspendTest(SUSPEND 로그인 차단), BackofficeSecurityIntegrationTest 확장(RBAC + 감사 로그 insert 검증)

🔎코드 설명

SUSPEND 로그인 차단 위치CommonAuthAdapter에서 SUSPENDenabled=true로 통과시키고, LoginServiceImpl.loginCommon()에서 인증 성공 직후 상태를 검사해 MEMBER_4018을 던집니다. Adapter의 disabled 처리로 막으면 비밀번호 검증 전에 차단되어 이메일 열거가 가능해지고 전용 에러 코드도 못 주기 때문입니다. 이때 loadMember()(lastLoginAt 갱신, deletedAt 자동 복구) 부수효과도 승인 전에는 실행되지 않습니다.

컨트롤러 단일 유지 + 서비스 내부 분기 — 회원 상세는 GET /members/{memberId} 하나로 유지하고, BackofficeMemberServiceImpl이 role을 확인해 역할별 쿼리·DTO 매핑으로 분기합니다. 클라이언트가 role을 몰라도 memberId만으로 조회할 수 있습니다.

💬고민사항 및 리뷰 요구사항 (Optional)

  • 승인 = 서류 검증 완료 간주: approve 시 isLicenseVerified/isSignVerified를 함께 true 처리하도록 했습니다. 서류 검증과 계정 승인을 완전히 분리해야 한다면 approve에서 해당 처리를 제거하면 됩니다.
  • 거절(INACTIVE) 후 재가입 플로우 없음: 거절된 계정은 영구 로그인 불가 상태입니다. 재심사/재제출 플로우가 필요한지 의견 부탁드립니다.
  • 탈퇴 회원 로그인 시 자동 복구 유지: CommonAuthAdapter.loadMember()의 기존 자동 복구 동작은 그대로 두고, 백오피스 복구 API를 별도 경로로 추가했습니다. 두 경로가 공존해도 괜찮은지 확인 부탁드립니다.
  • 프론트 영향: Partner/Admin 가입 응답에서 tokens가 null이 되므로, 가입 직후 자동 로그인하던 클라이언트는 "승인 대기" 안내 화면으로 변경이 필요합니다. (MEMBER_4018 코드로 분기 가능)

비고 (Optional)

  • 감사 로그 조회 API(GET /backoffice/audit-logs)는 이번 범위에서 제외 (별도 작업 예정)
  • Student는 uSaint 인증으로 즉시 ACTIVE 유지 (승인 대상 아님), 백오피스 회원 목록 조회에는 포함됨
  • 운영 중 발견: student.major 컬럼이 MySQL ENUM 타입으로 생성되어 Java enum과 목록이 어긋나면 조회가 실패합니다 (ddl-auto: update는 기존 컬럼을 수정하지 않음). VARCHAR로 변경 후 데이터 보정으로 해결했으며, 다른 enum 컬럼도 동일 잠재 이슈가 있습니다.

@2ghrms 2ghrms self-assigned this Jul 4, 2026
@2ghrms 2ghrms added the ✨ feature New feature or request label Jul 4, 2026
@2ghrms 2ghrms linked an issue Jul 4, 2026 that may be closed by this pull request
4 tasks
@2ghrms 2ghrms changed the base branch from main to develop July 4, 2026 08:31
Comment on lines +53 to +55
@RequestParam(required = false) UserRole role,
@RequestParam(required = false) ActivationStatus status,
@RequestParam(required = false) Boolean deleted,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RequestParam이 많아질 때는 ModelAttribute를 사용하면 깔끔할 것 같습니다.

Comment on lines +46 to +53
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;
};
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveName()을 Member 엔티티로 이동하는것도 고려해볼 수 있을 것 같아요.
역할에 따라 이름을 반환하는건 도메인 로직에 가깝다는 생각이 듭니다.
또한 나중에 다른 곳에서도 같은 패턴이 생길 수 있을 것 같아요

Comment on lines 59 to 92
@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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

한번의 로그인을 위해서 findByEmail이 loadUserDetails(), memberPreivew, loadMembers()에서 3번 호출되고 있어요. loadUserDetails에서 Member를 조회할 때 status와 role도 같이 담아 반환하면 SUSPEND 체크를 할 수 있을 것 같습니다!

Comment on lines +104 to +109
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

findById가 위에서 한번, withdrawMember()함수에서도 한번 총 두번 실행되고있어서 하나로 줄일 수 있을 것 같습니다

Comment on lines +278 to +282
private void activatePartnerStore(Partner partner) {
storeRepository.findByPartner(partner).ifPresent(store -> {
store.setIsActivate(ActivationStatus.ACTIVE);
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ifPresent로 존재하면 ACTIVE로 바꾸고 있는데 예외처리 및 로깅도 추가하면 좋을 것 같습니다

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추가 개발 요청입니다!
SUSPEND로 회원가입을 했을 때 디스코드에 알림연결을 해두면 좋을 것 같습니다 (서버 배포 시 오는 알람 처럼)
지금 당장 필요한 기능은 아니지만 있으면 좋을 것 같아 요청드립니다

EXISTED_STUDENT(HttpStatus.CONFLICT,"MEMBER_4009","이미 존재하는 학번입니다."),

MEMBER_ALREADY_WITHDRAWN(HttpStatus.BAD_REQUEST, "MEMBER_4010", "이미 탈퇴된 회원입니다."),
MEMBER_NOT_PENDING_APPROVAL(HttpStatus.BAD_REQUEST, "MEMBER_4012", "승인 대기 상태가 아닌 회원입니다."),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEMBER_4011 이 뛰어넘어져서 하나씩 땡기면 좋을 것 같아요

}

@Override
public BackofficeMemberSummaryDTO restoreMember(Long memberId) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

가입 거절된 (INACTIVE) 회원에 대해서는 복구가 안되는 로직으로 보입니다.
거절 -> 삭제 -> 복구 시에 대해서의 로직도 필요할 것 같습니다

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEAT/#368] 백오피스 API (회원운영)

2 participants