Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package redot.redot_server.domain.admin.controller;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springdoc.core.annotations.ParameterObject;
import redot.redot_server.domain.admin.controller.docs.AdminRedotMemberControllerDocs;
import redot.redot_server.domain.admin.dto.AdminRedotMemberSearchCondition;
import redot.redot_server.domain.admin.dto.request.AdminRedotMemberUpdateRequest;
import redot.redot_server.domain.admin.dto.response.AdminRedotMemberResponse;
import redot.redot_server.domain.admin.service.AdminRedotMemberService;
import redot.redot_server.global.util.dto.response.PageResponse;

@RestController
@RequestMapping("/api/v1/redot/admin/members")
@RequiredArgsConstructor
public class AdminRedotMemberController implements AdminRedotMemberControllerDocs {

private final AdminRedotMemberService adminRedotMemberService;

@GetMapping
@Override
public ResponseEntity<PageResponse<AdminRedotMemberResponse>> getRedotMembers(
@ParameterObject AdminRedotMemberSearchCondition searchCondition,
@ParameterObject Pageable pageable
) {
return ResponseEntity.ok(adminRedotMemberService.getRedotMembers(searchCondition, pageable));
}

@PutMapping("/{memberId}")
@Override
public ResponseEntity<AdminRedotMemberResponse> updateRedotMember(
@PathVariable("memberId") Long memberId,
@Valid @RequestBody AdminRedotMemberUpdateRequest request
) {
return ResponseEntity.ok(adminRedotMemberService.updateRedotMember(memberId, request));
}

@DeleteMapping("/{memberId}")
@Override
public ResponseEntity<Void> deleteRedotMember(@PathVariable("memberId") Long memberId) {
adminRedotMemberService.deleteRedotMember(memberId);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package redot.redot_server.domain.admin.controller.docs;

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.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import redot.redot_server.domain.admin.dto.AdminRedotMemberSearchCondition;
import redot.redot_server.domain.admin.dto.request.AdminRedotMemberUpdateRequest;
import redot.redot_server.domain.admin.dto.response.AdminRedotMemberResponse;
import redot.redot_server.global.util.dto.response.PageResponse;

@Tag(name = "Admin Redot Member", description = "관리자 Redot 회원 관리 API")
public interface AdminRedotMemberControllerDocs {

@Operation(summary = "Redot 회원 목록 조회", description = "`email`, `name`, `socialProvider`, `status` 조건으로 검색하고 `createdAt` 정렬을 ASC/DESC 로 조정할 수 있습니다.")
@ApiResponse(responseCode = "200", description = "조회 성공",
content = @Content(schema = @Schema(implementation = PageResponse.class)))
ResponseEntity<PageResponse<AdminRedotMemberResponse>> getRedotMembers(
@ParameterObject AdminRedotMemberSearchCondition searchCondition,
@ParameterObject Pageable pageable);

@Operation(summary = "Redot 회원 정보 수정", description = "이름, 프로필 이미지, 상태를 수정합니다.")
@ApiResponse(responseCode = "200", description = "수정 성공",
content = @Content(schema = @Schema(implementation = AdminRedotMemberResponse.class)))
ResponseEntity<AdminRedotMemberResponse> updateRedotMember(
@Parameter(description = "회원 ID", example = "1") Long memberId,
@Valid AdminRedotMemberUpdateRequest request);

@Operation(summary = "Redot 회원 삭제", description = "회원 계정을 탈퇴 처리합니다.")
@ApiResponse(responseCode = "204", description = "삭제 성공")
ResponseEntity<Void> deleteRedotMember(@Parameter(description = "회원 ID", example = "1") Long memberId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package redot.redot_server.domain.admin.dto;

import redot.redot_server.domain.redot.member.entity.RedotMemberStatus;
import redot.redot_server.domain.redot.member.entity.SocialProvider;

public record AdminRedotMemberSearchCondition(
String email,
String name,
SocialProvider socialProvider,
RedotMemberStatus status
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package redot.redot_server.domain.admin.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import redot.redot_server.domain.redot.member.entity.RedotMemberStatus;

public record AdminRedotMemberUpdateRequest(
@NotBlank
String name,
String profileImageUrl,
@NotNull
RedotMemberStatus status
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package redot.redot_server.domain.admin.dto.response;

import java.time.LocalDateTime;
import redot.redot_server.domain.redot.member.entity.RedotMemberStatus;
import redot.redot_server.domain.redot.member.entity.SocialProvider;

public interface AdminRedotMemberProjection {
Long getId();

String getEmail();

String getName();

String getProfileImageUrl();

SocialProvider getSocialProvider();

LocalDateTime getCreatedAt();

RedotMemberStatus getStatus();

Long getAppCount();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package redot.redot_server.domain.admin.dto.response;

import java.time.LocalDateTime;
import redot.redot_server.domain.redot.member.entity.RedotMemberStatus;
import redot.redot_server.domain.redot.member.entity.SocialProvider;

public record AdminRedotMemberProjectionImpl(
Long id,
String email,
String name,
String profileImageUrl,
SocialProvider socialProvider,
LocalDateTime createdAt,
RedotMemberStatus status,
Long appCount
) implements AdminRedotMemberProjection {

@Override
public Long getId() {
return id;
}

@Override
public String getEmail() {
return email;
}

@Override
public String getName() {
return name;
}

@Override
public String getProfileImageUrl() {
return profileImageUrl;
}

@Override
public SocialProvider getSocialProvider() {
return socialProvider;
}

@Override
public LocalDateTime getCreatedAt() {
return createdAt;
}

@Override
public RedotMemberStatus getStatus() {
return status;
}

@Override
public Long getAppCount() {
return appCount;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package redot.redot_server.domain.admin.dto.response;

import java.time.LocalDateTime;
import redot.redot_server.domain.admin.dto.response.AdminRedotMemberProjection;
import redot.redot_server.domain.redot.member.entity.RedotMember;
import redot.redot_server.domain.redot.member.entity.RedotMemberStatus;
import redot.redot_server.domain.redot.member.entity.SocialProvider;
import redot.redot_server.global.s3.util.ImageUrlResolver;

public record AdminRedotMemberResponse(
Long id,
String email,
String name,
String profileImageUrl,
SocialProvider socialProvider,
LocalDateTime createdAt,
RedotMemberStatus status,
long appCount
) {

public static AdminRedotMemberResponse fromProjection(AdminRedotMemberProjection projection,
ImageUrlResolver imageUrlResolver) {
return new AdminRedotMemberResponse(
projection.getId(),
projection.getEmail(),
projection.getName(),
imageUrlResolver.toPublicUrl(projection.getProfileImageUrl()),
projection.getSocialProvider(),
projection.getCreatedAt(),
projection.getStatus(),
projection.getAppCount() == null ? 0L : projection.getAppCount()
);
}

public static AdminRedotMemberResponse fromEntity(RedotMember member,
long appCount,
ImageUrlResolver imageUrlResolver) {
return new AdminRedotMemberResponse(
member.getId(),
member.getEmail(),
member.getName(),
imageUrlResolver.toPublicUrl(member.getProfileImageUrl()),
member.getSocialProvider(),
member.getCreatedAt(),
member.getStatus(),
appCount
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package redot.redot_server.domain.admin.service;

import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import redot.redot_server.domain.admin.dto.AdminRedotMemberSearchCondition;
import redot.redot_server.domain.admin.dto.request.AdminRedotMemberUpdateRequest;
import redot.redot_server.domain.admin.dto.response.AdminRedotMemberProjection;
import redot.redot_server.domain.admin.dto.response.AdminRedotMemberResponse;
import redot.redot_server.domain.auth.exception.AuthErrorCode;
import redot.redot_server.domain.auth.exception.AuthException;
import redot.redot_server.domain.redot.app.repository.RedotAppRepository;
import redot.redot_server.domain.redot.member.entity.RedotMember;
import redot.redot_server.domain.redot.member.entity.RedotMemberStatus;
import redot.redot_server.domain.redot.member.repository.RedotMemberRepository;
import redot.redot_server.global.s3.event.ImageDeletionEvent;
import redot.redot_server.global.s3.util.ImageUrlResolver;
import redot.redot_server.global.util.dto.response.PageResponse;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AdminRedotMemberService {

private final RedotMemberRepository redotMemberRepository;
private final RedotAppRepository redotAppRepository;
private final ApplicationEventPublisher eventPublisher;
private final ImageUrlResolver imageUrlResolver;

public PageResponse<AdminRedotMemberResponse> getRedotMembers(AdminRedotMemberSearchCondition searchCondition,
Pageable pageable) {
if (searchCondition != null && searchCondition.status() == RedotMemberStatus.DELETED) {
throw new AuthException(AuthErrorCode.INVALID_MEMBER_STATUS_FILTER);
}
Page<AdminRedotMemberProjection> summaries = redotMemberRepository.searchAdminMembers(searchCondition, pageable);
Page<AdminRedotMemberResponse> responsePage = summaries.map(summary ->
AdminRedotMemberResponse.fromProjection(summary, imageUrlResolver));
return PageResponse.from(responsePage);
}

@Transactional
public AdminRedotMemberResponse updateRedotMember(Long memberId, AdminRedotMemberUpdateRequest request) {
RedotMember redotMember = redotMemberRepository.findById(memberId)
.orElseThrow(() -> new AuthException(AuthErrorCode.REDOT_MEMBER_NOT_FOUND));

if (request.status() == RedotMemberStatus.DELETED) {
throw new AuthException(AuthErrorCode.INVALID_MEMBER_STATUS_UPDATE);
}

String newProfileImageUrl = imageUrlResolver.toStoredPath(request.profileImageUrl());
deleteOldProfileImageUrlIfChanged(newProfileImageUrl, redotMember);

redotMember.updateInfo(request.name(), newProfileImageUrl);

redotMember.changeStatus(request.status());

return AdminRedotMemberResponse.fromEntity(redotMember, redotAppRepository.countByOwnerId(memberId), imageUrlResolver);
}

@Transactional
public void deleteRedotMember(Long memberId) {
RedotMember redotMember = redotMemberRepository.findById(memberId)
.orElseThrow(() -> new AuthException(AuthErrorCode.REDOT_MEMBER_NOT_FOUND));
redotMember.delete();
}

private void deleteOldProfileImageUrlIfChanged(String newProfileImageUrl, RedotMember member) {
String oldProfileImageUrl = member.getProfileImageUrl();
if (oldProfileImageUrl != null && !oldProfileImageUrl.equals(newProfileImageUrl)) {
eventPublisher.publishEvent(new ImageDeletionEvent(oldProfileImageUrl));
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ public enum AuthErrorCode implements ErrorCode {
INVALID_EMAIL_VERIFICATION_CODE(400, 1024, "이메일 인증 코드가 올바르지 않습니다."),
INVALID_EMAIL_VERIFICATION_TOKEN(400, 1025, "이메일 인증 토큰이 유효하지 않습니다."),
UNSUPPORTED_EMAIL_VERIFICATION_PURPOSE(400, 1026, "지원하지 않는 이메일 인증 용도입니다."),
EMAIL_NOT_VERIFIED(400, 1027, "이메일 인증이 필요합니다.")
EMAIL_NOT_VERIFIED(400, 1027, "이메일 인증이 필요합니다."),
INVALID_MEMBER_STATUS_UPDATE(400, 1028, "지원하지 않는 회원 상태 변경입니다."),
INVALID_MEMBER_STATUS_FILTER(400, 1029, "지원하지 않는 회원 상태 조회입니다."),
BANNED_REDOT_MEMBER(403, 1030, "제한된 계정입니다."),
SOCIAL_EMAIL_REQUIRED(400, 1031, "소셜 계정에서 이메일 정보를 제공해야 합니다.")
;


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import redot.redot_server.domain.redot.member.dto.response.RedotMemberResponse;
import redot.redot_server.domain.redot.member.entity.RedotMember;
import redot.redot_server.domain.redot.member.repository.RedotMemberRepository;
import redot.redot_server.domain.redot.member.service.RedotMemberStatusValidator;
import redot.redot_server.global.s3.util.ImageUrlResolver;
import redot.redot_server.global.jwt.token.TokenContext;
import redot.redot_server.global.jwt.token.TokenType;
Expand All @@ -32,6 +33,7 @@ public class RedotMemberAuthService {
private final AuthTokenService authTokenService;
private final EmailVerificationService emailVerificationService;
private final ImageUrlResolver imageUrlResolver;
private final RedotMemberStatusValidator redotMemberStatusValidator;

@Transactional
public RedotMemberResponse signUp(RedotMemberCreateRequest request) {
Expand Down Expand Up @@ -68,6 +70,8 @@ public AuthResult signIn(HttpServletRequest request, RedotMemberSignInRequest si
throw new AuthException(AuthErrorCode.INVALID_USER_INFO);
}

redotMemberStatusValidator.ensureActive(member);

TokenContext context = new TokenContext(
member.getId(),
TokenType.REDOT_MEMBER,
Expand All @@ -94,8 +98,9 @@ public AuthResult reissue(HttpServletRequest request) {
throw new AuthException(AuthErrorCode.INVALID_TOKEN_SUBJECT);
}

redotMemberRepository.findById(memberId)
RedotMember member = redotMemberRepository.findById(memberId)
.orElseThrow(() -> new AuthException(AuthErrorCode.REDOT_MEMBER_NOT_FOUND));
redotMemberStatusValidator.ensureActive(member);

return authTokenService.issueTokens(request, new TokenContext(
memberId,
Expand All @@ -108,6 +113,7 @@ public AuthResult reissue(HttpServletRequest request) {
public RedotMemberResponse getCurrentMember(Long memberId) {
RedotMember member = redotMemberRepository.findById(memberId)
.orElseThrow(() -> new AuthException(AuthErrorCode.REDOT_MEMBER_NOT_FOUND));
redotMemberStatusValidator.ensureActive(member);

return RedotMemberResponse.fromEntity(member, imageUrlResolver);
}
Expand All @@ -124,6 +130,8 @@ public void resetPassword(PasswordResetConfirmRequest request) {
RedotMember member = redotMemberRepository.findByEmail(normalizedEmail)
.orElseThrow(() -> new AuthException(AuthErrorCode.REDOT_MEMBER_NOT_FOUND));

redotMemberStatusValidator.ensureActive(member);

member.resetPassword(passwordEncoder.encode(request.newPassword()));
}
}
Loading