diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1c7591fa..4126ea6c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -315,7 +315,7 @@ public class ChallengerRequest { ### File Names | Type | Pattern | Example | -| ---------- | -------------------------------------- | ----------------------------------- | +|------------|----------------------------------------|-------------------------------------| | Entity | `{Domain}.java` | `Challenger.java` | | Enum | `{Domain}{Type}.java` | `ChallengerStatus.java` | | UseCase | `{Action}{Domain}UseCase.java` | `RegisterChallengerUseCase.java` | diff --git a/build.gradle.kts b/build.gradle.kts index f59c3dcd..0e80ccb7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -61,6 +61,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-aop") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-oauth2-client") // OAuth2 Client implementation("org.springframework.boot:spring-boot-starter-data-jpa") // JWT diff --git "a/docs/RBAC_\352\265\254\355\230\204_\352\260\200\354\235\264\353\223\234.md" "b/docs/RBAC_\352\265\254\355\230\204_\352\260\200\354\235\264\353\223\234.md" new file mode 100644 index 00000000..84d8a5ce --- /dev/null +++ "b/docs/RBAC_\352\265\254\355\230\204_\352\260\200\354\235\264\353\223\234.md" @@ -0,0 +1,1238 @@ +# RBAC + ABAC 구현 가이드 + +## 개요 + +UMC Product Server에서 **RBAC (Role-Based Access Control) + ABAC (Attribute-Based Access Control) 하이브리드** 권한 제어 시스템을 구현하는 방법을 설명합니다. + +### 왜 RBAC + ABAC 하이브리드인가? + +단순한 RBAC만으로는 복잡한 비즈니스 요구사항을 처리하기 어렵고, 순수 ABAC는 관리가 복잡합니다. 따라서 두 방식의 장점을 결합합니다: + +- **RBAC**: "누가" (Who) - 빠른 역할 기반 필터링 +- **ABAC**: "무엇을", "언제", "어디서" (What, When, Where) - 컨텍스트 기반 세밀한 제어 + +--- + +## 본문 + +### 전체 아키텍처 + +``` +┌──────────────────────────────────────────────────┐ +│ Layer 1: RBAC (Spring Security) │ +│ - MemberRole: ADMIN, MEMBER │ +│ - ChallengerRole: SCHOOL_PRESIDENT, etc. │ +│ - @PreAuthorize("hasRole('...')") │ +│ - @PreAuthorize("hasAuthority('...')") │ +│ → 빠른 필터링: "운영진인가?" │ +└──────────────────────────────────────────────────┘ + ↓ +┌──────────────────────────────────────────────────┐ +│ Layer 2: ABAC (PermissionEvaluator) │ +│ - hasPermission(#id, 'TYPE', 'ACTION') │ +│ - Subject: memberId, organizationIds, roles │ +│ - Resource: ownerId, organizationId, status │ +│ - Environment: time, location, deadline │ +│ → 세밀한 제어: "어느 조직의 운영진인가?" │ +└──────────────────────────────────────────────────┘ + ↓ +┌──────────────────────────────────────────────────┐ +│ Layer 3: Business Logic (Service) │ +│ - 복잡한 도메인 규칙 │ +│ - 다중 조건 검증 │ +│ - 트랜잭션 보장 │ +│ → 비즈니스 로직: "계층 구조 검증" │ +└──────────────────────────────────────────────────┘ +``` + +--- + +## Layer 1: RBAC (Role-Based Access Control) + +### 1.1 MemberRole (시스템 레벨) + +#### 역할 정의 + +UMC 프로젝트에서는 **MemberRole을 최소화**하여 시스템 레벨 권한만 관리합니다. + +```java +// member/domain/MemberRole.java +public enum MemberRole { + ADMIN("시스템 관리자"), // 운영사무국 직원, 슈퍼 관리자 + MEMBER("일반 회원"); // 기본값 + + private final String description; + + MemberRole(String description) { + this.description = description; + } + + public String getAuthority() { + return "ROLE_" + this.name(); + } +} +``` + +**용도:** +- ✅ 시스템 전역 권한 (모든 데이터 접근, 시스템 설정) +- ✅ 챌린저가 아닌 사람 구분 +- ✅ 기본 인증 여부 + +**예시:** +```java +// ADMIN만 접근 +@PreAuthorize("hasRole('ADMIN')") +public ApiResponse deleteAllData() { } + +// 인증된 사용자 (MEMBER 이상) +@PreAuthorize("hasRole('MEMBER')") +public ApiResponse getMyProfile() { } +``` + +#### Member Entity 수정 + +```java +// member/domain/Member.java +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "member") +public class Member extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false, unique = true) + private String email; + + // MemberRole 추가 + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private MemberRole role; + + @Builder + private Member(String name, String email, MemberRole role) { + this.name = name; + this.email = email; + this.role = role != null ? role : MemberRole.MEMBER; // 기본값 + } +} +``` + +#### Flyway Migration + +```sql +-- db/migration/V{version}__add_member_role.sql +ALTER TABLE member +ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'MEMBER'; + +-- 기존 데이터에 기본값 설정 +UPDATE member SET role = 'MEMBER' WHERE role IS NULL; +``` + +--- + +### 1.2 ChallengerRole (비즈니스 레벨) + +#### RoleType 계층 구조 + +실제 UMC 조직 내 역할은 `RoleType`으로 관리합니다. + +```java +// challenger/domain/enums/RoleType.java +public enum RoleType { + // 중앙 (레벨 90~100) + CENTRAL_PRESIDENT(100, "총괄"), + CENTRAL_VICE_PRESIDENT(95, "부총괄"), + CENTRAL_DIRECTOR(90, "국장"), + CENTRAL_MANAGER(85, "국원"), + CENTRAL_PART_LEADER(80, "중앙 파트장"), + + // 지부 (레벨 70~79) + CHAPTER_LEADER(75, "지부장"), + CHAPTER_STAFF(70, "지부 운영진"), + + // 학교 (레벨 60~69) + SCHOOL_PRESIDENT(65, "회장"), + SCHOOL_VICE_PRESIDENT(63, "부회장"), + SCHOOL_PART_LEADER(61, "파트장"), + SCHOOL_STAFF(60, "기타 운영진"), + + // 일반 (레벨 10) + CHALLENGER(10, "챌린저"); + + private final int level; + private final String description; + + RoleType(int level, String description) { + this.level = level; + this.description = description; + } + + public int getLevel() { + return level; + } + + public String getDescription() { + return description; + } + + public boolean isHigherThan(RoleType other) { + return this.level > other.level; + } + + public boolean isStaffRole() { + return this.level >= SCHOOL_STAFF.level; + } +} +``` + +#### ChallengerRole Entity 개선 + +```java +// challenger/domain/ChallengerRole.java +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "challenger_role") +public class ChallengerRole { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "challenger_id", nullable = false) + private Challenger challenger; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private RoleType roleType; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private OrganizationType organizationType; + + @Column(nullable = false) + private Long organizationId; + + @Enumerated(EnumType.STRING) + @Column(name = "leading_part") + private ChallengerPart leadingPart; // 파트장인 경우 + + @Column(nullable = false) + private Long gisuId; + + // 도메인 로직: 다른 역할을 관리할 수 있는지 + public boolean canManage(ChallengerRole targetRole) { + // 같은 조직이 아니면 불가 + if (!this.organizationId.equals(targetRole.getOrganizationId())) { + return false; + } + + // 계층 구조에 따른 판단 + return this.roleType.isHigherThan(targetRole.getRoleType()); + } + + public boolean isStaffRole() { + return roleType.isStaffRole(); + } +} +``` + +--- + +### 1.3 MemberPrincipal 확장 + +Spring Security의 Principal에 **현재 기수의 Challenger 정보와 역할들**을 포함합니다. + +```java +// global/security/MemberPrincipal.java +@Getter +public class MemberPrincipal implements OAuth2User { + + // 기본 정보 + private final Long memberId; + private final String email; + private final MemberRole memberRole; + + // 현재 활성 기수의 Challenger 정보 + private final Long currentChallengerId; + private final Long currentGisuId; + private final List challengerRoles; // 현재 기수의 역할들 + + // OAuth2 + private final Map attributes; + private final String nameAttributeKey; + + // 전체 생성자 + public MemberPrincipal( + Long memberId, + String email, + MemberRole memberRole, + Long currentChallengerId, + Long currentGisuId, + List challengerRoles, + Map attributes, + String nameAttributeKey) { + this.memberId = memberId; + this.email = email; + this.memberRole = memberRole; + this.currentChallengerId = currentChallengerId; + this.currentGisuId = currentGisuId; + this.challengerRoles = challengerRoles != null ? challengerRoles : Collections.emptyList(); + this.attributes = attributes; + this.nameAttributeKey = nameAttributeKey; + } + + // JWT용 간단 생성자 + public MemberPrincipal(Long memberId, String email, MemberRole memberRole) { + this(memberId, email, memberRole, null, null, Collections.emptyList(), + Collections.emptyMap(), "id"); + } + + @Override + public Collection getAuthorities() { + List authorities = new ArrayList<>(); + + // 1. MemberRole 추가 + authorities.add(new SimpleGrantedAuthority(memberRole.getAuthority())); + + // 2. ChallengerRole들 추가 (현재 기수만) + for (RoleType roleType : challengerRoles) { + authorities.add(new SimpleGrantedAuthority(roleType.name())); + } + + return authorities; + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public String getName() { + return String.valueOf(memberId); + } + + // 편의 메서드 + public boolean isChallenger() { + return currentChallengerId != null; + } + + public boolean hasRole(RoleType roleType) { + return challengerRoles.contains(roleType); + } + + public boolean isStaff() { + return challengerRoles.stream() + .anyMatch(RoleType::isStaffRole); + } +} +``` + +**Authorities 예시:** +```java +MemberPrincipal { + memberId: 123, + memberRole: MEMBER, + currentChallengerId: 456, + challengerRoles: [SCHOOL_PRESIDENT, SCHOOL_PART_LEADER], + + // getAuthorities() 결과 + authorities: [ + "ROLE_MEMBER", // hasRole('MEMBER')로 체크 + "SCHOOL_PRESIDENT", // hasAuthority('SCHOOL_PRESIDENT')로 체크 + "SCHOOL_PART_LEADER" // hasAuthority('SCHOOL_PART_LEADER')로 체크 + ] +} +``` + +--- + +### 1.4 JwtTokenProvider 수정 + +JWT 인증 시 **현재 활성 기수의 Challenger와 역할을 로드**합니다. + +```java +// global/security/JwtTokenProvider.java +@Component +@RequiredArgsConstructor +public class JwtTokenProvider { + + private final LoadMemberPort loadMemberPort; + private final LoadChallengerPort loadChallengerPort; + private final LoadChallengerRolePort loadChallengerRolePort; + private final GetCurrentGisuUseCase getCurrentGisuUseCase; + + @Value("${jwt.secret}") + private String secretKey; + + @Value("${jwt.expiration}") + private long validityInMilliseconds; + + public String createToken(Long memberId) { + Claims claims = Jwts.claims(); + claims.put("memberId", memberId); + + Date now = new Date(); + Date validity = new Date(now.getTime() + validityInMilliseconds); + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(validity) + .signWith(SignatureAlgorithm.HS256, secretKey) + .compact(); + } + + public Authentication getAuthentication(String token) { + Claims claims = parseClaims(token); + Long memberId = claims.get("memberId", Long.class); + + // 1. Member 조회 + Member member = loadMemberPort.loadById(memberId) + .orElseThrow(() -> new AuthenticationException("Member not found")); + + // 2. 현재 활성 기수 조회 + Long currentGisuId = getCurrentGisuUseCase.getCurrentGisuId(); + + // 3. 현재 기수의 Challenger 조회 + Optional challengerOpt = loadChallengerPort + .findByMemberIdAndGisuId(memberId, currentGisuId); + + // 4. Challenger가 있으면 역할 로드 + Long currentChallengerId = null; + List challengerRoles = new ArrayList<>(); + + if (challengerOpt.isPresent()) { + Challenger challenger = challengerOpt.get(); + currentChallengerId = challenger.getId(); + + // 현재 기수의 ChallengerRole 조회 + List roles = loadChallengerRolePort + .findByChallengerId(challenger.getId()); + + challengerRoles = roles.stream() + .map(ChallengerRole::getRoleType) + .collect(Collectors.toList()); + } + + // 5. MemberPrincipal 생성 + MemberPrincipal principal = new MemberPrincipal( + member.getId(), + member.getEmail(), + member.getRole(), + currentChallengerId, + currentGisuId, + challengerRoles, + Collections.emptyMap(), + "id" + ); + + return new UsernamePasswordAuthenticationToken( + principal, + token, + principal.getAuthorities() + ); + } + + private Claims parseClaims(String token) { + return Jwts.parser() + .setSigningKey(secretKey) + .parseClaimsJws(token) + .getBody(); + } +} +``` + +--- + +### 1.5 RoleHierarchy 설정 + +```java +// global/config/MethodSecurityConfig.java +@Configuration +@EnableMethodSecurity +public class MethodSecurityConfig { + + @Bean + public RoleHierarchy roleHierarchy() { + RoleHierarchyImpl hierarchy = new RoleHierarchyImpl(); + + // ADMIN > MEMBER + String hierarchyString = """ + ROLE_ADMIN > ROLE_MEMBER + """; + + hierarchy.setHierarchy(hierarchyString); + return hierarchy; + } + + @Bean + public MethodSecurityExpressionHandler methodSecurityExpressionHandler( + ApplicationContext applicationContext, + UmcPermissionEvaluator umcPermissionEvaluator) { + + DefaultMethodSecurityExpressionHandler handler = + new DefaultMethodSecurityExpressionHandler(); + + handler.setPermissionEvaluator(umcPermissionEvaluator); + handler.setRoleHierarchy(roleHierarchy()); + handler.setApplicationContext(applicationContext); + + return handler; + } +} +``` + +--- + +### 1.6 @Public 어노테이션 (Meta-annotation) + +공개 API를 간결하게 표시하기 위한 커스텀 어노테이션입니다. + +```java +// global/security/annotation/Public.java +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@PreAuthorize("permitAll()") // Meta-annotation +public @interface Public { +} +``` + +**사용 예시:** + +```java +@RestController +@RequestMapping("/api/v1/posts") +public class PostController { + + @GetMapping + @Public // 인증 없이 접근 가능 + public ApiResponse getPublicPosts() { + return ApiResponse.success(postService.getPublicPosts()); + } + + @PostMapping + @PreAuthorize("hasRole('MEMBER')") // 인증 필요 + public ApiResponse createPost(@RequestBody CreatePostRequest request) { + return ApiResponse.success(postService.create(request)); + } +} +``` + +--- + +## Layer 2: ABAC (Attribute-Based Access Control) + +### 2.1 ABAC 속성 분류 + +```java +// 1. Subject Attributes (주체 속성) +principal.memberId +principal.memberRole (ADMIN, MEMBER) +principal.currentChallengerId +principal.challengerRoles (SCHOOL_PRESIDENT, etc.) +principal.currentGisuId + +// 2. Resource Attributes (리소스 속성) +post.authorId +post.organizationId +notice.scope (GLOBAL, ORGANIZATION, PERSONAL) +activity.deadline +schedule.attendanceWindow + +// 3. Environment Attributes (환경 속성) +LocalDateTime.now() +request.location +request.ipAddress + +// 4. Action Attributes (행위 속성) +READ, CREATE, UPDATE, DELETE +APPROVE, REJECT +SUBMIT, REVIEW +``` + +--- + +### 2.2 PermissionEvaluator 구현 + +Spring Security의 `hasPermission()` 표현식을 처리하는 핵심 컴포넌트입니다. + +```java +// global/security/UmcPermissionEvaluator.java +@Component +@RequiredArgsConstructor +public class UmcPermissionEvaluator implements PermissionEvaluator { + + private final LoadChallengerRolePort loadChallengerRolePort; + private final LoadChallengerPort loadChallengerPort; + private final LoadPostPort loadPostPort; + private final LoadActivityPort loadActivityPort; + private final LoadNoticePort loadNoticePort; + + @Override + public boolean hasPermission( + Authentication authentication, + Serializable targetId, + String targetType, + Object permission) { + + if (authentication == null || + !(authentication.getPrincipal() instanceof MemberPrincipal)) { + return false; + } + + MemberPrincipal principal = (MemberPrincipal) authentication.getPrincipal(); + String permissionStr = permission.toString(); + + return switch (targetType.toUpperCase()) { + case "POST" -> evaluatePostPermission(principal, (Long) targetId, permissionStr); + case "NOTICE" -> evaluateNoticePermission(principal, (Long) targetId, permissionStr); + case "ACTIVITY" -> evaluateActivityPermission(principal, (Long) targetId, permissionStr); + default -> false; + }; + } + + @Override + public boolean hasPermission( + Authentication authentication, + Object targetDomainObject, + Object permission) { + // Domain Object 직접 전달 시 + return false; // 필요시 구현 + } + + // ============================================================ + // Post 권한 검증 + // ============================================================ + + private boolean evaluatePostPermission( + MemberPrincipal principal, + Long postId, + String permission) { + + try { + Post post = loadPostPort.loadById(postId) + .orElseThrow(() -> new PostNotFoundException(postId)); + + return switch (permission.toUpperCase()) { + case "UPDATE" -> canUpdatePost(principal, post); + case "DELETE" -> canDeletePost(principal, post); + default -> false; + }; + } catch (Exception e) { + return false; + } + } + + private boolean canUpdatePost(MemberPrincipal principal, Post post) { + // ABAC 규칙 1: ADMIN은 모든 게시글 수정 가능 + if (principal.getMemberRole() == MemberRole.ADMIN) { + return true; + } + + // ABAC 규칙 2: 작성자만 수정 가능 + return post.getAuthorId().equals(principal.getMemberId()); + } + + private boolean canDeletePost(MemberPrincipal principal, Post post) { + // ABAC 규칙 1: ADMIN은 모든 게시글 삭제 가능 + if (principal.getMemberRole() == MemberRole.ADMIN) { + return true; + } + + // ABAC 규칙 2: 작성자는 삭제 가능 + if (post.getAuthorId().equals(principal.getMemberId())) { + return true; + } + + // ABAC 규칙 3: 조직 운영진은 해당 조직 게시글 삭제 가능 + if (post.getOrganizationId() != null) { + return isOrganizationStaff(principal, post.getOrganizationId()); + } + + return false; + } + + // ============================================================ + // Notice 권한 검증 (Scope 기반) + // ============================================================ + + private boolean evaluateNoticePermission( + MemberPrincipal principal, + Long noticeId, + String permission) { + + try { + Notice notice = loadNoticePort.loadById(noticeId) + .orElseThrow(() -> new NoticeNotFoundException(noticeId)); + + return switch (permission.toUpperCase()) { + case "UPDATE", "DELETE" -> canUpdateNotice(principal, notice); + default -> false; + }; + } catch (Exception e) { + return false; + } + } + + private boolean canUpdateNotice(MemberPrincipal principal, Notice notice) { + // ABAC 규칙 1: ADMIN은 모든 공지 수정 가능 + if (principal.getMemberRole() == MemberRole.ADMIN) { + return true; + } + + // ABAC 규칙 2: Scope에 따라 다름 + return switch (notice.getScope()) { + case GLOBAL -> false; // 전체 공지는 ADMIN만 + + case ORGANIZATION -> { + // 해당 조직의 운영진만 + if (notice.getOrganizationId() == null) yield false; + yield isOrganizationStaff(principal, notice.getOrganizationId()); + } + + case PERSONAL -> { + // 작성자만 + yield notice.getAuthorId().equals(principal.getMemberId()); + } + }; + } + + // ============================================================ + // Activity 권한 검증 (시간 기반) + // ============================================================ + + private boolean evaluateActivityPermission( + MemberPrincipal principal, + Long activityId, + String permission) { + + try { + Activity activity = loadActivityPort.loadById(activityId) + .orElseThrow(() -> new ActivityNotFoundException(activityId)); + + return switch (permission.toUpperCase()) { + case "SUBMIT" -> canSubmitActivity(principal, activity); + case "REVIEW" -> canReviewActivity(principal, activity); + default -> false; + }; + } catch (Exception e) { + return false; + } + } + + private boolean canSubmitActivity(MemberPrincipal principal, Activity activity) { + // ABAC 규칙 1: 챌린저만 + if (!principal.isChallenger()) { + return false; + } + + // ABAC 규칙 2: 제출 기한 내 (Environment Attribute) + LocalDateTime now = LocalDateTime.now(); + if (activity.getDeadline() != null && now.isAfter(activity.getDeadline())) { + return false; + } + + // ABAC 규칙 3: 본인의 활동만 + return activity.getChallengerId().equals(principal.getCurrentChallengerId()); + } + + private boolean canReviewActivity(MemberPrincipal principal, Activity activity) { + // ABAC 규칙 1: ADMIN은 모든 활동 검토 가능 + if (principal.getMemberRole() == MemberRole.ADMIN) { + return true; + } + + // ABAC 규칙 2: 운영진만 + if (!principal.isStaff()) { + return false; + } + + // ABAC 규칙 3: 같은 조직의 챌린저 활동만 + try { + Challenger targetChallenger = loadChallengerPort.load(activity.getChallengerId()); + return isSameOrganization(principal, targetChallenger); + } catch (Exception e) { + return false; + } + } + + // ============================================================ + // Helper Methods + // ============================================================ + + private boolean isSameOrganization(MemberPrincipal principal, Long organizationId) { + if (!principal.isChallenger()) { + return false; + } + + List roles = loadChallengerRolePort + .findByChallengerId(principal.getCurrentChallengerId()); + + return roles.stream() + .anyMatch(r -> r.getOrganizationId().equals(organizationId)); + } + + private boolean isSameOrganization(MemberPrincipal principal, Challenger targetChallenger) { + if (!principal.isChallenger()) { + return false; + } + + List principalOrgIds = loadChallengerRolePort + .findByChallengerId(principal.getCurrentChallengerId()) + .stream() + .map(ChallengerRole::getOrganizationId) + .toList(); + + List targetOrgIds = loadChallengerRolePort + .findByChallengerId(targetChallenger.getId()) + .stream() + .map(ChallengerRole::getOrganizationId) + .toList(); + + return principalOrgIds.stream() + .anyMatch(targetOrgIds::contains); + } + + private boolean isOrganizationStaff(MemberPrincipal principal, Long organizationId) { + if (!principal.isChallenger()) { + return false; + } + + List roles = loadChallengerRolePort + .findByChallengerIdAndOrganizationId( + principal.getCurrentChallengerId(), + organizationId + ); + + return roles.stream() + .anyMatch(ChallengerRole::isStaffRole); + } +} +``` + +--- + +## Layer 3: Service 비즈니스 로직 + +### 3.1 복잡한 권한 검증 예시 + +```java +// organization/application/service/OrganizationMemberService.java +@Service +@RequiredArgsConstructor +@Transactional +public class OrganizationMemberService implements RemoveMemberUseCase { + + private final LoadChallengerRolePort loadChallengerRolePort; + private final LoadChallengerPort loadChallengerPort; + private final RemoveMemberPort removeMemberPort; + + @Override + public void removeMember(RemoveMemberCommand command) { + // 1. 실행자의 역할 조회 (현재 기수, 요청한 조직) + List executorRoles = loadChallengerRolePort + .findByChallengerIdAndOrganizationId( + command.getExecutorChallengerId(), + command.getOrganizationId() + ); + + if (executorRoles.isEmpty()) { + throw new InsufficientOrganizationPermissionException( + "해당 조직의 멤버가 아닙니다." + ); + } + + // 2. 대상자의 역할 조회 + Challenger targetChallenger = loadChallengerPort + .findByMemberId(command.getTargetMemberId()) + .orElseThrow(() -> new NotChallengerException()); + + List targetRoles = loadChallengerRolePort + .findByChallengerIdAndOrganizationId( + targetChallenger.getId(), + command.getOrganizationId() + ); + + if (targetRoles.isEmpty()) { + throw new BusinessException(ErrorCode.TARGET_NOT_IN_ORGANIZATION); + } + + // 3. 권한 검증 (계층 구조) + ChallengerRole executorHighestRole = executorRoles.stream() + .max(Comparator.comparing(r -> r.getRoleType().getLevel())) + .orElseThrow(); + + ChallengerRole targetHighestRole = targetRoles.stream() + .max(Comparator.comparing(r -> r.getRoleType().getLevel())) + .orElseThrow(); + + if (!executorHighestRole.canManage(targetHighestRole)) { + throw new InsufficientOrganizationPermissionException( + String.format( + "%s 권한으로는 %s 권한을 가진 멤버를 관리할 수 없습니다.", + executorHighestRole.getRoleType().getDescription(), + targetHighestRole.getRoleType().getDescription() + ) + ); + } + + // 4. 멤버 제거 + for (ChallengerRole role : targetRoles) { + removeMemberPort.remove(role); + } + } +} +``` + +--- + +## 실전 사용 가이드 + +### 패턴 1: 공개 API + +```java +@GetMapping("/posts") +@Public // 또는 @PreAuthorize("permitAll()") +public ApiResponse getPublicPosts() { + return ApiResponse.success(postService.getPublicPosts()); +} +``` + +### 패턴 2: 인증 필요 + +```java +@GetMapping("/my-profile") +@PreAuthorize("hasRole('MEMBER')") +public ApiResponse getMyProfile(@AuthenticationPrincipal MemberPrincipal principal) { + return ApiResponse.success(memberService.getProfile(principal.getMemberId())); +} +``` + +### 패턴 3: ADMIN만 + +```java +@DeleteMapping("/admin/data") +@PreAuthorize("hasRole('ADMIN')") +public ApiResponse deleteAllData() { + adminService.deleteAllData(); + return ApiResponse.success(); +} +``` + +### 패턴 4: 특정 역할 (운영진) + +```java +// Layer 1: 운영진 여부만 체크 +@PostMapping("/activities/{activityId}/review") +@PreAuthorize("hasAnyAuthority('CENTRAL_PRESIDENT', 'SCHOOL_PRESIDENT', 'SCHOOL_STAFF')") +public ApiResponse reviewActivity( + @PathVariable Long activityId, + @AuthenticationPrincipal MemberPrincipal principal, + @RequestBody ReviewRequest request) { + + // Layer 3: Service에서 "어느 조직의 운영진인가?" 검증 + activityService.review(activityId, principal, request); + return ApiResponse.success(); +} +``` + +### 패턴 5: ABAC (소유권 체크) + +```java +@PutMapping("/posts/{postId}") +@PreAuthorize("hasPermission(#postId, 'POST', 'UPDATE')") +public ApiResponse updatePost( + @PathVariable Long postId, + @RequestBody UpdatePostRequest request) { + + postService.update(postId, request); + return ApiResponse.success(); +} +``` + +### 패턴 6: RBAC + ABAC 조합 + +```java +@DeleteMapping("/posts/{postId}") +@PreAuthorize("hasRole('MEMBER') and hasPermission(#postId, 'POST', 'DELETE')") +// ↑ RBAC ↑ ABAC +public ApiResponse deletePost(@PathVariable Long postId) { + postService.delete(postId); + return ApiResponse.success(); +} +``` + +### 패턴 7: Service에서 복잡한 검증 + +```java +@DeleteMapping("/organizations/{orgId}/members/{memberId}") +@PreAuthorize("hasRole('MEMBER')") // 최소 인증만 +public ApiResponse removeMember( + @PathVariable Long orgId, + @PathVariable Long memberId, + @AuthenticationPrincipal MemberPrincipal principal) { + + // Service에서 조직 내 권한 + 계층 구조 검증 + organizationService.removeMember( + new RemoveMemberCommand(orgId, principal.getCurrentChallengerId(), memberId) + ); + + return ApiResponse.success(); +} +``` + +--- + +## 권한 검증 전략 선택 가이드 + +| 시나리오 | Layer | 방법 | 예시 | +|---------|-------|------|------| +| 공개 API | - | `@Public` | 공개 게시글 조회 | +| 인증 필요 | Layer 1 | `hasRole('MEMBER')` | 내 프로필 조회 | +| 시스템 관리자 | Layer 1 | `hasRole('ADMIN')` | 모든 데이터 삭제 | +| 특정 역할 | Layer 1 | `hasAuthority('SCHOOL_PRESIDENT')` | 운영진 여부만 체크 | +| 소유권 체크 | Layer 2 | `hasPermission(#id, 'POST', 'UPDATE')` | 내 게시글만 수정 | +| 조직 권한 | Layer 1+2 | `hasAuthority(...) + hasPermission(...)` | 특정 조직 운영진 | +| 시간 기반 | Layer 2 or 3 | ABAC 또는 Service | 제출 기한 체크 | +| 계층 구조 | Layer 3 | Service | 조직 내 상하 관계 | +| 복합 조건 | Layer 3 | Service | 다중 조건 검증 | + +--- + +## 구현 체크리스트 + +### 1단계: MemberRole 설정 + +- [ ] `MemberRole` enum 생성 (ADMIN, MEMBER) +- [ ] `Member` Entity에 `role` 필드 추가 +- [ ] Flyway Migration 작성 +- [ ] 기존 데이터 마이그레이션 (기본값 MEMBER) + +### 2단계: ChallengerRole 강화 + +- [ ] `RoleType`에 `level` 필드 추가 +- [ ] `RoleType.isStaffRole()` 메서드 구현 +- [ ] `ChallengerRole.canManage()` 도메인 로직 구현 + +### 3단계: MemberPrincipal 확장 + +- [ ] `MemberPrincipal`에 `currentChallengerId`, `challengerRoles` 추가 +- [ ] `getAuthorities()` 메서드 수정 (ChallengerRole 포함) +- [ ] 편의 메서드 추가 (`isChallenger()`, `isStaff()`) + +### 4단계: JwtTokenProvider 수정 + +- [ ] 현재 활성 기수 조회 로직 추가 +- [ ] 현재 기수의 Challenger 조회 +- [ ] 현재 기수의 ChallengerRole 로드 +- [ ] MemberPrincipal 생성 시 전달 + +### 5단계: ABAC (PermissionEvaluator) + +- [ ] `UmcPermissionEvaluator` 구현 +- [ ] Post, Notice, Activity 권한 검증 로직 구현 +- [ ] Helper 메서드 구현 (`isSameOrganization`, etc.) +- [ ] MethodSecurityConfig에 등록 + +### 6단계: Controller 적용 + +- [ ] 공개 API에 `@Public` 추가 +- [ ] 인증 API에 `@PreAuthorize("hasRole('MEMBER')")` 추가 +- [ ] 역할 기반 API에 `hasAuthority(...)` 추가 +- [ ] ABAC 필요 API에 `hasPermission(...)` 추가 + +### 7단계: Service 검증 로직 + +- [ ] 조직 권한 검증 로직 구현 +- [ ] 계층 구조 검증 로직 구현 +- [ ] 시간 기반 검증 로직 구현 + +### 8단계: 테스트 + +- [ ] Unit Test: PermissionEvaluator 테스트 +- [ ] Unit Test: Service 권한 검증 로직 +- [ ] Integration Test: API 레벨 권한 체크 +- [ ] E2E Test: 실제 사용자 시나리오 + +--- + +## Port 인터페이스 예시 + +### LoadChallengerRolePort + +```java +// challenger/application/port/out/LoadChallengerRolePort.java +public interface LoadChallengerRolePort { + + List findByChallengerId(Long challengerId); + + List findByChallengerIdAndOrganizationId( + Long challengerId, + Long organizationId + ); + + Optional findById(Long id); +} +``` + +### GetCurrentGisuUseCase + +```java +// organization/application/port/in/query/GetCurrentGisuUseCase.java +public interface GetCurrentGisuUseCase { + + /** + * 현재 활성 기수 ID 조회 + * @return 현재 활성 기수 ID + */ + Long getCurrentGisuId(); +} +``` + +--- + +## 예외 처리 + +### 권한 예외 클래스 + +```java +// global/exception/InsufficientPermissionException.java +public class InsufficientPermissionException extends BusinessException { + public InsufficientPermissionException(String message) { + super(ErrorCode.INSUFFICIENT_PERMISSION, message); + } +} + +// global/exception/InsufficientOrganizationPermissionException.java +public class InsufficientOrganizationPermissionException extends BusinessException { + public InsufficientOrganizationPermissionException(String message) { + super(ErrorCode.INSUFFICIENT_ORGANIZATION_PERMISSION, message); + } +} + +// global/exception/NotChallengerException.java +public class NotChallengerException extends BusinessException { + public NotChallengerException() { + super(ErrorCode.NOT_CHALLENGER, "챌린저가 아닙니다."); + } +} +``` + +### ErrorCode 추가 + +```java +// global/exception/constant/ErrorCode.java +public enum ErrorCode { + // ... 기존 코드 + + // Authorization + INSUFFICIENT_PERMISSION("AUTH_003", "권한이 부족합니다."), + INSUFFICIENT_ORGANIZATION_PERMISSION("AUTH_004", "조직 내 권한이 부족합니다."), + NOT_CHALLENGER("AUTH_005", "챌린저가 아닙니다."), +} +``` + +--- + +## MemberRole vs ChallengerRole 비교 + +| 구분 | MemberRole | ChallengerRole | +|-----|------------|----------------| +| **위치** | member 도메인 | challenger 도메인 | +| **용도** | 시스템 전역 권한 | 조직 내부 권한 | +| **범위** | 전체 시스템 | 특정 조직 + 기수 | +| **종류** | ADMIN, MEMBER | CENTRAL_PRESIDENT, SCHOOL_STAFF, 등 | +| **설정** | Member Entity에 단일 값 | ChallengerRole Entity (1:N 관계) | +| **검증 시점** | Controller `@PreAuthorize` | Service 레이어 비즈니스 로직 | +| **예시** | "시스템 관리자인가?" | "A대학교 9기 회장인가?" | + +### 사용 예시 + +```java +// MemberRole: 시스템 레벨 +if (principal.getMemberRole() == MemberRole.ADMIN) { + // 전체 시스템 관리자 +} + +// ChallengerRole: 조직 레벨 +if (principal.hasRole(RoleType.SCHOOL_PRESIDENT)) { + // 특정 학교의 회장 (현재 기수) +} +``` + +--- + +## 참고 자료 + +- [Spring Security Method Security](https://docs.spring.io/spring-security/reference/servlet/authorization/method-security.html) +- [RBAC Wikipedia](https://en.wikipedia.org/wiki/Role-based_access_control) +- [ABAC Wikipedia](https://en.wikipedia.org/wiki/Attribute-based_access_control) +- Hexagonal Architecture - Get Your Hands Dirty on Clean Architecture + +--- + +## 부록: 전체 데이터 흐름 + +### 인증 → 권한 검증 → 비즈니스 로직 + +``` +1. 사용자 요청 + ├─ JWT Token: "Bearer eyJhbGc..." + └─ Endpoint: DELETE /api/v1/organizations/123/members/456 + +2. JwtAuthenticationFilter + ├─ JWT 파싱 + ├─ Member 조회 (memberId: 100) + ├─ 현재 기수 조회 (gisuId: 9) + ├─ Challenger 조회 (challengerId: 200, gisuId: 9) + ├─ ChallengerRole 조회 ([SCHOOL_PRESIDENT at orgId: 123]) + └─ MemberPrincipal 생성 + { + memberId: 100, + memberRole: MEMBER, + currentChallengerId: 200, + challengerRoles: [SCHOOL_PRESIDENT], + authorities: [ROLE_MEMBER, SCHOOL_PRESIDENT] + } + +3. Spring Security 검증 + @PreAuthorize("hasAuthority('SCHOOL_PRESIDENT')") + ├─ authorities에 SCHOOL_PRESIDENT 있음? + └─ ✅ 통과 + +4. Controller + public ApiResponse removeMember( + @PathVariable Long orgId, // 123 + @PathVariable Long memberId, // 456 + @AuthenticationPrincipal MemberPrincipal principal) { + + organizationService.removeMember( + new RemoveMemberCommand(orgId, principal.getCurrentChallengerId(), memberId) + ); + } + +5. Service 비즈니스 로직 + ├─ executorRoles = findByChallenger(200) where orgId=123 + │ → [SCHOOL_PRESIDENT at org 123] + ├─ targetRoles = findByMemberId(456) where orgId=123 + │ → [CHALLENGER at org 123] + ├─ same organization? ✅ + ├─ executorRole.level > targetRole.level? + │ SCHOOL_PRESIDENT(65) > CHALLENGER(10) ✅ + └─ 멤버 제거 성공! +``` + +--- + +## 문의 및 기여 + +RBAC + ABAC 구현 관련 질문이나 개선 사항은 팀 Notion 또는 GitHub Issue로 남겨주세요. diff --git a/docs/erd.dbml b/docs/erd.dbml new file mode 100644 index 00000000..3c155b2e --- /dev/null +++ b/docs/erd.dbml @@ -0,0 +1,660 @@ +// Use DBML to define your database structure +// Docs: https://dbml.dbdiagram.io/docs + +// created_at, updated_at은 모든 테이블에 있는 것을 가정합니다. +// base_entity로 제작 + + +// === 사용자 및 회원가입 관련 === + +enum MemberStatus { + PENDING // OAuth 로그인 시도 했던 사람 + ACTIVE // 유효한 회원 + INACTIVE // 휴면 계정 + WITHDRAWN // 탈퇴 계정 +} + +Table member { + id bigint [pk, increment] + name varchar + nickname varchar + email varchar + school_id varchar [ref: > school.id] + profile_image_id bigint + status MemberStatus + + note: '회원가입 시 단계를 구분하기 위한 단계' + + // 사용자의 학교는 챌린저 활동 당시에 결정되도록 함. + // 지원서에 지원하는 학교를 명시하도록 함. +} + +enum OAuthProvider { + GOOGLE + APPLE + KAKAO +} + +Table member_oauth { + id bigint [pk, increment] + member_id bigint [ref: > member.id] + provider OAuthProvider [note: 'OAuth Provider'] + provider_member_id varchar [note: 'OAuth Provider의 ID'] +} + +// === 약관 관련 === + +// Table terms { +// id bigint [pk, increment] +// content varchar +// is_required bool +// } + +// Terms는 따로 문서로 관리하도록 하고, 사용자가 어떤 약관을 동의하였는지 종류만 enum으로 표기하도록 함 + +enum TermType { + SERVICE_POLICY + PRIVACY_POLICY + MARKETING_POLICY +} + +Table member_term_agreement { + id bigint [pk, increment] + member_id bigint [ref: > member.id] + term_type TermType + agreed_at datetime [default: `now()`] +} + +Table school { + id bigint [pk, increment] + name varchar + logo_image_id bigint + email_domain varchar [note: 'cau.ac.kr 처럼 학교별 이메일 '] +} + +Table gisu { + id bigint [pk, increment, note: '기수 번호 (1, 2, 3...)'] + is_active bool [note: '현재 활동 기수 여부'] + start_at datetime + ends_at datetime +} + +Table central_organization { + id bigint [pk, increment] + gisu_id bigint [ref: > gisu.id] + name varchar [default: '9기 중앙운영사무국'] + + Note: '중앙 조직은 단 하나만 존재' +} + +Table chapter { + id bigint [pk, increment] + name varchar + gisu_id bigint [ref: > gisu.id] +} + +// 지부 내 학교 +Table chapter_school { + id bigint [pk, increment] + chapter_id bigint [ref: > chapter.id] + school_id bigint [ref: > school.id] +} + +// === 웹 팀 지원서 관리는 이쪽에 === + + + // === 실제 활동 챌린저 관련 === + +enum ChallengerStatus { + ACTIVE // 활성 + GRADUATED // 수료 + EXPELLED // 제명 (3OUT 포함) + WITHDRAWN // 자진탈부 +} + +enum ChallengerPart { + PLAN + DESIGN + WEB + IOS + ANDROID + SPRINGBOOT + NODEJS +} + +Table challenger { + id bigint [pk, increment] + + member_id bigint [ref: > member.id] + part ChallengerPart + gisu_id bigint [ref: > gisu.id] + status ChallengerStatus +} + +/** +e.g. + +중앙대학교 회장: role_group = 중앙대학교 role = (scope:학교, name:회장) +중앙운영사무국 총괄: role_group = 중앙운영사무국 role = 중앙운영사무국 총괄 + +**/ + +enum OrganizationType { + CENTRAL + CHAPTER + SCHOOL +} + +Table challenger_role { + id bigint [pk, increment] + challenger_id bigint [ref: > challenger.id] + role_type RoleType [not null] + + // 조직 정보 (계층 구조로 명확화) + organization_type OrganizationType [not null, note: '조직 단위'] + organization_id bigint [not null, note: '지부, 학교 ID'] + + // 파트장인 경우 + leadingPart ChallengerPart [null, note: '파트장인 경우만'] + + gisu_id bigint [ref: > gisu.id] +} + +enum PointType { + BEST_WORKBOOK + WARNING + OUT +} + +Table challenger_point { + id bigint [pk, increment] + challenger_id bigint [ref: > challenger.id] + type PointType [note: '상벌점 종류 구분'] + description varchar [note: '사유'] + related_workbook_id bigint [ref: > challenger_workbook.id, note: '사유가 되는 워크북 ID, 기입하지 않을 수도 있어서 nullable'] +} + +// === ROLE === + +// 역할을 일일히 만들어야 하나? +// 교내: 회장, 부회장, 파트장, 기타운영진 +// 지부: 지부장 +// 중앙운영사무국: 총괄, 부총괄, 교육국, 교육국원, + +enum RoleType { + // 중앙 + CENTRAL_PRESIDENT // 총괄 + CENTRAL_VICE_PRESIDENT // 부총괄 + CENTRAL_DIRECTOR // 국장 + CENTRAL_MANAGER // 국원 + CENTRAL_PART_LEADER // 중앙 파트장 + + // 지부 + CHAPTER_LEADER // 지부장 + CHAPTER_STAFF // 지부 운영진 + + // 학교 + SCHOOL_PRESIDENT // 회장 + SCHOOL_VICE_PRESIDENT // 부회장 + SCHOOL_PART_LEADER // 파트장 + SCHOOL_STAFF // 기타 운영진 + + // 일반 + CHALLENGER // 챌린저 +} + + +// === Home === + + + +// === 공지 === + +Table notice { + id bigint [pk, increment] + + title varchar + content varchar + author_challenger_id bigint [ref: > challenger.id, note: '작성자 챌린저 ID, 사용자 ID로 구분?'] + vote_id bigint [ref: > question.id, note: '투표가 없는 경우 null'] + link varchar [note: '링크'] + + // 공지의 카테고리 (누가 작성할 수 있는지) + scope OrganizationType [note: '어떤 scope에서 작성된 공지인지'] + organization_id bigint [not null, note: 'scope에 따라 중앙/지부/학교 ID'] + + // 여러 카테고리의 사람에게 날리는 공지라면? + // e.g. Spring&Node 대상 공지 (개발파트 공지) + // e.g.2 Pla&Design 대상 공지 + + // 누가 이 공지를 볼 수 있는지 + target_roles array [note: 'RoleType의 Array, null이면 전체 역할 대상 (회장단 or 지부장 공지용)'] + target_parts array [note: 'ChallengerPart의 Array, null이면 전체 파트 대상'] + target_gisu_id bigint [ref: > gisu.id, note: 'null아면 전체 기수 대상'] + + // 알람 발송 대상이였는지도 저장해야 하나, OR 그냥 바로 발송하는 방식? + should_notify bool [default: true, note: '알림 전송 여부'] + notified_at datetime [null, note: '알림 발송 시각'] +} + +Table notice_read { + id bigint [pk, increment] + notice_id bigint [ref: > notice.id] + challenger_id bigint [ref: > challenger.id] +} + +Table notice_image { + id bigint [pk, increment] + notice_id bigint [ref: > notice.id] + image_id bigint [ref: > files.id] +} +// === INFRA: S3 === + +Table files { + id bigint [pk, increment] + original_file_name varchar + stored_file_name varchar + s3_key varchar + content_type varchar + file_size varchar + status bool [note: '업로드 여부'] +} + +// ========================================== +// ================ 예은 작업본 시작 ================ +// ========================================== + +// === 활동 부분 === + +// 파트별 커리큘럼 부모 +Table curriculum { + id bigint [pk, increment] + gisu_id bigint [ref: > gisu.id, note: '어떤 기수의 워크북인지'] + part ChallengerPart [note: '어떤 파트의 워크북인지'] +} + +// 파트별 커리큘럼 - 주차 +// 작성할 수 있는 role을 한정 ? - 비즈니스로 할지, role을 엮을지 +Table original_workbook { + id bigint [pk, increment] + curriculum_id bigint [ref: > curriculum.id, note: '해당 워크북이 소속된 커리큘럼'] + title varchar [note: '주차별 워크북 제목'] + description varchar [note: '워크북 설명'] + workbook_url varchar [note: 'Notion으로 워크북 관리 시 URL'] + order_no int [note: '워크북 순서'] +} + +Table workbook_mission { + id bigint [pk, increment] + title varchar [note: '미션 이름'] + mission_type MissionType [note: '미션의 형태'] + content varchar [note: '미션 내용'] + + Note: '각 주차별 워크북에 있는 미션' +} + +enum WorkbookStatus { + PASS + FAIL + PENDING +} + +enum MissionType { + LINK [note: '링크 형식'] + MEMO [note: '메모 형식'] + PLAIN [note: '완료만 체크하는 형식'] +} + +// 주차별 챌린저 워크북 +Table challenger_workbook { + id bigint [pk, increment] + challenger_id bigint [ref: > challenger.id] + original_workbook_id bigint [ref: > original_workbook.id, note: '원본 워크북'] + status WorkbookStatus + schedule_id bigint [ref: > schedule.id] + is_best bool [note: '베스트 워크북 여부'] +} + +Table challenger_mission { + id bigint [pk, increment] + mission_id bigint [ref: > workbook_mission.id] + challenger_workbook_id bigint [ref: > challenger_workbook.id] + submission varchar [note: '제출물'] +} + +Table study_group { + id bigint [pk, increment] + name varchar [note: '스터디명'] + gisu_id bigint [ref: > gisu.id] + group_leader_id bigint [ref: > challenger.id, note: '스터디장'] + + note: '각 스터디를 나타냄, 학교/지부에는 속하지 않고 기수에만 속함.' +} + +Table study_group_member { + id bigint [pk, increment] + group_id bigint [ref: > study_group.id] + member_id bigint [ref: > challenger.id] + + note: '각 스터디에 속한 인원을 표현' +} + +// ============= 스케쥴 ================== + +// 해당 type에 따라서 출석 여부를 결정할 수 있는 사람의 권한 범위가 딸려옵니다. +enum ScheduleType { + STUDY_SESSION + CENTRAL_EVENT + CHAPTER_EVENT + SCHOOL_EVENT +} + +// 모든 것은 일정으로 관리됨 (스터디, 행사 등) +Table schedule { + id bigint [pk, increment] + name varchar + description varchar + type ScheduleType + author_challenger_id bigint [ref: > challenger.id, note: '일정을 생성한 챌린저 ID'] + + starts_at datetime + ends_at datetime + location_name varchar // 장소는 텍스트 형식으로만 제공 + location point + + late_threshold interval [note: '지각 허용 시간', default: '00:10:00'] + absent_threshold interval [note: '결석 처리 시간', default: '00:30:00'] + attendance_radius int [default: 50, note: '인정 반경 (미터 단위)'] + + + // 정기 스터디도 스케쥴을 생성해야 하네 + // Spring Scheduler를 통해서 주기마다 생성하도록 해야할 것 같은데 +} + +enum AttendanceStatus { + PENDING // 대기중 + PRESENT // 출석 + PRESENT_PENDING + LATE // 지각 + LATE_PENDING // 지각으로 출석 승인 대기 + ABSENT // 결석, 결석은 Scheduler에서 schedule의 absent threshold 경과 발견 시 자동으로 결석처리 + EXCUSED // 인정결석 + EXCUSED_PENDING +} + +// 특정 schedule의 출석은 누가 변경할 수 있는가? 에 대한 명시가 필요해 보입니다. +// => schedule type 별로 조직을 결정. + +// 각 스케쥴에 대한 출석 정보, 곧 개인별 출석부에 해당함. (이번 스케쥴의 출석 여부를 나타냄) +// 각 스케쥴을 생성할 때 미리 대상자를 결정해야 "출석부" 를 생성하는 것 +Table schedule_attendance { + id bigint [pk, increment] + + // 혹시 다른 기수 사람일 수도 있으니까 member로 연결 + member_id bigint [ref: > member.id] + schedule_id bigint [ref: > schedule.id] + location point + status AttendanceStatus + // 승인은 무조건 운영진(=챌린저)가 해야함 + confirmed_challenger_id bigint [ref: > challenger.id] + reason varchar [note: '출석 인정 사유 발생 시 입력'] +} + + +// ========================================== +// ================ 세니 작업본 ================ +// ========================================== + +// 1. 커뮤니티 부분은 challenger_id가 아니라 member_id로 매핑해야 할 것 같은데 어떻게 생각하시나요? + +enum CommunityPostCategory { + LIGHTNING [note: '번개'] + HOBBY [note: '취미'] + QUESTION [note: '질문'] + INFO [note: '정보'] + SUGGESTION [note: '건의'] + GENERAL [note: '자유 (general로 이름 변경)'] +} + +enum CommunityPostRegion { + SEOUL [note: '서울'] + GYEONGGI_INCHEON [note: '경기/인천'] + DAEJEON_CHUNGCHEONG [note: '대전/충청'] + BUSAN_GYEONGNAM [note: '부산/경남'] +} + +enum CommunityPostStatus { + OPEN [note: '모집 중'] + CLOSED [note: '마감'] +} + +Table community_post { // SuperType + id bigint [pk, increment] + title varchar + content varchar + is_anonymous bool [note: '건의사항의 경우 익명 여부'] + status CommunityPostStatus [default: 'OPEN'] + category CommunityPostCategory [not null] + region CommunityPostRegion [not null] + + // 관계 매핑 + author_member_id bigint [ref: > member.id] +} + +// flash_gather라는 표현을 쓴다고 구글이 그러는데 lightening 쓸까요? ㅋㅋㅋ +// 별개로, 이건 번개모임의 정보를 나타내는 부분이니 해당 번개 자체를 나타내면 좋을 것 같아요. +Table flash_gather { // SubType + id bigint [pk, increment] + meeting_at datetime [note: '번개 날짜/시간'] + location varchar [note: '구체적인 장소'] + max_people int [note: '최대 인원'] + open_chat_link varchar [note: '오픈채팅방 링크'] + + // 관계 매핑 + post_id bigint [not null, unique, ref: - community_post.id] +} + +// community_like 정도로 축약해도 좋을 것 같습니다. +Table community_post_like { + id bigint [pk, increment] + + // 관계 매핑 + member_id bigint [ref: > member.id] + community_post_id bigint [ref: > community_post.id] +} + +Table comment { + id bigint [pk, increment] + content varchar + + // 관계 매핑 + author_challenger_id bigint [ref: > challenger.id] + community_post_id bigint [ref: > community_post.id] + parent_comment_id bigint [null, ref: > comment.id, note: '자기 참조'] +} + +Table community_post_image { + id bigint [pk, increment] + post_id bigint [ref: > community_post.id] + image_id bigint [ref: > files.id] +} + +// ====== 폼 ====== + +Table form { + id bigint [pk, increment] + created_member_id bigint [ref: > member.id] + title varchar + description varchar + is_active bool + + Note: '폼 한 개 (질문을 담고 있는)' +} + +enum QuestionType { + SHORT_TEXT + LONG_TEXT + RADIO + CHECKBOX + DROPDOWN + SCHEDULE + PORTFOLIO +} + +enum FormResponseStatus { + DRAFT + SUBMITTED +} + +enum RecruitmentPhase { + BEFORE_APPLY + APPLY_OPEN + DOC_REVIEWING + DOC_RESULT_PUBLISHED + INTERVIEW_WAITING + FINAL_REVIEWING + FINAL_RESULT_PUBLISHED + CLOSED +} + +enum RecruitmentPartStatus { + OPEN + CLOSED +} + +enum RecruitmentScheduleType { + APPLY_WINDOW + DOC_REVIEW_WINDOW + DOC_RESULT_AT + INTERVIEW_WINDOW + FINAL_REVIEW_WINDOW + FINAL_RESULT_AT + OT_AT + ACTIVITY_WINDOW +} + +enum EvaluationStage { + DOCUMENT + FINAL +} + +enum EvaluationDecision { + PASS + FAIL + HOLD +} + +Table question { + id bigint [pk, increment] + form_id bigint [ref: > form.id] + // text or content? + question_text varchar [note: '질문 본문'] + type QuestionType + is_required bool + order_no int + + Note: '폼에 있는 질문 1개' +} + +Table question_option { + id bigint [pk, increment] + question_id bigint [ref: > question.id] + content varchar + order_no int + + Note: '객관식 유형의 질문들(단일 선택, 복수 선택 무관)의 옵션들' +} + +Table form_response { + id bigint [pk, increment] + form_id bigint [ref: > form.id] + respondent_member_id bigint [ref: > member.id, note: '응답한 사용자 id'] + status FormResponseStatus [not null, default: 'DRAFT'] + submitted_at timestamptz + application_no varchar + submited_ip varchar + + Note: '사용자의 응답지' +} + +Table single_answer { + id bigint [pk, increment] + response_id bigint [ref: > form_response.id] + question_id bigint [ref: > question.id] + + answered_as_type QuestionType [not null, note: '응답 당시 질문 타입'] + + /* [JSONB 구조 및 규칙] + 변경 시 어떻게 할 것인지가 처리 필요 + + 1. 주관식 (SHORT_TEXT, LONG_TEXT) + -> { "text": "답변내용" } + + 2. 객관식 단일 (RADIO, DROPDOWN) + -> { "selectedOptionId": 123 } + + 3. 객관식 다중 (CHECKBOX) + -> { "selectedOptionIds": [123, 124, 125] } + + * 주의: 질문 타입에 맞지 않는 키가 존재하거나 값이 있으면 안 됨. + */ + value jsonb [not null, note: '답변 내용 전체를 JSON으로 저장'] + + Note: '개별 질문에 대한 답변' +} + +Table recruitment { + id bigint [pk, increment] + + school_id bigint [ref: > school.id, note: '모집 주체(학교 단위)'] + gisu_id bigint [ref: > gisu.id] + + title varchar + description varchar + form_id bigint [ref: > form.id, note: '이 모집에서 사용하는 폼'] + + is_active bool [default: true] + phase RecruitmentPhase [default: 'BEFORE_APPLY', note: '대시보드 진행 단계(저장하거나 계산 가능)'] + + Note: '모집 자체. 예: 중앙대 10기 추가모집' +} + +Table recruitment_part { + id bigint [pk, increment] + recruitment_id bigint [ref: > recruitment.id] + + part ChallengerPart + status RecruitmentPartStatus [default: 'OPEN', note: '파트별 모집중/마감(추가모집은 OPEN으로 전환)'] + + Note: '파트별 모집 상태 관리' +} + +Table recruitment_schedule { + id bigint [pk, increment] + recruitment_id bigint [ref: > recruitment.id] + + type RecruitmentScheduleType + starts_at timestamptz + ends_at timestamptz + note varchar [note: '표시용 문구(선택)'] + + Note: '모집 일정(활동 schedule과 분리). 단계별 윈도우/발표일 관리' +} + +Table evaluation { + id bigint [pk, increment] + response_id bigint [ref: > form_response.id] + + stage EvaluationStage + evaluator_member_id bigint [ref: > member.id, note: '평가자(운영진). challenger로 제한하면 challenger_id로 바꿔도 됨'] + + score int + decision EvaluationDecision [default: 'HOLD'] + memo varchar + + indexes { + (response_id, stage) [unique, name: 'uq_evaluation_response_stage'] + } + + Note: '지원서 평가(서류/최종). 단일평가 가정' +} \ No newline at end of file diff --git a/src/main/java/com/umc/product/challenger/application/port/in/command/ManageChallengerUseCase.java b/src/main/java/com/umc/product/challenger/application/port/in/command/ManageChallengerUseCase.java new file mode 100644 index 00000000..ba4dc3b3 --- /dev/null +++ b/src/main/java/com/umc/product/challenger/application/port/in/command/ManageChallengerUseCase.java @@ -0,0 +1,20 @@ +package com.umc.product.challenger.application.port.in.command; + +public interface ManageChallengerUseCase { + /** + * 챌린저 상벌점 부여 + */ + void givePointsToChallenger(); + + // 역할이라고 함은, 운영진 중 일부를 의미 + + /** + * 챌린저에게 역할을 부여 + */ + void assignRoleToChallenger(); + + /** + * 챌린저 역할 회수 + */ + void revokeRoleFromChallenger(); +} diff --git a/src/main/java/com/umc/product/challenger/application/port/in/query/ChallengerPublicInfo.java b/src/main/java/com/umc/product/challenger/application/port/in/query/ChallengerPublicInfo.java new file mode 100644 index 00000000..a54d1017 --- /dev/null +++ b/src/main/java/com/umc/product/challenger/application/port/in/query/ChallengerPublicInfo.java @@ -0,0 +1,24 @@ +package com.umc.product.challenger.application.port.in.query; + +import com.umc.product.challenger.domain.Challenger; +import com.umc.product.challenger.domain.enums.ChallengerPart; +import lombok.Builder; + +@Builder +public record ChallengerPublicInfo( + Long challengerId, + Long memberId, + Long gisuId, + ChallengerPart part +) { + + public static ChallengerPublicInfo from(Challenger challenger) { + return ChallengerPublicInfo.builder() + .challengerId(challenger.getId()) + .memberId(challenger.getMemberId()) + .gisuId(challenger.getGisuId()) + .part(challenger.getPart()) + .build(); + + } +} diff --git a/src/main/java/com/umc/product/challenger/application/port/in/query/ChallengerWorkbookSummary.java b/src/main/java/com/umc/product/challenger/application/port/in/query/ChallengerWorkbookSummary.java new file mode 100644 index 00000000..46559829 --- /dev/null +++ b/src/main/java/com/umc/product/challenger/application/port/in/query/ChallengerWorkbookSummary.java @@ -0,0 +1,43 @@ +package com.umc.product.challenger.application.port.in.query; + + +import com.umc.product.challenger.domain.Challenger; +import com.umc.product.challenger.domain.ChallengerMission; +import com.umc.product.challenger.domain.ChallengerWorkbook; +import com.umc.product.curriculum.domain.OriginalWorkbook; +import com.umc.product.curriculum.domain.enums.WorkbookStatus; + +public record ChallengerWorkbookSummary( + Long challengerWorkbookId, + Long challengerId, + String challengerName, + String schoolName, + String part, + String workbookTitle, + String submission, + WorkbookStatus status, + Boolean isBest +) { + public static ChallengerWorkbookSummary from( + ChallengerWorkbook workbook, + ChallengerMission mission, + Challenger challenger, +// TODO: 나중에 추가 +// Member member, +// School school, + OriginalWorkbook originalWorkbook + ) { + return new ChallengerWorkbookSummary( + workbook.getId(), + challenger.getId(), + null, // TODO: member.getName() + null, // TODO: school.getName() + challenger.getPart().name(), + originalWorkbook.getTitle(), + mission != null ? mission.getSubmission() : null, + workbook.getStatus(), + workbook.getIsBest() + ); + } + +} diff --git a/src/main/java/com/umc/product/challenger/application/port/in/query/GetChallengerUseCase.java b/src/main/java/com/umc/product/challenger/application/port/in/query/GetChallengerUseCase.java new file mode 100644 index 00000000..24fbbf28 --- /dev/null +++ b/src/main/java/com/umc/product/challenger/application/port/in/query/GetChallengerUseCase.java @@ -0,0 +1,12 @@ +package com.umc.product.challenger.application.port.in.query; + +public interface GetChallengerUseCase { + // TODO: 챌린저에 대해서 public/private 정보 구분 필요 시 method 추가해서 진행하여야 함 + + /** + * 챌린저 ID로 공개 가능한 정보 조회 + */ + ChallengerPublicInfo getChallengerPublicInfo(Long challengerId); + + +} diff --git a/src/main/java/com/umc/product/challenger/application/port/in/query/GetChallengerWorkbooksUseCase.java b/src/main/java/com/umc/product/challenger/application/port/in/query/GetChallengerWorkbooksUseCase.java new file mode 100644 index 00000000..2e376f01 --- /dev/null +++ b/src/main/java/com/umc/product/challenger/application/port/in/query/GetChallengerWorkbooksUseCase.java @@ -0,0 +1,16 @@ +package com.umc.product.challenger.application.port.in.query; + + +import java.util.List; + +public interface GetChallengerWorkbooksUseCase { + + /** + * (운영진 기능) 주차, 스터디그룹별 제출된 ChallengerWorkbook 리스트 조회 + * + * @param gisuId 기수 ID + * @param weekNo 주차 (order_no) + * @param studyGroupId 스터디그룹 ID (null이면 전체 그룹) + */ + List getByWeekAndStudyGroup(Long gisuId, Integer weekNo, Long studyGroupId); +} diff --git a/src/main/java/com/umc/product/challenger/application/port/out/LoadChallengerMissionPort.java b/src/main/java/com/umc/product/challenger/application/port/out/LoadChallengerMissionPort.java new file mode 100644 index 00000000..d7e9bb86 --- /dev/null +++ b/src/main/java/com/umc/product/challenger/application/port/out/LoadChallengerMissionPort.java @@ -0,0 +1,12 @@ +package com.umc.product.challenger.application.port.out; + +import com.umc.product.challenger.domain.ChallengerMission; +import java.util.List; +import java.util.Optional; + +public interface LoadChallengerMissionPort { + + Optional findById(Long id); + + List findByChallengerWorkbookId(Long challengerWorkbookId); +} diff --git a/src/main/java/com/umc/product/challenger/application/port/out/LoadChallengerPort.java b/src/main/java/com/umc/product/challenger/application/port/out/LoadChallengerPort.java new file mode 100644 index 00000000..06ff6b57 --- /dev/null +++ b/src/main/java/com/umc/product/challenger/application/port/out/LoadChallengerPort.java @@ -0,0 +1,12 @@ +package com.umc.product.challenger.application.port.out; + +import com.umc.product.challenger.domain.Challenger; +import java.util.List; +import java.util.Optional; + +public interface LoadChallengerPort { + + Optional findById(Long id); + + List findByGisuId(Long gisuId); +} diff --git a/src/main/java/com/umc/product/challenger/application/port/out/LoadChallengerWorkbookPort.java b/src/main/java/com/umc/product/challenger/application/port/out/LoadChallengerWorkbookPort.java new file mode 100644 index 00000000..9b52fa2d --- /dev/null +++ b/src/main/java/com/umc/product/challenger/application/port/out/LoadChallengerWorkbookPort.java @@ -0,0 +1,20 @@ +package com.umc.product.challenger.application.port.out; + +import com.umc.product.challenger.domain.ChallengerWorkbook; +import java.util.List; +import java.util.Optional; + +public interface LoadChallengerWorkbookPort { + + Optional findById(Long id); + + Optional findByChallengerIdAndOriginalWorkbookId(Long challengerId, Long originalWorkbookId); + + List findByChallengerId(Long challengerId); + + List findByChallengerIdAndCurriculumId(Long challengerId, Long curriculumId); + + List findByOriginalWorkbookId(Long originalWorkbookId); + + List findByOriginalWorkbookIdAndStudyGroupId(Long originalWorkbookId, Long studyGroupId); +} diff --git a/src/main/java/com/umc/product/challenger/application/port/out/SaveChallengerMissionPort.java b/src/main/java/com/umc/product/challenger/application/port/out/SaveChallengerMissionPort.java new file mode 100644 index 00000000..57052a0e --- /dev/null +++ b/src/main/java/com/umc/product/challenger/application/port/out/SaveChallengerMissionPort.java @@ -0,0 +1,11 @@ +package com.umc.product.challenger.application.port.out; + + +import com.umc.product.challenger.domain.ChallengerMission; + +public interface SaveChallengerMissionPort { + + ChallengerMission save(ChallengerMission challengerMission); + + void delete(ChallengerMission challengerMission); +} diff --git a/src/main/java/com/umc/product/challenger/application/port/out/SaveChallengerWorkbookPort.java b/src/main/java/com/umc/product/challenger/application/port/out/SaveChallengerWorkbookPort.java new file mode 100644 index 00000000..44224e6e --- /dev/null +++ b/src/main/java/com/umc/product/challenger/application/port/out/SaveChallengerWorkbookPort.java @@ -0,0 +1,7 @@ +package com.umc.product.challenger.application.port.out; + +import com.umc.product.challenger.domain.ChallengerWorkbook; + +public interface SaveChallengerWorkbookPort { + ChallengerWorkbook save(ChallengerWorkbook challengerWorkbook); +} diff --git a/src/main/java/com/umc/product/challenger/domain/Challenger.java b/src/main/java/com/umc/product/challenger/domain/Challenger.java new file mode 100644 index 00000000..1dea1a0e --- /dev/null +++ b/src/main/java/com/umc/product/challenger/domain/Challenger.java @@ -0,0 +1,68 @@ +package com.umc.product.challenger.domain; + +import com.umc.product.challenger.domain.enums.ChallengerPart; +import com.umc.product.challenger.domain.enums.ChallengerStatus; +import com.umc.product.challenger.domain.exception.ChallengerDomainException; +import com.umc.product.challenger.domain.exception.ChallengerErrorCode; +import com.umc.product.common.BaseEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.ArrayList; +import java.util.List; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "challenger") +public class Challenger extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, name = "member_id") + private Long memberId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, name = "part") + private ChallengerPart part; + + @Column(nullable = false, name = "gisu_id") + private Long gisuId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, name = "status") + private ChallengerStatus status; + + @OneToMany( + mappedBy = "challenger", + fetch = FetchType.LAZY, + cascade = CascadeType.ALL, + orphanRemoval = true + ) + private List challengerPoints = new ArrayList<>(); + + public Challenger(Long memberId, ChallengerPart part, Long gisuId) { + this.memberId = memberId; + this.part = part; + this.gisuId = gisuId; + this.status = ChallengerStatus.ACTIVE; + } + + public void validateChallengerStatus() { + if (this.status != ChallengerStatus.ACTIVE) { + throw new ChallengerDomainException(ChallengerErrorCode.CHALLENGER_NOT_ACTIVE); + } + } +} diff --git a/src/main/java/com/umc/product/challenger/domain/ChallengerMission.java b/src/main/java/com/umc/product/challenger/domain/ChallengerMission.java new file mode 100644 index 00000000..f53dec99 --- /dev/null +++ b/src/main/java/com/umc/product/challenger/domain/ChallengerMission.java @@ -0,0 +1,48 @@ +package com.umc.product.challenger.domain; + +import com.umc.product.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "challenger_mission") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ChallengerMission extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long workbookMissionId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "challenger_workbook_id", nullable = false) + private ChallengerWorkbook challengerWorkbook; + + @Column(columnDefinition = "TEXT") + private String submission; + + @Builder + private ChallengerMission(Long workbookMissionId, ChallengerWorkbook challengerWorkbook, String submission) { + this.workbookMissionId = workbookMissionId; + this.challengerWorkbook = challengerWorkbook; + this.submission = submission; + } + + public void updateSubmission(String submission) { + this.submission = submission; + } +} diff --git a/src/main/java/com/umc/product/challenger/domain/ChallengerPoint.java b/src/main/java/com/umc/product/challenger/domain/ChallengerPoint.java new file mode 100644 index 00000000..bae046b9 --- /dev/null +++ b/src/main/java/com/umc/product/challenger/domain/ChallengerPoint.java @@ -0,0 +1,43 @@ +package com.umc.product.challenger.domain; + +import com.umc.product.challenger.domain.enums.PointType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 챌린저의 상벌점 점수 입니다. + */ +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "challenger_point") +public class ChallengerPoint { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne() + @JoinColumn(name = "challenger_id", nullable = false) + private Challenger challenger; + + @Column(nullable = false, name = "point") + @Enumerated(EnumType.STRING) + private PointType type; + + @Column(length = 200) + private String description; + + @Column(name = "related_workbook_id") + private Long relatedWorkbookId; +} diff --git a/src/main/java/com/umc/product/challenger/domain/ChallengerRole.java b/src/main/java/com/umc/product/challenger/domain/ChallengerRole.java new file mode 100644 index 00000000..ec2fa12e --- /dev/null +++ b/src/main/java/com/umc/product/challenger/domain/ChallengerRole.java @@ -0,0 +1,54 @@ +package com.umc.product.challenger.domain; + +import com.umc.product.challenger.domain.enums.ChallengerPart; +import com.umc.product.challenger.domain.enums.OrganizationType; +import com.umc.product.challenger.domain.enums.RoleType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "challenger_role") +public class ChallengerRole { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "challenger_id", nullable = false) + private Challenger challenger; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, name = "role_type") + private RoleType roleType; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, name = "organization_type") + private OrganizationType organizationType; + + @Column(nullable = false, name = "organization_id") + private Long organizationId; + + // 파트장인 경우 어떤 파트의 파트장인지 + // 본인이 활동 중인 파트와 다른 파트의 파트장인 경우가 있어서 명시하도록 함 + @Enumerated(EnumType.STRING) + @Column(name = "leading_part") + private ChallengerPart leadingPart; + + @Column(nullable = false, name = "gisu_id") + private Long gisuId; + + +} diff --git a/src/main/java/com/umc/product/challenger/domain/ChallengerWorkbook.java b/src/main/java/com/umc/product/challenger/domain/ChallengerWorkbook.java new file mode 100644 index 00000000..e2aadfaa --- /dev/null +++ b/src/main/java/com/umc/product/challenger/domain/ChallengerWorkbook.java @@ -0,0 +1,93 @@ +package com.umc.product.challenger.domain; + +import com.umc.product.challenger.exception.ChallengerErrorCode; +import com.umc.product.common.BaseEntity; +import com.umc.product.curriculum.domain.enums.WorkbookStatus; +import com.umc.product.global.exception.BusinessException; +import com.umc.product.global.exception.constant.Domain; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.ArrayList; +import java.util.List; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "challenger_workbook") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ChallengerWorkbook extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long challengerId; + + @Column(nullable = false) + private Long originalWorkbookId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private WorkbookStatus status; + + @Column(nullable = false) + private Long scheduleId; + + @Column(nullable = false) + private Boolean isBest; + + @OneToMany(mappedBy = "challengerWorkbook", cascade = CascadeType.ALL, orphanRemoval = true) + private List missions = new ArrayList<>(); + + @Builder + private ChallengerWorkbook(Long challengerId, Long originalWorkbookId, Long scheduleId) { + this.challengerId = challengerId; + this.originalWorkbookId = originalWorkbookId; + this.scheduleId = scheduleId; + this.status = WorkbookStatus.PENDING; + this.isBest = false; + } + + public void markAsPass() { + validatePendingStatus(); + this.status = WorkbookStatus.PASS; + } + + public void markAsFail() { + validatePendingStatus(); + this.status = WorkbookStatus.FAIL; + } + + public void selectAsBest() { + validatePassStatus(); + this.isBest = true; + } + + public void unselectAsBest() { + this.isBest = false; + } + + private void validatePassStatus() { + if (this.status != WorkbookStatus.PASS) { + throw new BusinessException(Domain.CHALLENGER, ChallengerErrorCode.INVALID_WORKBOOK_STATUS); + } + } + + private void validatePendingStatus() { + if (this.status != WorkbookStatus.PENDING) { + throw new BusinessException(Domain.CHALLENGER, ChallengerErrorCode.INVALID_WORKBOOK_STATUS); + } + } +} diff --git a/src/main/java/com/umc/product/challenger/domain/enums/ChallengerPart.java b/src/main/java/com/umc/product/challenger/domain/enums/ChallengerPart.java new file mode 100644 index 00000000..c7710644 --- /dev/null +++ b/src/main/java/com/umc/product/challenger/domain/enums/ChallengerPart.java @@ -0,0 +1,11 @@ +package com.umc.product.challenger.domain.enums; + +public enum ChallengerPart { + PLAN, + DESIGN, + WEB, + IOS, + ANDROID, + SPRINGBOOT, + NODEJS +} diff --git a/src/main/java/com/umc/product/challenger/domain/enums/ChallengerStatus.java b/src/main/java/com/umc/product/challenger/domain/enums/ChallengerStatus.java new file mode 100644 index 00000000..ef630884 --- /dev/null +++ b/src/main/java/com/umc/product/challenger/domain/enums/ChallengerStatus.java @@ -0,0 +1,8 @@ +package com.umc.product.challenger.domain.enums; + +public enum ChallengerStatus { + ACTIVE, // 활성 + GRADUATED, // 수료 + EXPELLED, // 제명 (3OUT 포함) + WITHDRAWN // 자진탈부 +} diff --git a/src/main/java/com/umc/product/challenger/domain/enums/OrganizationType.java b/src/main/java/com/umc/product/challenger/domain/enums/OrganizationType.java new file mode 100644 index 00000000..1cae5083 --- /dev/null +++ b/src/main/java/com/umc/product/challenger/domain/enums/OrganizationType.java @@ -0,0 +1,7 @@ +package com.umc.product.challenger.domain.enums; + +public enum OrganizationType { + CENTRAL, + CHAPTER, + SCHOOL +} diff --git a/src/main/java/com/umc/product/challenger/domain/enums/PointType.java b/src/main/java/com/umc/product/challenger/domain/enums/PointType.java new file mode 100644 index 00000000..cda76b1e --- /dev/null +++ b/src/main/java/com/umc/product/challenger/domain/enums/PointType.java @@ -0,0 +1,7 @@ +package com.umc.product.challenger.domain.enums; + +public enum PointType { + BEST_WORKBOOK, + WARNING, + OUT +} diff --git a/src/main/java/com/umc/product/challenger/domain/enums/RoleType.java b/src/main/java/com/umc/product/challenger/domain/enums/RoleType.java new file mode 100644 index 00000000..5f96672d --- /dev/null +++ b/src/main/java/com/umc/product/challenger/domain/enums/RoleType.java @@ -0,0 +1,23 @@ +package com.umc.product.challenger.domain.enums; + +public enum RoleType { + // 중앙 + CENTRAL_PRESIDENT, // 총괄 + CENTRAL_VICE_PRESIDENT, // 부총괄 + CENTRAL_DIRECTOR, // 국장 + CENTRAL_MANAGER, // 국원 + CENTRAL_PART_LEADER, // 중앙 파트장 + + // 지부 + CHAPTER_LEADER, // 지부장 + CHAPTER_STAFF, // 지부 운영진 + + // 학교 + SCHOOL_PRESIDENT, // 회장 + SCHOOL_VICE_PRESIDENT, // 부회장 + SCHOOL_PART_LEADER, // 파트장 + SCHOOL_STAFF, // 기타 운영진 + + // 일반 + CHALLENGER, // 챌린저 +} diff --git a/src/main/java/com/umc/product/challenger/domain/exception/ChallengerDomainException.java b/src/main/java/com/umc/product/challenger/domain/exception/ChallengerDomainException.java new file mode 100644 index 00000000..c5f1291e --- /dev/null +++ b/src/main/java/com/umc/product/challenger/domain/exception/ChallengerDomainException.java @@ -0,0 +1,12 @@ +package com.umc.product.challenger.domain.exception; + +import com.umc.product.global.exception.BusinessException; +import com.umc.product.global.exception.constant.Domain; + +public class ChallengerDomainException extends BusinessException { + public ChallengerDomainException(ChallengerErrorCode challengerErrorCode) { + super(Domain.CHALLENGER, challengerErrorCode); + } + + +} diff --git a/src/main/java/com/umc/product/challenger/domain/exception/ChallengerErrorCode.java b/src/main/java/com/umc/product/challenger/domain/exception/ChallengerErrorCode.java new file mode 100644 index 00000000..d8c95342 --- /dev/null +++ b/src/main/java/com/umc/product/challenger/domain/exception/ChallengerErrorCode.java @@ -0,0 +1,22 @@ +package com.umc.product.challenger.domain.exception; + +import com.umc.product.global.response.code.BaseCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ChallengerErrorCode implements BaseCode { + + CHALLENGER_NOT_FOUND(HttpStatus.NOT_FOUND, "CHALLENGER-0001", "사용자를 찾을 수 없습니다."), + CHALLENGER_ALREADY_EXISTS(HttpStatus.CONFLICT, "CHALLENGER-0002", "이미 등록된 사용자입니다."), + CHALLENGER_ALREADY_WITHDRAWN(HttpStatus.BAD_REQUEST, "CHALLENGER-0003", "이미 탈퇴한 사용자입니다."), + INVALID_CHALLENGER_STATUS(HttpStatus.BAD_REQUEST, "CHALLENGER-0004", "올바르지 않은 사용자 상태입니다."), + CHALLENGER_NOT_ACTIVE(HttpStatus.BAD_REQUEST, "CHALLENGER-0005", "유효한 챌린저가 아닙니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/com/umc/product/global/config/OpenApiConfig.java b/src/main/java/com/umc/product/global/config/OpenApiConfig.java index 25002f60..f23c4298 100644 --- a/src/main/java/com/umc/product/global/config/OpenApiConfig.java +++ b/src/main/java/com/umc/product/global/config/OpenApiConfig.java @@ -1,6 +1,7 @@ package com.umc.product.global.config; import com.fasterxml.jackson.databind.ObjectMapper; +import com.umc.product.global.constant.SwaggerTag; import io.swagger.v3.core.jackson.ModelResolver; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; @@ -8,53 +9,47 @@ import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.servers.Server; +import io.swagger.v3.oas.models.tags.Tag; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import java.util.List; - @Configuration public class OpenApiConfig { + private final String accessToken = "Access Token"; + private final String refreshToken = "Refresh Token"; @Value("${server.port:8080}") private String serverPort; @Bean - public OpenAPI openAPI() { - String accessToken = "Access Token"; - String refreshToken = "Refresh Token"; + public OpenAPI umcProductApi() { return new OpenAPI() - .info(new Info() - .title("UMC PRODUCT TEAM API") - .version("0.1.0") - .description("UMC Product Team API")) - .servers(List.of( - new Server() - .url("http://localhost:" + serverPort) - .description("Local") - )) - .addSecurityItem(new SecurityRequirement() - .addList(accessToken) - .addList(refreshToken) - ) - .components(new Components() - .addSecuritySchemes(accessToken, - new SecurityScheme() - .type(SecurityScheme.Type.HTTP) - .scheme("bearer") - .bearerFormat("JWT") - .description("발급받은 Access Token을 입력해주세요.") - ) - .addSecuritySchemes(refreshToken, - new SecurityScheme() - .type(SecurityScheme.Type.HTTP) - .scheme("bearer") - .bearerFormat("JWT") - .description("Refresh Token을 입력해주세요.") - ) - ); + .info(apiInfo()) + .servers(servers()) + .tags(tags()) // Enum으로 관리하는 Tags + .components(securityComponents()) + .addSecurityItem(securityRequirement()); + } + + private Info apiInfo() { + return new Info() + .title("UMC PRODUCT TEAM API") + .version("0.1.0") + .description("UMC Product Team API"); + } + + private List servers() { + return List.of( + new Server() + .url("http://localhost:" + serverPort) + .description("Local") + ); } @Bean @@ -62,4 +57,38 @@ public ModelResolver modelResolver(ObjectMapper objectMapper) { // Spring이 관리하는 ObjectMapper를 Swagger에 전달 return new ModelResolver(objectMapper); } + + /** + * Enum으로 관리하는 Tags를 OpenAPI Tag로 변환 + */ + private List tags() { + return Arrays.stream(SwaggerTag.values()) + .sorted(Comparator.comparingInt(SwaggerTag::getOrder)) // order 순으로 정렬 + .map(SwaggerTag::toTag) + .collect(Collectors.toList()); + } + + private Components securityComponents() { + return new Components() + .addSecuritySchemes(accessToken, + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("발급받은 Access Token을 입력해주세요.") + ) + .addSecuritySchemes(refreshToken, + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("Refresh Token을 입력해주세요.") + ); + } + + private SecurityRequirement securityRequirement() { + return new SecurityRequirement() + .addList(accessToken) + .addList(refreshToken); + } } diff --git a/src/main/java/com/umc/product/global/config/P6SpyConfig.java b/src/main/java/com/umc/product/global/config/P6SpyConfig.java index cf1308b1..f93ace85 100644 --- a/src/main/java/com/umc/product/global/config/P6SpyConfig.java +++ b/src/main/java/com/umc/product/global/config/P6SpyConfig.java @@ -3,11 +3,10 @@ import com.p6spy.engine.spy.P6SpyOptions; import com.p6spy.engine.spy.appender.MessageFormattingStrategy; import jakarta.annotation.PostConstruct; +import java.util.Locale; import org.hibernate.engine.jdbc.internal.FormatStyle; import org.springframework.context.annotation.Configuration; -import java.util.Locale; - @Configuration public class P6SpyConfig { diff --git a/src/main/java/com/umc/product/global/config/SecurityConfig.java b/src/main/java/com/umc/product/global/config/SecurityConfig.java index dcd48a9e..c163cf43 100644 --- a/src/main/java/com/umc/product/global/config/SecurityConfig.java +++ b/src/main/java/com/umc/product/global/config/SecurityConfig.java @@ -3,8 +3,11 @@ import com.umc.product.global.security.ApiAccessDeniedHandler; import com.umc.product.global.security.ApiAuthenticationEntryPoint; -import com.umc.product.global.security.CustomAuthorizationManager; import com.umc.product.global.security.JwtAuthenticationFilter; +import com.umc.product.global.security.oauth.OAuth2AuthenticationFailureHandler; +import com.umc.product.global.security.oauth.OAuth2AuthenticationSuccessHandler; +import com.umc.product.member.adapter.in.web.oauth.CustomOAuth2UserService; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -30,19 +33,12 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import java.util.List; - @Configuration @EnableWebSecurity @EnableMethodSecurity // @PreAuthorize, @PostAuthorize 활성화 @RequiredArgsConstructor public class SecurityConfig { - private final JwtAuthenticationFilter jwtAuthenticationFilter; - private final CustomAuthorizationManager customAuthorizationManager; - private final ApiAuthenticationEntryPoint authenticationEntryPoint; - private final ApiAccessDeniedHandler accessDeniedHandler; - private static final String[] SWAGGER_PATHS = { "/swagger-ui/**", "/docs/**", @@ -52,10 +48,17 @@ public class SecurityConfig { "/webjars/**" }; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final ApiAuthenticationEntryPoint authenticationEntryPoint; + private final ApiAccessDeniedHandler accessDeniedHandler; + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2AuthenticationSuccessHandler oAuth2SuccessHandler; + private final OAuth2AuthenticationFailureHandler oAuth2FailureHandler; + /** - * Swagger용 SecurityFilterChain (dev 프로필에서만 활성화) - * - HTTP Basic 인증 적용 - * - 순서가 먼저라서 Swagger 경로는 이 체인이 처리 + * Swagger용 SecurityFilterChain (dev에서만 활성화, local은 따로 제약을 걸지 않음) + *

+ * HTTP Basic 인증 적용 - 순서가 먼저라서 Swagger 경로는 이 체인이 처리 */ @Bean @Order(1) @@ -75,6 +78,13 @@ public SecurityFilterChain swaggerSecurityFilterChain(HttpSecurity http) throws return http.build(); } + /** + * 메인 Security 체인 + * + * @param http + * @return + * @throws Exception + */ @Bean @Order(2) public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -85,12 +95,22 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .httpBasic(AbstractHttpConfigurer::disable) // HTTP Basic 비활성 .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // OAuth2 로그인 설정 + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> + userInfo.userService(customOAuth2UserService)) + .successHandler(oAuth2SuccessHandler) + .failureHandler(oAuth2FailureHandler) + ) .authorizeHttpRequests(auth -> auth // Health Check .requestMatchers("/actuator/**").permitAll() + // OAuth2 + .requestMatchers("/oauth2/authorization/**", "/login/oauth2/code/**").permitAll() // Swagger API .requestMatchers(SWAGGER_PATHS).permitAll() - .anyRequest().access(customAuthorizationManager) // 커스텀 매니저 사용 + // 나머지는 Method Security (@PreAuthorize, @Public)로 제어 + .anyRequest().authenticated() ) // Spring 기본 로그인 필터 동작 전에 JWT 동작 .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) @@ -153,4 +173,4 @@ public CorsConfigurationSource corsConfigurationSource() { source.registerCorsConfiguration("/**", configuration); return source; } -} \ No newline at end of file +} diff --git a/src/main/java/com/umc/product/global/config/WebMvcConfig.java b/src/main/java/com/umc/product/global/config/WebMvcConfig.java index 33f6d092..5b0c8188 100644 --- a/src/main/java/com/umc/product/global/config/WebMvcConfig.java +++ b/src/main/java/com/umc/product/global/config/WebMvcConfig.java @@ -1,21 +1,20 @@ package com.umc.product.global.config; -import com.umc.product.global.security.resolver.CurrentUserArgumentResolver; +import com.umc.product.global.security.resolver.CurrentMemberArgumentResolver; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; -import java.util.List; - @Configuration @RequiredArgsConstructor public class WebMvcConfig implements WebMvcConfigurer { - private final CurrentUserArgumentResolver currentUserArgumentResolver; + private final CurrentMemberArgumentResolver currentMemberArgumentResolver; @Override public void addArgumentResolvers(List resolvers) { - resolvers.add(currentUserArgumentResolver); + resolvers.add(currentMemberArgumentResolver); } -} \ No newline at end of file +} diff --git a/src/main/java/com/umc/product/global/constant/SwaggerTag.java b/src/main/java/com/umc/product/global/constant/SwaggerTag.java new file mode 100644 index 00000000..27d7720e --- /dev/null +++ b/src/main/java/com/umc/product/global/constant/SwaggerTag.java @@ -0,0 +1,68 @@ +package com.umc.product.global.constant; + +import io.swagger.v3.oas.models.tags.Tag; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SwaggerTag { + + TEST("개발자용", "개발자용 Test API", 0), + AUTH("인증/인가", "인증/인가 API", 2), + MEMBER("사용자", "사용자 API", 1), + CHALLENGER("챌린저", "챌린저 API", 2), + ORGANIZATION("조직 (학교/지부/중앙운영사무국)", "조직 관련 API", 2), + CURRICULUM("커리큘럼", "커리큘럼 관련 API", 2), + SCHEDULE("행사 및 스터디 일정", "행사 및 스터디 관련 API", 2), + COMMUNITY("커뮤니티", "커뮤니티 API", 2), + NOTICE("공지사항", "공지사항 API", 2), + + ADMIN("관리자/운영진", "관리자 전용 별도 API", 99), + + // 추가하는 경우, 하단의 Constatns에도 반드시 동일하게 추가할 것 + ; + + private final String name; + private final String description; + private final int order; // Swagger UI에서 표시 순서 + + /** + * 이름으로 찾기 + */ + public static SwaggerTag fromName(String name) { + for (SwaggerTag tag : values()) { + if (tag.name.equals(name)) { + return tag; + } + } + throw new IllegalArgumentException("Unknown tag: " + name); + } + + /** + * OpenAPI Tag 객체로 변환 + */ + public Tag toTag() { + return new Tag() + .name(this.name) + .description(this.description); + } + + /** + * Annotation에서 사용할 수 있도록 상수로 정의 + * + * @Tag(name = SwaggerTag.Constants.USER) + */ + public static class Constants { + public static final String TEST = "개발자용"; + public static final String AUTH = "인증/인가"; + public static final String MEMBER = "사용자"; + public static final String CHALLENGER = "챌린저"; + public static final String ORGANIZATION = "조직 (학교/지부/중앙운영사무국)"; + public static final String CURRICULUM = "커리큘럼"; + public static final String SCHEDULE = "행사 및 스터디 일정"; + public static final String COMMUNITY = "커뮤니티"; + public static final String NOTICE = "공지사항"; + public static final String ADMIN = "관리자/운영진"; + } +} diff --git a/src/main/java/com/umc/product/global/exception/GlobalExceptionHandler.java b/src/main/java/com/umc/product/global/exception/GlobalExceptionHandler.java index a145a54a..fcbd4f15 100644 --- a/src/main/java/com/umc/product/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/umc/product/global/exception/GlobalExceptionHandler.java @@ -7,6 +7,9 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -25,10 +28,6 @@ import org.springframework.web.context.request.WebRequest; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Optional; - @Slf4j @RestControllerAdvice(annotations = {RestController.class}) @RequiredArgsConstructor @@ -38,8 +37,8 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { private String activeProfile; /** - * Spring Security 권한 거부 예외 처리 - AuthorizationDeniedException: @PreAuthorize 등 메서드 레벨 보안 - - * AccessDeniedException: URL 패턴 기반 보안 + * Spring Security 권한 거부 예외 처리 - AuthorizationDeniedException: @PreAuthorize 등 메서드 레벨 보안 - AccessDeniedException: + * URL 패턴 기반 보안 */ @ExceptionHandler({AuthorizationDeniedException.class, AccessDeniedException.class}) public ResponseEntity handleAccessDenied(Exception e, WebRequest request) { @@ -75,7 +74,8 @@ public ResponseEntity handleConstraintViolation(ConstraintViolationExcep */ @Override public ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException e, - HttpHeaders headers, HttpStatusCode status, WebRequest request) { + HttpHeaders headers, HttpStatusCode status, + WebRequest request) { Map errors = new LinkedHashMap<>(); @@ -192,7 +192,8 @@ private ResponseEntity buildErrorResponse( private ResponseEntity handleExceptionInternalFalse(Exception e, CommonErrorCode errorCommonStatus, - HttpHeaders headers, HttpStatus status, WebRequest request, String errorPoint) { + HttpHeaders headers, HttpStatus status, + WebRequest request, String errorPoint) { ApiResponse body = ApiResponse.onFailure(errorCommonStatus.getCode(), errorCommonStatus.getMessage(), errorPoint); return super.handleExceptionInternal( diff --git a/src/main/java/com/umc/product/global/exception/constant/CommonErrorCode.java b/src/main/java/com/umc/product/global/exception/constant/CommonErrorCode.java index 64409085..4c676709 100644 --- a/src/main/java/com/umc/product/global/exception/constant/CommonErrorCode.java +++ b/src/main/java/com/umc/product/global/exception/constant/CommonErrorCode.java @@ -14,6 +14,10 @@ public enum CommonErrorCode implements BaseCode { // Error Code는 DOMAIN-CATEGORY-NUMBER 형식으로 작성할 것. + // e.g. CHALLENGER-COMMON-0001 + // 카테고리는 도메인 내의 세부 카테고리, 작성자에게 권한을 드립니다. + // Number는 0001 부터 4자리로 작성하며, 삭제할 경우 결번 처리하여 중복을 방지해주세요. + // 반드시 Notion 및 Docs와 동기화해주세요. // 에러 코드는 되도록 재사용 금지 INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON-0001", @@ -21,6 +25,8 @@ public enum CommonErrorCode implements BaseCode { BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON-400", "잘못된 요청입니다."), UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON-401", "인증이 필요합니다."), FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON-403", "허용되지 않는 요청입니다."), + SECURITY_NOT_GIVEN(HttpStatus.UNAUTHORIZED, "SECURITY-0001", "인증 정보가 전달되지 않았습니다."), + SECURITY_FORBIDDEN(HttpStatus.FORBIDDEN, "SECURITY-0002", "권한이 부족합니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/umc/product/global/exception/constant/Domain.java b/src/main/java/com/umc/product/global/exception/constant/Domain.java index f16a1d5d..a405dee0 100644 --- a/src/main/java/com/umc/product/global/exception/constant/Domain.java +++ b/src/main/java/com/umc/product/global/exception/constant/Domain.java @@ -8,5 +8,12 @@ public enum Domain { COMMON, AUTH, + MEMBER, CHALLENGER, -} \ No newline at end of file + ORGANIZATION, + CURRICULUM, + SCHEDULE, + COMMUNITY, + NOTICE, + FORM, +} diff --git a/src/main/java/com/umc/product/global/security/ApiAccessDeniedHandler.java b/src/main/java/com/umc/product/global/security/ApiAccessDeniedHandler.java index beab9422..cdd7bf2c 100644 --- a/src/main/java/com/umc/product/global/security/ApiAccessDeniedHandler.java +++ b/src/main/java/com/umc/product/global/security/ApiAccessDeniedHandler.java @@ -5,15 +5,14 @@ import com.umc.product.global.response.ApiResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; -import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import java.io.IOException; -import java.nio.charset.StandardCharsets; - @Component @RequiredArgsConstructor public class ApiAccessDeniedHandler implements AccessDeniedHandler { diff --git a/src/main/java/com/umc/product/global/security/ApiAuthenticationEntryPoint.java b/src/main/java/com/umc/product/global/security/ApiAuthenticationEntryPoint.java index 87004db9..580c28f7 100644 --- a/src/main/java/com/umc/product/global/security/ApiAuthenticationEntryPoint.java +++ b/src/main/java/com/umc/product/global/security/ApiAuthenticationEntryPoint.java @@ -5,15 +5,14 @@ import com.umc.product.global.response.ApiResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; -import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import java.io.IOException; -import java.nio.charset.StandardCharsets; - @Component @RequiredArgsConstructor public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint { @@ -23,7 +22,7 @@ public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest req, HttpServletResponse res, AuthenticationException ex) throws IOException { - CommonErrorCode status = CommonErrorCode.UNAUTHORIZED; + CommonErrorCode status = CommonErrorCode.SECURITY_NOT_GIVEN; ApiResponse body = ApiResponse.onFailure(status.getCode(), status.getMessage(), null); diff --git a/src/main/java/com/umc/product/global/security/CustomAuthorizationManager.java b/src/main/java/com/umc/product/global/security/CustomAuthorizationManager.java deleted file mode 100644 index 9ff88f53..00000000 --- a/src/main/java/com/umc/product/global/security/CustomAuthorizationManager.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.umc.product.global.security; - -import com.umc.product.global.security.annotation.Public; -import jakarta.servlet.http.HttpServletRequest; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.security.authorization.AuthorizationDecision; -import org.springframework.security.authorization.AuthorizationManager; -import org.springframework.security.core.Authentication; -import org.springframework.security.web.access.intercept.RequestAuthorizationContext; -import org.springframework.stereotype.Component; -import org.springframework.web.method.HandlerMethod; -import org.springframework.web.servlet.HandlerExecutionChain; -import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; - -import java.util.function.Supplier; - -/** - * API별 접근 권한을 커스텀하게 설정하기 위한 AuthorizationManager 구현체 - *

- * - @Public 어노테이션이 붙은 API는 인증 없이 접근 허용 - * - 그 외의 API는 인증된 사용자만 접근 허용 - */ -@Component -public class CustomAuthorizationManager implements - AuthorizationManager { - - private final RequestMappingHandlerMapping handlerMapping; - - public CustomAuthorizationManager( - @Qualifier("requestMappingHandlerMapping") RequestMappingHandlerMapping handlerMapping) { - this.handlerMapping = handlerMapping; - } - - @Override - public AuthorizationDecision check(Supplier authentication, - RequestAuthorizationContext context) { - HttpServletRequest request = context.getRequest(); - - try { - HandlerExecutionChain handler = handlerMapping.getHandler(request); - - if (handler != null && handler.getHandler() instanceof HandlerMethod handlerMethod) { - // 메서드에 @Public이 있으면 허용 - if (handlerMethod.hasMethodAnnotation(Public.class)) { - return new AuthorizationDecision(true); - } - - // 클래스에 @Public이 있으면 허용 - if (handlerMethod.getBeanType().isAnnotationPresent(Public.class)) { - return new AuthorizationDecision(true); - } - } - } catch (Exception e) { - // 핸들러를 찾지 못한 경우 - } - - // @Public이 없으면 인증 여부 확인 - Authentication auth = authentication.get(); - boolean isAuthenticated = auth != null && auth.isAuthenticated() - && !"anonymousUser".equals(auth.getPrincipal()); - - return new AuthorizationDecision(isAuthenticated); - } -} \ No newline at end of file diff --git a/src/main/java/com/umc/product/global/security/JwtAuthenticationFilter.java b/src/main/java/com/umc/product/global/security/JwtAuthenticationFilter.java index e8add560..5ba805c1 100644 --- a/src/main/java/com/umc/product/global/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/umc/product/global/security/JwtAuthenticationFilter.java @@ -5,6 +5,8 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -13,9 +15,6 @@ import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; -import java.io.IOException; -import java.util.List; - @Component @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { @@ -32,10 +31,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse Long userId = jwtTokenProvider.getUserId(token); List roles = jwtTokenProvider.getRoles(token); - UserPrincipal userPrincipal = UserPrincipal.builder() - .userId(userId) - .roles(roles) - .build(); + MemberPrincipal memberPrincipal = new MemberPrincipal(userId, ""); // email은 빈 문자열 List authorities = roles.stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) @@ -46,7 +42,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse // credentials: 인증에 사용된 자격 증명 (여기서는 null로 설정) // authorities: 사용자의 권한 정보 (여기서는 null로 설정) UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( - userPrincipal, + memberPrincipal, null, authorities ); diff --git a/src/main/java/com/umc/product/global/security/JwtTokenProvider.java b/src/main/java/com/umc/product/global/security/JwtTokenProvider.java index 45f6c145..5189c5a7 100644 --- a/src/main/java/com/umc/product/global/security/JwtTokenProvider.java +++ b/src/main/java/com/umc/product/global/security/JwtTokenProvider.java @@ -1,16 +1,19 @@ package com.umc.product.global.security; -import io.jsonwebtoken.*; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; import io.jsonwebtoken.security.Keys; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.Date; import java.util.List; +import javax.crypto.SecretKey; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; @Slf4j @Component diff --git a/src/main/java/com/umc/product/global/security/MemberPrincipal.java b/src/main/java/com/umc/product/global/security/MemberPrincipal.java new file mode 100644 index 00000000..8d623e11 --- /dev/null +++ b/src/main/java/com/umc/product/global/security/MemberPrincipal.java @@ -0,0 +1,50 @@ +package com.umc.product.global.security; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +@Getter +public class MemberPrincipal implements OAuth2User { + + private final Long memberId; + private final String email; + private final Map attributes; + private final String nameAttributeKey; + + public MemberPrincipal(Long memberId, String email, + Map attributes, + String nameAttributeKey) { + this.memberId = memberId; + this.email = email; + this.attributes = attributes; + this.nameAttributeKey = nameAttributeKey; + } + + // JWT 인증용 생성자 (OAuth 없이 사용) + public MemberPrincipal(Long memberId, String email) { + this(memberId, email, Collections.emptyMap(), "id"); + } + + // OAuth2User 메서드 구현 + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public Collection getAuthorities() { + return Collections.emptyList(); + } + + @Override + public String getName() { + if (attributes.isEmpty()) { + return String.valueOf(memberId); + } + return String.valueOf(attributes.get(nameAttributeKey)); + } +} diff --git a/src/main/java/com/umc/product/global/security/UserPrincipal.java b/src/main/java/com/umc/product/global/security/UserPrincipal.java deleted file mode 100644 index 986bb644..00000000 --- a/src/main/java/com/umc/product/global/security/UserPrincipal.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.umc.product.global.security; - -import lombok.Builder; - -import java.util.List; - -@Builder -public record UserPrincipal( - Long userId, - List roles -) { - -} \ No newline at end of file diff --git a/src/main/java/com/umc/product/global/security/annotation/CurrentUser.java b/src/main/java/com/umc/product/global/security/annotation/CurrentMember.java similarity index 93% rename from src/main/java/com/umc/product/global/security/annotation/CurrentUser.java rename to src/main/java/com/umc/product/global/security/annotation/CurrentMember.java index 825af837..e0de61e6 100644 --- a/src/main/java/com/umc/product/global/security/annotation/CurrentUser.java +++ b/src/main/java/com/umc/product/global/security/annotation/CurrentMember.java @@ -1,14 +1,13 @@ package com.umc.product.global.security.annotation; -import org.springframework.security.core.annotation.AuthenticationPrincipal; - import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import org.springframework.security.core.annotation.AuthenticationPrincipal; @Target(ElementType.PARAMETER) // 파라미터에만 붙일 수 있음 @Retention(RetentionPolicy.RUNTIME) // 런타임까지 유지 @AuthenticationPrincipal // (선택사항) Swagger 등이 이 어노테이션을 인식하도록 돕는 메타 어노테이션 -public @interface CurrentUser { -} \ No newline at end of file +public @interface CurrentMember { +} diff --git a/src/main/java/com/umc/product/global/security/annotation/Public.java b/src/main/java/com/umc/product/global/security/annotation/Public.java index 9ba11f6f..c6f9d9c2 100644 --- a/src/main/java/com/umc/product/global/security/annotation/Public.java +++ b/src/main/java/com/umc/product/global/security/annotation/Public.java @@ -4,9 +4,31 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import org.springframework.security.access.prepost.PreAuthorize; -@Target({ElementType.METHOD, ElementType.TYPE}) // 메서드, 클래스에 사용 가능 +/** + * 공개 API를 나타내는 어노테이션 + *

+ * Spring Security의 {@link PreAuthorize @PreAuthorize("permitAll()")}와 동일하게 동작합니다. + * 인증 없이 접근 가능한 API에 사용합니다. + *

+ * + *

사용 예시:

+ *
+ * {@code
+ * @GetMapping("/posts")
+ * @Public
+ * public ApiResponse getPublicPosts() {
+ *     // 인증 없이 접근 가능
+ * }
+ * }
+ * 
+ * + * @see PreAuthorize + */ +@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) +@PreAuthorize("permitAll()") // Meta-annotation: @Public = @PreAuthorize("permitAll()") public @interface Public { } \ No newline at end of file diff --git a/src/main/java/com/umc/product/global/security/oauth/OAuth2Attributes.java b/src/main/java/com/umc/product/global/security/oauth/OAuth2Attributes.java new file mode 100644 index 00000000..614da818 --- /dev/null +++ b/src/main/java/com/umc/product/global/security/oauth/OAuth2Attributes.java @@ -0,0 +1,84 @@ +package com.umc.product.global.security.oauth; + +import com.umc.product.member.application.port.in.command.ProcessOAuthLoginCommand; +import com.umc.product.member.domain.OAuthProvider; +import java.util.Map; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class OAuth2Attributes { + private Map attributes; + private String nameAttributeKey; + private String name; + private String email; + private String nickname; + private OAuthProvider provider; + private String providerId; + + public static OAuth2Attributes of(String registrationId, + String userNameAttributeName, + Map attributes) { + return switch (registrationId.toLowerCase()) { + case "google" -> ofGoogle(userNameAttributeName, attributes); + case "kakao" -> ofKakao(userNameAttributeName, attributes); + case "naver" -> ofNaver(userNameAttributeName, attributes); + default -> throw new IllegalArgumentException("Unsupported provider: " + registrationId); + }; + } + + private static OAuth2Attributes ofGoogle(String userNameAttributeName, + Map attributes) { + return OAuth2Attributes.builder() + .provider(OAuthProvider.GOOGLE) + .providerId((String) attributes.get(userNameAttributeName)) + .name((String) attributes.get("name")) + .email((String) attributes.get("email")) + .nickname((String) attributes.get("name")) + .attributes(attributes) + .nameAttributeKey(userNameAttributeName) + .build(); + } + + private static OAuth2Attributes ofKakao(String userNameAttributeName, + Map attributes) { + Map kakaoAccount = (Map) attributes.get("kakao_account"); + Map profile = (Map) kakaoAccount.get("profile"); + + return OAuth2Attributes.builder() + .provider(OAuthProvider.KAKAO) + .providerId(String.valueOf(attributes.get(userNameAttributeName))) + .name((String) profile.get("nickname")) + .email((String) kakaoAccount.get("email")) + .nickname((String) profile.get("nickname")) + .attributes(attributes) + .nameAttributeKey(userNameAttributeName) + .build(); + } + + private static OAuth2Attributes ofNaver(String userNameAttributeName, + Map attributes) { + Map response = (Map) attributes.get(userNameAttributeName); + + return OAuth2Attributes.builder() + .provider(OAuthProvider.NAVER) + .providerId((String) response.get("id")) + .name((String) response.get("name")) + .email((String) response.get("email")) + .nickname((String) response.get("nickname")) + .attributes(attributes) + .nameAttributeKey(userNameAttributeName) + .build(); + } + + public ProcessOAuthLoginCommand toCommand() { + return new ProcessOAuthLoginCommand( + provider, + providerId, + email, + name, + nickname + ); + } +} diff --git a/src/main/java/com/umc/product/global/security/oauth/OAuth2AuthenticationFailureHandler.java b/src/main/java/com/umc/product/global/security/oauth/OAuth2AuthenticationFailureHandler.java new file mode 100644 index 00000000..d9e39955 --- /dev/null +++ b/src/main/java/com/umc/product/global/security/oauth/OAuth2AuthenticationFailureHandler.java @@ -0,0 +1,57 @@ +package com.umc.product.global.security.oauth; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { + + @Value("${oauth2.redirect-url:http://localhost:3000/oauth/callback}") + private String redirectUrl; + + @Override + public void onAuthenticationFailure( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception + ) throws IOException { + log.error("=== OAuth2 Authentication Failed ==="); + log.error("Request URI: {}", request.getRequestURI()); + log.error("Exception Type: {}", exception.getClass().getName()); + log.error("Exception Message: {}", exception.getMessage()); + + if (exception instanceof OAuth2AuthenticationException oauthEx) { + log.error("OAuth2 Error Code: {}", oauthEx.getError().getErrorCode()); + log.error("OAuth2 Error Description: {}", oauthEx.getError().getDescription()); + } + + if (exception.getCause() != null) { + log.error("Cause Type: {}", exception.getCause().getClass().getName()); + log.error("Cause Message: {}", exception.getCause().getMessage()); + } + + // 전체 스택트레이스 출력 + log.error("Full Stack Trace:", exception); + + // 프론트엔드로 에러와 함께 리다이렉트 + String targetUrl = UriComponentsBuilder.fromUriString(redirectUrl) + .queryParam("error", "oauth_failed") + .queryParam("message", exception.getMessage()) + .build() + .toUriString(); + + log.error("Redirecting to: {}", targetUrl); + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } +} diff --git a/src/main/java/com/umc/product/global/security/oauth/OAuth2AuthenticationSuccessHandler.java b/src/main/java/com/umc/product/global/security/oauth/OAuth2AuthenticationSuccessHandler.java new file mode 100644 index 00000000..159d7949 --- /dev/null +++ b/src/main/java/com/umc/product/global/security/oauth/OAuth2AuthenticationSuccessHandler.java @@ -0,0 +1,50 @@ +package com.umc.product.global.security.oauth; + +import com.umc.product.global.security.JwtTokenProvider; +import com.umc.product.global.security.MemberPrincipal; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Collections; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtTokenProvider jwtTokenProvider; + + @Value("${oauth2.redirect-url:http://localhost:3000/oauth/callback}") + private String redirectUrl; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) throws IOException { + + MemberPrincipal memberPrincipal = (MemberPrincipal) authentication.getPrincipal(); + Long memberId = memberPrincipal.getMemberId(); + + log.info("OAuth2 authentication success: memberId={}", memberId); + + // JWT 토큰 생성 + String accessToken = jwtTokenProvider.createAccessToken(memberId, Collections.emptyList()); + String refreshToken = jwtTokenProvider.createRefreshToken(memberId); + + // 프론트엔드로 리다이렉트 (토큰 전달) + String targetUrl = UriComponentsBuilder.fromUriString(redirectUrl) + .queryParam("accessToken", accessToken) + .queryParam("refreshToken", refreshToken) + .build().toUriString(); + + log.info("Redirecting to: {}", targetUrl); + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } +} diff --git a/src/main/java/com/umc/product/global/security/resolver/CurrentUserArgumentResolver.java b/src/main/java/com/umc/product/global/security/resolver/CurrentMemberArgumentResolver.java similarity index 79% rename from src/main/java/com/umc/product/global/security/resolver/CurrentUserArgumentResolver.java rename to src/main/java/com/umc/product/global/security/resolver/CurrentMemberArgumentResolver.java index b0fb58f5..3ec7f0a6 100644 --- a/src/main/java/com/umc/product/global/security/resolver/CurrentUserArgumentResolver.java +++ b/src/main/java/com/umc/product/global/security/resolver/CurrentMemberArgumentResolver.java @@ -1,7 +1,7 @@ package com.umc.product.global.security.resolver; -import com.umc.product.global.security.UserPrincipal; -import com.umc.product.global.security.annotation.CurrentUser; +import com.umc.product.global.security.MemberPrincipal; +import com.umc.product.global.security.annotation.CurrentMember; import org.springframework.core.MethodParameter; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -12,14 +12,14 @@ import org.springframework.web.method.support.ModelAndViewContainer; @Component -public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver { +public class CurrentMemberArgumentResolver implements HandlerMethodArgumentResolver { @Override public boolean supportsParameter(MethodParameter parameter) { // 1. 파라미터에 @CurrentUser 어노테이션이 붙어 있는지 확인 - boolean hasAnnotation = parameter.hasParameterAnnotation(CurrentUser.class); + boolean hasAnnotation = parameter.hasParameterAnnotation(CurrentMember.class); // 2. 파라미터 타입이 UserPrincipal인지 확인 - boolean hasUserType = UserPrincipal.class.isAssignableFrom(parameter.getParameterType()); + boolean hasUserType = MemberPrincipal.class.isAssignableFrom(parameter.getParameterType()); return hasAnnotation && hasUserType; } @@ -36,12 +36,12 @@ public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer m return null; } - // 실제 UserPrincipal 객체 반환 + // 실제 MemberPrincipal 객체 반환 Object principal = authentication.getPrincipal(); - if (principal instanceof UserPrincipal) { + if (principal instanceof MemberPrincipal) { return principal; } return null; } -} \ No newline at end of file +} diff --git a/src/main/java/com/umc/product/member/adapter/in/web/MemberController.java b/src/main/java/com/umc/product/member/adapter/in/web/MemberController.java new file mode 100644 index 00000000..ef19525d --- /dev/null +++ b/src/main/java/com/umc/product/member/adapter/in/web/MemberController.java @@ -0,0 +1,55 @@ +package com.umc.product.member.adapter.in.web; + +import com.umc.product.global.constant.SwaggerTag; +import com.umc.product.global.security.MemberPrincipal; +import com.umc.product.global.security.annotation.CurrentMember; +import com.umc.product.member.adapter.in.web.dto.request.CompleteRegisterMemberRequest; +import com.umc.product.member.adapter.in.web.dto.response.MemberResponse; +import com.umc.product.member.application.port.in.command.ManageMemberUseCase; +import com.umc.product.member.application.port.in.query.GetMemberUseCase; +import com.umc.product.member.application.port.in.query.MemberInfo; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/member") +@RequiredArgsConstructor +@Tag(name = SwaggerTag.Constants.MEMBER) +public class MemberController { + + private final ManageMemberUseCase manageMemberUseCase; + private final GetMemberUseCase getMemberUseCase; + + // 로그인은 OAuth를 통해서만 진행됨!! + + @PostMapping("/register/complete") + @Operation(summary = "회원가입 완료", + description = """ + ### OAuth 로그인 후 회원가입 완료 처리 + - OAuth로 로그인한 사용자가 아직 회원가입이 완료되지 않은 상태(이름, 닉네임 등 사용자 정보를 설정하지 않은 상태)인 경우에 사용됩니다. + - 이름, 닉네임, 이메일, 학교명을 선택합니다. + - 작성 기준: 2026/01/06 하늘/박경운 + """) + public Long completeRegister(@Valid @RequestBody CompleteRegisterMemberRequest request, + @CurrentMember MemberPrincipal currentMember) { + + Long userId = manageMemberUseCase.completeRegister(request.toCommand(currentMember.getMemberId())); + return userId; + } + + + @GetMapping("/{memberId}") + @Operation(summary = "사용자 상세 조회") + public MemberResponse getUser(@PathVariable Long memberId) { + MemberInfo memberInfo = getMemberUseCase.getById(memberId); + return MemberResponse.from(memberInfo); + } +} diff --git a/src/main/java/com/umc/product/member/adapter/in/web/dto/request/CompleteRegisterMemberRequest.java b/src/main/java/com/umc/product/member/adapter/in/web/dto/request/CompleteRegisterMemberRequest.java new file mode 100644 index 00000000..dea5d6f5 --- /dev/null +++ b/src/main/java/com/umc/product/member/adapter/in/web/dto/request/CompleteRegisterMemberRequest.java @@ -0,0 +1,41 @@ +package com.umc.product.member.adapter.in.web.dto.request; + +import com.umc.product.member.application.port.in.command.CompleteRegisterMemberCommand; +import com.umc.product.member.domain.enums.TermType; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +/** + * 회원가입 완료 요청 DTO + */ +public record CompleteRegisterMemberRequest( + @NotBlank(message = "이름은 필수입니다") + String name, + + @NotBlank(message = "닉네임은 필수입니다") + String nickname, + + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "유효한 이메일 형식이어야 합니다") + String email, + + Long schoolId, + Long profileImageId, + + @NotNull(message = "약관 동의는 필수입니다") + List agreedTerms +) { + public CompleteRegisterMemberCommand toCommand(Long memberId) { + return CompleteRegisterMemberCommand.builder() + .memberId(memberId) + .name(name) + .nickname(nickname) + .email(email) + .schoolId(schoolId) + .profileImageId(profileImageId) + .agreedTerms(agreedTerms) + .build(); + } +} diff --git a/src/main/java/com/umc/product/member/adapter/in/web/dto/response/MemberResponse.java b/src/main/java/com/umc/product/member/adapter/in/web/dto/response/MemberResponse.java new file mode 100644 index 00000000..599ce62f --- /dev/null +++ b/src/main/java/com/umc/product/member/adapter/in/web/dto/response/MemberResponse.java @@ -0,0 +1,26 @@ +package com.umc.product.member.adapter.in.web.dto.response; + +import com.umc.product.member.application.port.in.query.MemberInfo; +import com.umc.product.member.domain.enums.MemberStatus; + +public record MemberResponse( + Long id, + String name, + String nickname, + String email, + Long schoolId, + Long profileImageId, + MemberStatus status +) { + public static MemberResponse from(MemberInfo memberInfo) { + return new MemberResponse( + memberInfo.id(), + memberInfo.name(), + memberInfo.nickname(), + memberInfo.email(), + memberInfo.schoolId(), + memberInfo.profileImageId(), + memberInfo.status() + ); + } +} diff --git a/src/main/java/com/umc/product/member/adapter/in/web/oauth/CustomOAuth2UserService.java b/src/main/java/com/umc/product/member/adapter/in/web/oauth/CustomOAuth2UserService.java new file mode 100644 index 00000000..b2007385 --- /dev/null +++ b/src/main/java/com/umc/product/member/adapter/in/web/oauth/CustomOAuth2UserService.java @@ -0,0 +1,72 @@ +package com.umc.product.member.adapter.in.web.oauth; + +import com.umc.product.global.security.MemberPrincipal; +import com.umc.product.global.security.oauth.OAuth2Attributes; +import com.umc.product.member.application.port.in.command.ProcessOAuthLoginUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService implements OAuth2UserService { + + private final ProcessOAuthLoginUseCase processOAuthLoginUseCase; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + log.info("=== OAuth2 login started: provider={} ===", registrationId); + + try { + // 1. OAuth Provider에서 사용자 정보 가져오기 + OAuth2UserService delegate = new DefaultOAuth2UserService(); + OAuth2User oAuth2User = delegate.loadUser(userRequest); + log.debug("OAuth2User loaded successfully from provider: {}", registrationId); + log.debug("OAuth2User attributes: {}", oAuth2User.getAttributes()); + + // 2. Provider 정보 추출 + String userNameAttributeName = userRequest.getClientRegistration() + .getProviderDetails() + .getUserInfoEndpoint() + .getUserNameAttributeName(); + log.debug("UserNameAttributeName: {}", userNameAttributeName); + + // 3. OAuth 응답을 Command로 변환 + OAuth2Attributes attributes = OAuth2Attributes.of( + registrationId, + userNameAttributeName, + oAuth2User.getAttributes() + ); + log.debug("Parsed OAuth2Attributes: provider={}, providerId={}, email={}, name={}", + attributes.getProvider(), attributes.getProviderId(), + attributes.getEmail(), attributes.getName()); + + // 4. UseCase 호출 (회원 조회 또는 가입) + Long memberId = processOAuthLoginUseCase.processOAuthLogin(attributes.toCommand()); + log.info("OAuth2 login completed: provider={}, memberId={}", registrationId, memberId); + + // 5. MemberPrincipal 반환 (Spring Security가 사용) + return new MemberPrincipal( + memberId, + attributes.getEmail(), + attributes.getAttributes(), + attributes.getNameAttributeKey() + ); + } catch (Exception ex) { + log.error("=== OAuth2 login failed: provider={} ===", registrationId, ex); + log.error("Error type: {}", ex.getClass().getName()); + log.error("Error message: {}", ex.getMessage()); + if (ex.getCause() != null) { + log.error("Cause: {}", ex.getCause().getMessage()); + } + throw ex; + } + } +} diff --git a/src/main/java/com/umc/product/member/adapter/out/persistence/MemberJpaRepository.java b/src/main/java/com/umc/product/member/adapter/out/persistence/MemberJpaRepository.java new file mode 100644 index 00000000..fc8d8a34 --- /dev/null +++ b/src/main/java/com/umc/product/member/adapter/out/persistence/MemberJpaRepository.java @@ -0,0 +1,15 @@ +package com.umc.product.member.adapter.out.persistence; + +import com.umc.product.member.domain.Member; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberJpaRepository extends JpaRepository { + Optional findByEmail(String email); + + Optional findByNickname(String nickname); + + boolean existsByEmail(String email); + + boolean existsByNickname(String nickname); +} diff --git a/src/main/java/com/umc/product/member/adapter/out/persistence/MemberOAuthRepository.java b/src/main/java/com/umc/product/member/adapter/out/persistence/MemberOAuthRepository.java new file mode 100644 index 00000000..469c1a58 --- /dev/null +++ b/src/main/java/com/umc/product/member/adapter/out/persistence/MemberOAuthRepository.java @@ -0,0 +1,12 @@ +package com.umc.product.member.adapter.out.persistence; + +import com.umc.product.member.domain.MemberOAuth; +import com.umc.product.member.domain.OAuthProvider; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberOAuthRepository extends JpaRepository { + Optional findByProviderAndProviderId(OAuthProvider provider, String providerId); + + Optional findByMemberId(Long memberId); +} diff --git a/src/main/java/com/umc/product/member/adapter/out/persistence/MemberPersistenceAdapter.java b/src/main/java/com/umc/product/member/adapter/out/persistence/MemberPersistenceAdapter.java new file mode 100644 index 00000000..d712204c --- /dev/null +++ b/src/main/java/com/umc/product/member/adapter/out/persistence/MemberPersistenceAdapter.java @@ -0,0 +1,84 @@ +package com.umc.product.member.adapter.out.persistence; + +import com.umc.product.member.application.port.out.LoadMemberOAuthPort; +import com.umc.product.member.application.port.out.LoadMemberPort; +import com.umc.product.member.application.port.out.SaveMemberPort; +import com.umc.product.member.domain.Member; +import com.umc.product.member.domain.MemberOAuth; +import com.umc.product.member.domain.MemberTermAgreement; +import com.umc.product.member.domain.OAuthProvider; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MemberPersistenceAdapter implements LoadMemberPort, SaveMemberPort, LoadMemberOAuthPort { + + private final MemberJpaRepository memberJpaRepository; + private final MemberOAuthRepository memberOAuthRepository; + private final MemberTermAgreementRepository memberTermAgreementRepository; + private final MemberQueryRepository memberQueryRepository; + + @Override + public Optional findById(Long id) { + return memberJpaRepository.findById(id); + } + + @Override + public Optional findByEmail(String email) { + return memberJpaRepository.findByEmail(email); + } + + // QueryDSL 사용 테스트 하고자 넣은 method + @Override + public Optional findByNickname(String nickname) { + return memberQueryRepository.findByNickname(nickname); + } + + @Override + public boolean existsById(Long id) { + return memberJpaRepository.existsById(id); + } + + @Override + public boolean existsByEmail(String email) { + return memberJpaRepository.existsByEmail(email); + } + + @Override + public boolean existsByNickname(String nickname) { + return memberJpaRepository.existsByNickname(nickname); + } + + @Override + public Member save(Member member) { + return memberJpaRepository.save(member); + } + + @Override + public MemberOAuth saveOAuth(MemberOAuth memberOAuth) { + return memberOAuthRepository.save(memberOAuth); + } + + @Override + public List saveTermAgreements(List agreements) { + return memberTermAgreementRepository.saveAll(agreements); + } + + @Override + public void delete(Member member) { + memberJpaRepository.delete(member); + } + + @Override + public Optional findByProviderAndProviderUserId(OAuthProvider provider, String providerUserId) { + return memberOAuthRepository.findByProviderAndProviderId(provider, providerUserId); + } + + @Override + public Optional findByUserId(Long userId) { + return memberOAuthRepository.findByMemberId(userId); + } +} diff --git a/src/main/java/com/umc/product/member/adapter/out/persistence/MemberQueryRepository.java b/src/main/java/com/umc/product/member/adapter/out/persistence/MemberQueryRepository.java new file mode 100644 index 00000000..98774115 --- /dev/null +++ b/src/main/java/com/umc/product/member/adapter/out/persistence/MemberQueryRepository.java @@ -0,0 +1,28 @@ +package com.umc.product.member.adapter.out.persistence; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.umc.product.member.domain.Member; +import com.umc.product.member.domain.QMember; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class MemberQueryRepository { + private final JPAQueryFactory queryFactory; + + public Long countAllMembers() { + return queryFactory + .selectFrom(QMember.member) + .stream().count(); + } + + public Optional findByNickname(String nickname) { + return Optional.ofNullable(queryFactory + .selectFrom(QMember.member) + .where(QMember.member.nickname.eq(nickname)) + .fetchFirst() + ); + } +} diff --git a/src/main/java/com/umc/product/member/adapter/out/persistence/MemberTermAgreementRepository.java b/src/main/java/com/umc/product/member/adapter/out/persistence/MemberTermAgreementRepository.java new file mode 100644 index 00000000..81ff65b8 --- /dev/null +++ b/src/main/java/com/umc/product/member/adapter/out/persistence/MemberTermAgreementRepository.java @@ -0,0 +1,7 @@ +package com.umc.product.member.adapter.out.persistence; + +import com.umc.product.member.domain.MemberTermAgreement; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberTermAgreementRepository extends JpaRepository { +} diff --git a/src/main/java/com/umc/product/member/application/port/in/command/CompleteRegisterMemberCommand.java b/src/main/java/com/umc/product/member/application/port/in/command/CompleteRegisterMemberCommand.java new file mode 100644 index 00000000..8baa87d8 --- /dev/null +++ b/src/main/java/com/umc/product/member/application/port/in/command/CompleteRegisterMemberCommand.java @@ -0,0 +1,40 @@ +package com.umc.product.member.application.port.in.command; + +import static java.util.Objects.requireNonNull; + +import com.umc.product.member.domain.MemberTermAgreement; +import com.umc.product.member.domain.enums.TermType; +import java.util.List; +import lombok.Builder; + +/** + * 회원가입 완료를 위한 Command + */ +@Builder +public record CompleteRegisterMemberCommand( + Long memberId, // 가입 완료할 member의 ID + String name, + String nickname, + String email, + Long schoolId, + Long profileImageId, + List agreedTerms +) { + public CompleteRegisterMemberCommand { + requireNonNull(memberId, "회원 ID는 null일 수 없습니다."); + requireNonNull(name, "이름은 null일 수 없습니다."); + requireNonNull(nickname, "닉네임은 null일 수 없습니다."); + requireNonNull(email, "이메일은 null일 수 없습니다."); + requireNonNull(schoolId, "학교 ID는 null일 수 없습니다."); + requireNonNull(agreedTerms, "약관 동의 여부는 null일 수 없습니다."); + } + + public List toTermAgreementEntities() { + return agreedTerms.stream() + .map(termType -> MemberTermAgreement.builder() + .memberId(memberId) + .termType(termType) + .build()) + .toList(); + } +} diff --git a/src/main/java/com/umc/product/member/application/port/in/command/ManageMemberUseCase.java b/src/main/java/com/umc/product/member/application/port/in/command/ManageMemberUseCase.java new file mode 100644 index 00000000..904332fd --- /dev/null +++ b/src/main/java/com/umc/product/member/application/port/in/command/ManageMemberUseCase.java @@ -0,0 +1,10 @@ +package com.umc.product.member.application.port.in.command; + +public interface ManageMemberUseCase { + Long register(RegisterMemberCommand command); + + /** + * OAuth로 회원 정보를 작성한 사용자가 회원가입을 완료할 수 있도록 합니다. + */ + Long completeRegister(CompleteRegisterMemberCommand command); +} diff --git a/src/main/java/com/umc/product/member/application/port/in/command/ProcessOAuthLoginCommand.java b/src/main/java/com/umc/product/member/application/port/in/command/ProcessOAuthLoginCommand.java new file mode 100644 index 00000000..c4130712 --- /dev/null +++ b/src/main/java/com/umc/product/member/application/port/in/command/ProcessOAuthLoginCommand.java @@ -0,0 +1,18 @@ +package com.umc.product.member.application.port.in.command; + +import com.umc.product.member.domain.OAuthProvider; +import java.util.Objects; + +public record ProcessOAuthLoginCommand( + OAuthProvider provider, + String providerId, // OAuth Provider의 사용자 ID + String email, + String name, + String nickname +) { + public ProcessOAuthLoginCommand { + Objects.requireNonNull(provider, "provider must not be null"); + Objects.requireNonNull(providerId, "providerId must not be null"); + Objects.requireNonNull(email, "email must not be null"); + } +} diff --git a/src/main/java/com/umc/product/member/application/port/in/command/ProcessOAuthLoginUseCase.java b/src/main/java/com/umc/product/member/application/port/in/command/ProcessOAuthLoginUseCase.java new file mode 100644 index 00000000..8602e817 --- /dev/null +++ b/src/main/java/com/umc/product/member/application/port/in/command/ProcessOAuthLoginUseCase.java @@ -0,0 +1,11 @@ +package com.umc.product.member.application.port.in.command; + +public interface ProcessOAuthLoginUseCase { + /** + * OAuth 로그인 처리 (회원 조회 또는 신규 가입) + * + * @param command OAuth 로그인 정보 + * @return 회원 ID + */ + Long processOAuthLogin(ProcessOAuthLoginCommand command); +} diff --git a/src/main/java/com/umc/product/member/application/port/in/command/RegisterMemberCommand.java b/src/main/java/com/umc/product/member/application/port/in/command/RegisterMemberCommand.java new file mode 100644 index 00000000..0af046d5 --- /dev/null +++ b/src/main/java/com/umc/product/member/application/port/in/command/RegisterMemberCommand.java @@ -0,0 +1,53 @@ +package com.umc.product.member.application.port.in.command; + +import static java.util.Objects.requireNonNull; + +import com.umc.product.member.domain.Member; +import com.umc.product.member.domain.MemberOAuth; +import com.umc.product.member.domain.MemberTermAgreement; +import com.umc.product.member.domain.OAuthProvider; +import com.umc.product.member.domain.enums.TermType; +import java.util.List; + +public record RegisterMemberCommand( + String name, + String nickname, + String email, + OAuthProvider oauthProvider, + String providerId, + List agreedTerms +) { + public RegisterMemberCommand { + requireNonNull(name, "이름은 null일 수 없습니다."); + requireNonNull(nickname, "닉네임은 null일 수 없습니다."); + requireNonNull(email, "이메일은 null일 수 없습니다."); + requireNonNull(oauthProvider, "OAuth 제공자는 null일 수 없습니다."); + requireNonNull(providerId, "OAuth 제공자 측 사용자 ID는 null일 수 없습니다."); + requireNonNull(agreedTerms, "약관 동의 여부는 null일 수 없습니다."); + } + + public Member toMemberEntity() { + return Member.builder() + .name(name) + .nickname(nickname) + .email(email) + .build(); + } + + public MemberOAuth toMemberOAuthEntity(Long memberId) { + return MemberOAuth.builder() + .memberId(memberId) + .provider(oauthProvider) + .providerId(providerId) + .build(); + } + + public List toTermAgreementEntities(Long memberId) { + return agreedTerms.stream() + .map(termType -> MemberTermAgreement.builder() + .memberId(memberId) + .termType(termType) + .build()) + .toList(); + } +} diff --git a/src/main/java/com/umc/product/member/application/port/in/query/GetMemberUseCase.java b/src/main/java/com/umc/product/member/application/port/in/query/GetMemberUseCase.java new file mode 100644 index 00000000..e39b5cea --- /dev/null +++ b/src/main/java/com/umc/product/member/application/port/in/query/GetMemberUseCase.java @@ -0,0 +1,11 @@ +package com.umc.product.member.application.port.in.query; + +public interface GetMemberUseCase { + MemberInfo getById(Long userId); + + MemberInfo getByEmail(String email); + + boolean existsById(Long userId); + + boolean existsByEmail(String email); +} diff --git a/src/main/java/com/umc/product/member/application/port/in/query/MemberInfo.java b/src/main/java/com/umc/product/member/application/port/in/query/MemberInfo.java new file mode 100644 index 00000000..47c00e74 --- /dev/null +++ b/src/main/java/com/umc/product/member/application/port/in/query/MemberInfo.java @@ -0,0 +1,26 @@ +package com.umc.product.member.application.port.in.query; + +import com.umc.product.member.domain.Member; +import com.umc.product.member.domain.enums.MemberStatus; + +public record MemberInfo( + Long id, + String name, + String nickname, + String email, + Long schoolId, + Long profileImageId, + MemberStatus status +) { + public static MemberInfo from(Member member) { + return new MemberInfo( + member.getId(), + member.getName(), + member.getNickname(), + member.getEmail(), + member.getSchoolId(), + member.getProfileImageId(), + member.getStatus() + ); + } +} diff --git a/src/main/java/com/umc/product/member/application/port/out/LoadMemberOAuthPort.java b/src/main/java/com/umc/product/member/application/port/out/LoadMemberOAuthPort.java new file mode 100644 index 00000000..d63c503f --- /dev/null +++ b/src/main/java/com/umc/product/member/application/port/out/LoadMemberOAuthPort.java @@ -0,0 +1,11 @@ +package com.umc.product.member.application.port.out; + +import com.umc.product.member.domain.MemberOAuth; +import com.umc.product.member.domain.OAuthProvider; +import java.util.Optional; + +public interface LoadMemberOAuthPort { + Optional findByProviderAndProviderUserId(OAuthProvider provider, String providerUserId); + + Optional findByUserId(Long userId); +} diff --git a/src/main/java/com/umc/product/member/application/port/out/LoadMemberPort.java b/src/main/java/com/umc/product/member/application/port/out/LoadMemberPort.java new file mode 100644 index 00000000..3b92776c --- /dev/null +++ b/src/main/java/com/umc/product/member/application/port/out/LoadMemberPort.java @@ -0,0 +1,18 @@ +package com.umc.product.member.application.port.out; + +import com.umc.product.member.domain.Member; +import java.util.Optional; + +public interface LoadMemberPort { + Optional findById(Long id); + + Optional findByEmail(String email); + + Optional findByNickname(String nickname); + + boolean existsById(Long id); + + boolean existsByEmail(String email); + + boolean existsByNickname(String nickname); +} diff --git a/src/main/java/com/umc/product/member/application/port/out/SaveMemberPort.java b/src/main/java/com/umc/product/member/application/port/out/SaveMemberPort.java new file mode 100644 index 00000000..dcdfa705 --- /dev/null +++ b/src/main/java/com/umc/product/member/application/port/out/SaveMemberPort.java @@ -0,0 +1,16 @@ +package com.umc.product.member.application.port.out; + +import com.umc.product.member.domain.Member; +import com.umc.product.member.domain.MemberOAuth; +import com.umc.product.member.domain.MemberTermAgreement; +import java.util.List; + +public interface SaveMemberPort { + Member save(Member member); + + MemberOAuth saveOAuth(MemberOAuth memberOAuth); + + List saveTermAgreements(List agreements); + + void delete(Member member); +} diff --git a/src/main/java/com/umc/product/member/application/service/command/MemberCommandService.java b/src/main/java/com/umc/product/member/application/service/command/MemberCommandService.java new file mode 100644 index 00000000..2c6aaefc --- /dev/null +++ b/src/main/java/com/umc/product/member/application/service/command/MemberCommandService.java @@ -0,0 +1,84 @@ +package com.umc.product.member.application.service.command; + +import com.umc.product.global.exception.BusinessException; +import com.umc.product.global.exception.constant.Domain; +import com.umc.product.member.application.port.in.command.CompleteRegisterMemberCommand; +import com.umc.product.member.application.port.in.command.ManageMemberUseCase; +import com.umc.product.member.application.port.in.command.RegisterMemberCommand; +import com.umc.product.member.application.port.out.LoadMemberOAuthPort; +import com.umc.product.member.application.port.out.LoadMemberPort; +import com.umc.product.member.application.port.out.SaveMemberPort; +import com.umc.product.member.domain.Member; +import com.umc.product.member.domain.MemberOAuth; +import com.umc.product.member.domain.MemberTermAgreement; +import com.umc.product.member.domain.exception.MemberDomainException; +import com.umc.product.member.domain.exception.MemberErrorCode; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class MemberCommandService implements ManageMemberUseCase { + + private final LoadMemberPort loadMemberPort; + private final SaveMemberPort saveMemberPort; + private final LoadMemberOAuthPort loadMemberOAuthPort; + + @Override + public Long register(RegisterMemberCommand command) { + // 1. OAuth 정보로 이미 등록된 사용자인지 확인 + loadMemberOAuthPort + .findByProviderAndProviderUserId(command.oauthProvider(), command.providerId()) + .ifPresent(oauth -> { + throw new BusinessException(Domain.MEMBER, + MemberErrorCode.MEMBER_ALREADY_EXISTS); + }); + + // 2. 이메일 중복 확인 + if (loadMemberPort.existsByEmail(command.email())) { + throw new BusinessException(Domain.MEMBER, MemberErrorCode.EMAIL_ALREADY_EXISTS); + } + + // 4. Member 생성 및 저장 + Member member = command.toMemberEntity(); + Member savedMember = saveMemberPort.save(member); + + // 5. OAuth 저장 + MemberOAuth memberOAuth = command.toMemberOAuthEntity(savedMember.getId()); + saveMemberPort.saveOAuth(memberOAuth); + + // 6. 약관 동의 정보 저장 + List agreements = command.toTermAgreementEntities(savedMember.getId()); + saveMemberPort.saveTermAgreements(agreements); + + // 7. member 상태를 ACTIVE로 변경 + savedMember.activate(); + + return savedMember.getId(); + } + + @Override + public Long completeRegister(CompleteRegisterMemberCommand command) { + // 1. Command에 담긴 회원이 존재하고, 상태가 PENDING인지 확인 + Member member = loadMemberPort.findById(command.memberId()) + .orElseThrow(() -> new MemberDomainException(MemberErrorCode.MEMBER_NOT_FOUND)); + member.validateIfRegisterAvailable(); + + // 2. 이메일 중복 확인 및 이메일 인증이 완료되었는지 확인 + // TODO: 이메일 인증 로직 추후에 붙이도록 함 + + // 3. Member 업데이트 (Status ACTIVE 전환 포함) + member.activate(); + member.updateProfile(command.nickname(), command.schoolId(), command.profileImageId()); + Member savedMember = saveMemberPort.save(member); + + // 4. 약관 동의 정보 저장 + List agreements = command.toTermAgreementEntities(); + saveMemberPort.saveTermAgreements(agreements); + + return savedMember.getId(); + } +} diff --git a/src/main/java/com/umc/product/member/application/service/command/OAuthLoginService.java b/src/main/java/com/umc/product/member/application/service/command/OAuthLoginService.java new file mode 100644 index 00000000..0072eb50 --- /dev/null +++ b/src/main/java/com/umc/product/member/application/service/command/OAuthLoginService.java @@ -0,0 +1,66 @@ +package com.umc.product.member.application.service.command; + +import com.umc.product.member.application.port.in.command.ProcessOAuthLoginCommand; +import com.umc.product.member.application.port.in.command.ProcessOAuthLoginUseCase; +import com.umc.product.member.application.port.out.LoadMemberOAuthPort; +import com.umc.product.member.application.port.out.SaveMemberPort; +import com.umc.product.member.domain.Member; +import com.umc.product.member.domain.MemberOAuth; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class OAuthLoginService implements ProcessOAuthLoginUseCase { + + private final LoadMemberOAuthPort loadMemberOAuthPort; + private final SaveMemberPort saveMemberPort; + + @Override + public Long processOAuthLogin(ProcessOAuthLoginCommand command) { + log.info("Processing OAuth login: provider={}, providerId={}", + command.provider(), command.providerId()); + + // 1. 기존 OAuth 연동 조회 + return loadMemberOAuthPort + .findByProviderAndProviderUserId(command.provider(), command.providerId()) + .map(MemberOAuth::getMemberId) // 이미 가입된 회원 + // TODO: 임시 회원가입 하는 회원도 생각해야함 + .orElseGet(() -> registerOAuthMemberWithPendingState(command)); // 신규 회원 가입 + } + + private Long registerOAuthMemberWithPendingState(ProcessOAuthLoginCommand command) { + log.info("Registering new member: email={}", command.email()); + + // 1. Member 생성 + Member member = Member.builder() + .name(command.name()) + .nickname(command.nickname() != null ? command.nickname() : generateNickname(command.email())) + .email(command.email()) + .build(); + + Member savedMember = saveMemberPort.save(member); + + // 2. MemberOAuth 연동 정보 저장 + MemberOAuth memberOAuth = MemberOAuth.builder() + .memberId(savedMember.getId()) + .provider(command.provider()) + .providerId(command.providerId()) + .build(); + + saveMemberPort.saveOAuth(memberOAuth); + + log.info("New member registered: memberId={}", savedMember.getId()); + return savedMember.getId(); + } + + private String generateNickname(String email) { + // 이메일에서 @앞부분 + 랜덤 숫자 + String prefix = email.split("@")[0]; + return prefix + "_" + System.currentTimeMillis() % 10000; + } +} diff --git a/src/main/java/com/umc/product/member/application/service/query/MemberQueryService.java b/src/main/java/com/umc/product/member/application/service/query/MemberQueryService.java new file mode 100644 index 00000000..8d7c9cc2 --- /dev/null +++ b/src/main/java/com/umc/product/member/application/service/query/MemberQueryService.java @@ -0,0 +1,44 @@ +package com.umc.product.member.application.service.query; + +import com.umc.product.global.exception.BusinessException; +import com.umc.product.global.exception.constant.Domain; +import com.umc.product.member.application.port.in.query.GetMemberUseCase; +import com.umc.product.member.application.port.in.query.MemberInfo; +import com.umc.product.member.application.port.out.LoadMemberPort; +import com.umc.product.member.domain.Member; +import com.umc.product.member.domain.exception.MemberErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberQueryService implements GetMemberUseCase { + + private final LoadMemberPort loadMemberPort; + + @Override + public MemberInfo getById(Long userId) { + Member member = loadMemberPort.findById(userId) + .orElseThrow(() -> new BusinessException(Domain.MEMBER, MemberErrorCode.MEMBER_NOT_FOUND)); + return MemberInfo.from(member); + } + + @Override + public MemberInfo getByEmail(String email) { + Member member = loadMemberPort.findByEmail(email) + .orElseThrow(() -> new BusinessException(Domain.MEMBER, MemberErrorCode.MEMBER_NOT_FOUND)); + return MemberInfo.from(member); + } + + @Override + public boolean existsById(Long userId) { + return loadMemberPort.existsById(userId); + } + + @Override + public boolean existsByEmail(String email) { + return loadMemberPort.existsByEmail(email); + } +} diff --git a/src/main/java/com/umc/product/member/domain/Member.java b/src/main/java/com/umc/product/member/domain/Member.java new file mode 100644 index 00000000..6b489ac8 --- /dev/null +++ b/src/main/java/com/umc/product/member/domain/Member.java @@ -0,0 +1,97 @@ +package com.umc.product.member.domain; + +import com.umc.product.common.BaseEntity; +import com.umc.product.member.domain.enums.MemberStatus; +import com.umc.product.member.domain.exception.MemberDomainException; +import com.umc.product.member.domain.exception.MemberErrorCode; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "member") +public class Member extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 30) // 한글 10자까지 고려 + private String name; + + @Column(nullable = false, length = 20) // 한글 1~5자 + private String nickname; + + @Column(nullable = false, length = 100) + private String email; + + @Column(name = "school_id") + private Long schoolId; // ID 참조만 (organization 도메인 의존 방지) + + @Column(name = "profile_image_id") + private Long profileImageId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private MemberStatus status; + + @Builder + private Member(String name, String nickname, String email, Long schoolId, Long profileImageId) { + this.name = name; + this.nickname = nickname; + this.email = email; + this.schoolId = schoolId; + this.profileImageId = profileImageId; + this.status = MemberStatus.PENDING; + } + + // Domain Logic: 회원가입 완료 처리 + public void activate() { + if (this.status != MemberStatus.PENDING) { + throw new MemberDomainException(MemberErrorCode.INVALID_MEMBER_STATUS); + } + this.status = MemberStatus.ACTIVE; + } + + // Domain Logic: 프로필 업데이트 + public void updateProfile(String nickname, Long schoolId, Long profileImageId) { + validateActive(); + if (nickname != null) { + this.nickname = nickname; + } + if (schoolId != null) { + this.schoolId = schoolId; + } + if (profileImageId != null) { + this.profileImageId = profileImageId; + } + } + + public void validateIfRegisterAvailable() { + if (this.status != MemberStatus.PENDING) { + throw new MemberDomainException(MemberErrorCode.MEMBER_ALREADY_REGISTERED); + } + } + + /** + * 유효한 사용자인지 검증 (휴면 회원이 아닌지) + */ + private void validateActive() { + if (this.status != MemberStatus.ACTIVE) { + throw new MemberDomainException(MemberErrorCode.MEMBER_NOT_ACTIVE); + } + } + + // TODO: 탈퇴 및 휴면 처리에 대한 도메인 로직은 추후 추가 +} diff --git a/src/main/java/com/umc/product/member/domain/MemberOAuth.java b/src/main/java/com/umc/product/member/domain/MemberOAuth.java new file mode 100644 index 00000000..60e106f8 --- /dev/null +++ b/src/main/java/com/umc/product/member/domain/MemberOAuth.java @@ -0,0 +1,47 @@ +package com.umc.product.member.domain; + +import com.umc.product.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "member_oauth", uniqueConstraints = { + @UniqueConstraint(name = "uk_member_oauth_provider_provider_id", + columnNames = {"oauth_provider", "provider_id"}) +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MemberOAuth extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "member_id", nullable = false) + private Long memberId; // ID 참조만 + + @Enumerated(EnumType.STRING) + @Column(name = "oauth_provider", nullable = false, length = 20) + private OAuthProvider provider; + + @Column(name = "provider_id", nullable = false, length = 512) + private String providerId; + + @Builder + private MemberOAuth(Long memberId, OAuthProvider provider, String providerId) { + this.memberId = memberId; + this.provider = provider; + this.providerId = providerId; + } +} diff --git a/src/main/java/com/umc/product/member/domain/MemberTermAgreement.java b/src/main/java/com/umc/product/member/domain/MemberTermAgreement.java new file mode 100644 index 00000000..73a651ef --- /dev/null +++ b/src/main/java/com/umc/product/member/domain/MemberTermAgreement.java @@ -0,0 +1,45 @@ +package com.umc.product.member.domain; + +import com.umc.product.common.BaseEntity; +import com.umc.product.member.domain.enums.TermType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "member_term_agreement") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MemberTermAgreement extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "member_id", nullable = false) + private Long memberId; // ID 참조만 + + @Enumerated(EnumType.STRING) + @Column(name = "term_type", nullable = false, length = 20) + private TermType termType; + + @Column(name = "agreed_at", nullable = false) + private Instant agreedAt; + + @Builder + private MemberTermAgreement(Long memberId, TermType termType, Instant agreedAt) { + this.memberId = memberId; + this.termType = termType; + this.agreedAt = agreedAt != null ? agreedAt : Instant.now(); + } +} diff --git a/src/main/java/com/umc/product/member/domain/OAuthProvider.java b/src/main/java/com/umc/product/member/domain/OAuthProvider.java new file mode 100644 index 00000000..baa2f2a4 --- /dev/null +++ b/src/main/java/com/umc/product/member/domain/OAuthProvider.java @@ -0,0 +1,8 @@ +package com.umc.product.member.domain; + +public enum OAuthProvider { + GOOGLE, + APPLE, + KAKAO, + NAVER +} diff --git a/src/main/java/com/umc/product/member/domain/enums/MemberStatus.java b/src/main/java/com/umc/product/member/domain/enums/MemberStatus.java new file mode 100644 index 00000000..915b3c24 --- /dev/null +++ b/src/main/java/com/umc/product/member/domain/enums/MemberStatus.java @@ -0,0 +1,8 @@ +package com.umc.product.member.domain.enums; + +public enum MemberStatus { + PENDING, // OAuth 로그인 시도 했던 사람 + ACTIVE, // 유효한 회원 + INACTIVE, // 휴면 계정 + WITHDRAWN // 탈퇴 계정 +} diff --git a/src/main/java/com/umc/product/member/domain/enums/TermType.java b/src/main/java/com/umc/product/member/domain/enums/TermType.java new file mode 100644 index 00000000..9cf42445 --- /dev/null +++ b/src/main/java/com/umc/product/member/domain/enums/TermType.java @@ -0,0 +1,7 @@ +package com.umc.product.member.domain.enums; + +public enum TermType { + SERVICE, // 서비스이용약관 + PRIVACY, // 개인정보처리방침 + MARKETING // 마케팅정보수신동의 +} diff --git a/src/main/java/com/umc/product/member/domain/exception/MemberDomainException.java b/src/main/java/com/umc/product/member/domain/exception/MemberDomainException.java new file mode 100644 index 00000000..ae70e1fe --- /dev/null +++ b/src/main/java/com/umc/product/member/domain/exception/MemberDomainException.java @@ -0,0 +1,12 @@ +package com.umc.product.member.domain.exception; + +import com.umc.product.global.exception.BusinessException; +import com.umc.product.global.exception.constant.Domain; + +public class MemberDomainException extends BusinessException { + public MemberDomainException(MemberErrorCode memberErrorCode) { + super(Domain.MEMBER, memberErrorCode); + } + + +} diff --git a/src/main/java/com/umc/product/member/domain/exception/MemberErrorCode.java b/src/main/java/com/umc/product/member/domain/exception/MemberErrorCode.java new file mode 100644 index 00000000..6e4dc5e1 --- /dev/null +++ b/src/main/java/com/umc/product/member/domain/exception/MemberErrorCode.java @@ -0,0 +1,24 @@ +package com.umc.product.member.domain.exception; + +import com.umc.product.global.response.code.BaseCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum MemberErrorCode implements BaseCode { + + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER-0001", "사용자를 찾을 수 없습니다."), + MEMBER_ALREADY_EXISTS(HttpStatus.CONFLICT, "MEMBER-0002", "이미 등록된 사용자입니다."), + EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "MEMBER-0003", "이미 사용 중인 이메일입니다."), + MEMBER_ALREADY_WITHDRAWN(HttpStatus.BAD_REQUEST, "MEMBER-0004", "이미 탈퇴한 사용자입니다."), + INVALID_MEMBER_STATUS(HttpStatus.BAD_REQUEST, "MEMBER-0005", "올바르지 않은 사용자 상태입니다."), + MEMBER_NOT_ACTIVE(HttpStatus.BAD_REQUEST, "MEMBER-0006", "올바르지 않은 사용자 상태입니다."), + MEMBER_ALREADY_REGISTERED(HttpStatus.BAD_REQUEST, "MEMBER-0007", "이미 회원가입을 완료한 사용자입니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/com/umc/product/temp/controller/TempController.java b/src/main/java/com/umc/product/temp/controller/TempController.java index 0c4dd7ac..b6d453a1 100644 --- a/src/main/java/com/umc/product/temp/controller/TempController.java +++ b/src/main/java/com/umc/product/temp/controller/TempController.java @@ -1,11 +1,13 @@ package com.umc.product.temp.controller; +import com.umc.product.global.constant.SwaggerTag.Constants; import com.umc.product.global.response.ApiResponse; import com.umc.product.global.security.JwtTokenProvider; -import com.umc.product.global.security.UserPrincipal; -import com.umc.product.global.security.annotation.CurrentUser; +import com.umc.product.global.security.MemberPrincipal; +import com.umc.product.global.security.annotation.CurrentMember; import com.umc.product.global.security.annotation.Public; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Profile; import org.springframework.web.bind.annotation.GetMapping; @@ -15,6 +17,7 @@ @RequiredArgsConstructor @Profile("local | dev") @RestController("temp") +@Tag(name = Constants.TEST) public class TempController { private final JwtTokenProvider jwtTokenProvider; @@ -35,7 +38,7 @@ public String healthCheck() { @Operation(summary = "인증된 사용자인지 여부를 확인합니다.", description = "인증되지 않은 사용자인 경우 401을 반환합니다.") @GetMapping("/check-authenticated") - public String checkAuthenticated(@CurrentUser UserPrincipal currentUser) { + public String checkAuthenticated(@CurrentMember MemberPrincipal currentUser) { return currentUser.toString(); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..6a9873d9 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,134 @@ +# 서버 기본 설정들 + +server: + port: ${SERVER_PORT:8080} + shutdown: graceful + # tomcat tuning options (나중에 test 하면서 추가) +# tomcat: +# threads: +# max: 200 +# min-spare: 30 +# max-connections: 700 +# accept-count: 300 + +spring: + application: + name: x-umc-product + profiles: + # 기본적으로 local 프로필을 활성화, 실수로 prod에 접근하는 것을 방지함 + active: local + config: + # application-secret 받도록 설정, 우리는 활용하지 않음. + import: + - optional:classpath:application-secret.yml + - optional:file:/app/config/application-secret.yml + docker: + compose: + enabled: false # Local DB를 위한 Compose는 기본적으로 비활성화 + + jackson: + generator: + write-numbers-as-strings: true + + datasource: + url: ${DATABASE_URL} + username: ${DATABASE_USERNAME} + password: ${DATABASE_PASSWORD} + driver-class-name: org.postgresql.Driver + + hikari: + auto-commit: true + + maximum-pool-size: 10 # Core * 2 + 여유분 + minimum-idle: 10 # max와 동일하게 설정 (고정 풀 크기 권장) + + # 3. 수명 및 타임아웃 + max-lifetime: 1800000 # 30분 + connection-timeout: 30000 # 30초 + validation-timeout: 5000 # 5초 + + keepalive-time: 60000 # DB에 1분마다 생존 신고 (유휴 커넥션 방지) + + + jpa: + hibernate: + ddl-auto: validate + open-in-view: false + properties: + hibernate: + format_sql: true + # dialect는 명시가 불필요해서 생략 + + +# swagger 설정 +springdoc: + swagger-ui: + path: /docs # 원하는 경로로 변경 + enabled: false + operations-sorter: method # API 정렬 (method, alpha) + tags-sorter: alpha # 태그 정렬 + + api-docs: + path: /docs-json # API 문서 경로 + enabled: false + +logging: + level: + root: INFO + + # 내 애플리케이션 - 개발 시 DEBUG + com.umc.product.api: DEBUG + + # Spring Web - 요청/응답 확인 + org.springframework.web: INFO + + # JPA/Hibernate - SQL 쿼리 확인 + org.hibernate.SQL: INFO # 실행되는 SQL + org.hibernate.orm.jdbc.bind: INFO # 바인딩 파라미터 (Hibernate 6+) + + # 트랜잭션 확인 + org.springframework.transaction: DEBUG + org.springframework.orm.jpa: DEBUG + + # 시끄러운 로그 끄기 + org.springframework.web.client.DefaultRestClient: OFF + org.apache.http: WARN + com.zaxxer.hikari: WARN + +management: + server: + port: ${MANAGEMENT_PORT:9090} + endpoint: + health: + show-details: always + endpoints: + web: + exposure: + include: "health,info,metrics,prometheus,tracing" + tracing: + enabled: true + sampling: + probability: ${TRACE_SAMPLING_PROBABILITY:0.1} + # 지금은 내보내지 않음, Grafana Tempo로 연동하도록 추후 추가 + # otlp: + # tracing: + # endpoint: ${TEMPO_URL} + + metrics: + enable: + jvm: true + process: true + http: true + jdbc: true # DB 쿼리 메트릭 + # rabbitmq: true # messaging, 사용 시 추가 + system: true # CPU, 메모리 등 시스템 메트릭 + tomcat: true # 톰캣 스레드풀, 세션 메트릭 + hikaricp: true # HikariCP 커넥션 풀 메트릭 + logback: true # 로그 레벨별 카운트 + cache: true # 캐시 사용 시 + spring.security: true # Spring Security 인증 메트릭 + + prometheus: + metrics: + export: + enabled: true diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index c1dddbdc..44ce64e2 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -17,13 +17,13 @@ - %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%X{traceId}] [%thread] %logger{40} : %msg%n + %d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) [%-4.4X{traceId}] [%thread] %logger{40} : %msg%n - + @@ -33,4 +33,4 @@ - \ No newline at end of file + diff --git a/src/test/java/com/umc/product/UmcProductApplicationTests.java b/src/test/java/com/umc/product/UmcProductApplicationTests.java index 3d5f6254..a9c74e90 100644 --- a/src/test/java/com/umc/product/UmcProductApplicationTests.java +++ b/src/test/java/com/umc/product/UmcProductApplicationTests.java @@ -1,11 +1,8 @@ package com.umc.product; +import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest class UmcProductApplicationTests { - -// @Test -// void contextLoads() { -// } }