diff --git a/README.md b/README.md index a8f76a34..098d4f2c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Learning Manager -> **v0.0.7** | 스터디 과정 관리 및 출석 시스템 +> **v0.0.9** | 스터디 과정 관리 및 출석 시스템 회원의 온/오프라인 스터디 과정을 지원하는 백엔드 서비스입니다. 출석 관리, 일정 관리 등 반복적이고 비효율적인 업무를 자동화하여 **학습 과정 자체에 집중할 수 있는 인프라**를 제공합니다. @@ -43,10 +43,11 @@ | 구분 | 기술 | 버전 | |-----------|-----------------------|--------| -| Language | Java | 17 | +| Language | Java | 21 | | Framework | Spring Boot | 3.3.12 | | Security | Spring Security + JWT | - | | ORM | Spring Data JPA | - | +| Query | QueryDSL | 5.1.0 | | NoSQL | Spring Data MongoDB | - | ### Database @@ -163,7 +164,7 @@ learning-manager/ ### 사전 요구사항 -- Java 17+ +- Java 21+ - Docker & Docker Compose - Gradle 8.x @@ -294,8 +295,8 @@ curl -X POST http://localhost:8080/api/v1/auth/token \ | 문서 | 설명 | |--------------------------------------------|--------------| -| [PRD v0.0.7](docs/Prd_v0_0_7.md) | 제품 요구사항 명세서 | -| [Usecases v0.0.7](docs/Usecases_v0_0_7.md) | 유스케이스 명세 | +| [PRD v0.0.9](docs/Prd_v0_0_9.md) | 제품 요구사항 명세서 | +| [Usecases v0.0.9](docs/Usecases_v0_0_9.md) | 유스케이스 명세 | | [ERD](docs/entity-relation-diagram.puml) | 엔티티 관계 다이어그램 | | [Components](docs/components.puml) | 컴포넌트 다이어그램 | | [Sequences](docs/sequences/) | 시퀀스 다이어그램 | diff --git a/adapter/persistence/build.gradle.kts b/adapter/persistence/build.gradle.kts index df68d855..9e7b605f 100644 --- a/adapter/persistence/build.gradle.kts +++ b/adapter/persistence/build.gradle.kts @@ -5,4 +5,4 @@ plugins { dependencies { implementation(project(":core:domain")) implementation(project(":core:requires")) -} \ No newline at end of file +} diff --git a/adapter/persistence/src/main/java/me/chan99k/learningmanager/config/JpaConfig.java b/adapter/persistence/src/main/java/me/chan99k/learningmanager/config/JpaConfig.java index 79acba97..e4ced3ce 100644 --- a/adapter/persistence/src/main/java/me/chan99k/learningmanager/config/JpaConfig.java +++ b/adapter/persistence/src/main/java/me/chan99k/learningmanager/config/JpaConfig.java @@ -8,6 +8,9 @@ import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import jakarta.persistence.EntityManager; import me.chan99k.learningmanager.member.SystemRoleHierarchy; @Configuration @@ -25,4 +28,8 @@ public SystemRoleHierarchy systemRoleHierarchy() { return new SystemRoleHierarchy(); } + @Bean + public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) { + return new JPAQueryFactory(entityManager); + } } diff --git a/adapter/persistence/src/main/java/me/chan99k/learningmanager/course/CustomCourseRepository.java b/adapter/persistence/src/main/java/me/chan99k/learningmanager/course/CustomCourseRepository.java new file mode 100644 index 00000000..8ffde105 --- /dev/null +++ b/adapter/persistence/src/main/java/me/chan99k/learningmanager/course/CustomCourseRepository.java @@ -0,0 +1,35 @@ +package me.chan99k.learningmanager.course; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import me.chan99k.learningmanager.course.entity.CourseEntity; +import me.chan99k.learningmanager.member.CourseParticipationInfo; + +public interface CustomCourseRepository { + + Page findCourseMembersByCourseId(Long courseId, Pageable pageable); + + // === Entity 조회 === + Optional findManagedCourseById(Long courseId, Long memberId); + + List findManagedCoursesByMemberId(Long memberId); + + List findParticipatingCoursesByMemberId(Long memberId); + + // === Projection 조회 === + List findParticipatingCoursesWithRoleByMemberId(Long memberId); + + Optional findCourseBasicDetailsById(Long courseId); + + // === 인가(Authorization) 관련 쿼리 === + + boolean existsByMemberIdAndCourseIdAndRole(Long memberId, Long courseId, CourseRole role); + + boolean existsByMemberIdAndCourseIdAndRoleIn(Long memberId, Long courseId, List roles); + + boolean existsByMemberIdAndCourseId(Long memberId, Long courseId); +} diff --git a/adapter/persistence/src/main/java/me/chan99k/learningmanager/course/CustomCourseRepositoryImpl.java b/adapter/persistence/src/main/java/me/chan99k/learningmanager/course/CustomCourseRepositoryImpl.java new file mode 100644 index 00000000..140e9a3f --- /dev/null +++ b/adapter/persistence/src/main/java/me/chan99k/learningmanager/course/CustomCourseRepositoryImpl.java @@ -0,0 +1,179 @@ +package me.chan99k.learningmanager.course; + +import static me.chan99k.learningmanager.course.entity.QCourseEntity.*; +import static me.chan99k.learningmanager.course.entity.QCourseMemberEntity.*; +import static me.chan99k.learningmanager.course.entity.QCurriculumEntity.*; +import static me.chan99k.learningmanager.member.entity.QAccountEntity.*; +import static me.chan99k.learningmanager.member.entity.QMemberEntity.*; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; + +import com.querydsl.core.Tuple; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import me.chan99k.learningmanager.course.entity.CourseEntity; +import me.chan99k.learningmanager.member.CourseParticipationInfo; + +public class CustomCourseRepositoryImpl implements CustomCourseRepository { + private final JPAQueryFactory queryFactory; + + public CustomCourseRepositoryImpl(JPAQueryFactory queryFactory) { + this.queryFactory = queryFactory; + } + + @Override + public Page findCourseMembersByCourseId(Long courseId, Pageable pageable) { + List tuples = queryFactory + .select( + courseMemberEntity.memberId, + memberEntity.nickname, + accountEntity.email, + courseMemberEntity.courseRole, + courseMemberEntity.createdAt + ).from(courseMemberEntity) + .join(memberEntity).on(courseMemberEntity.memberId.eq(memberEntity.id)) + .join(accountEntity).on(accountEntity.member.id.eq(memberEntity.id)) + .where(courseMemberEntity.course.id.eq(courseId)) + .orderBy(courseMemberEntity.createdAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + List courseMemberInfos = tuples.stream().map(tuple -> + new CourseMemberInfo( + tuple.get(courseMemberEntity.memberId), + tuple.get(memberEntity.nickname), + Objects.requireNonNull(tuple.get(accountEntity.email)).address(), + tuple.get(courseMemberEntity.courseRole), + tuple.get(courseMemberEntity.createdAt)) + ).toList(); + + JPAQuery countQuery = queryFactory + .select(courseMemberEntity.count()) + .from(courseMemberEntity) + .where(courseMemberEntity.course.id.eq(courseId)); + + return PageableExecutionUtils.getPage(courseMemberInfos, pageable, countQuery::fetchOne); + } + + @Override + public Optional findManagedCourseById(Long courseId, Long memberId) { + CourseEntity result = queryFactory + .selectFrom(courseEntity) + .join(courseEntity.courseMemberList, courseMemberEntity) + .where( + courseEntity.id.eq(courseId), + courseMemberEntity.memberId.eq(memberId), + courseMemberEntity.courseRole.eq(CourseRole.MANAGER)) + .fetchOne(); + + return Optional.ofNullable(result); + } + + @Override + public List findManagedCoursesByMemberId(Long memberId) { + return queryFactory + .selectFrom(courseEntity) + .join(courseEntity.courseMemberList, courseMemberEntity) + .where( + courseMemberEntity.memberId.eq(memberId), + courseMemberEntity.courseRole.eq(CourseRole.MANAGER)) + .fetch(); + } + + @Override + public List findParticipatingCoursesByMemberId(Long memberId) { + return queryFactory + .selectFrom(courseEntity) + .join(courseEntity.courseMemberList, courseMemberEntity) + .where(courseMemberEntity.memberId.eq(memberId)) + .fetch(); + } + + @Override + public List findParticipatingCoursesWithRoleByMemberId(Long memberId) { + return queryFactory + .select(Projections.constructor(CourseParticipationInfo.class, + courseEntity.id, + courseEntity.title, + courseEntity.description, + courseMemberEntity.courseRole)) + .from(courseEntity) + .join(courseEntity.courseMemberList, courseMemberEntity) + .where(courseMemberEntity.memberId.eq(memberId)) + .fetch(); + } + + @Override + public Optional findCourseBasicDetailsById(Long courseId) { + CourseDetailInfo result = queryFactory + .select(Projections.constructor(CourseDetailInfo.class, + courseEntity.id, + courseEntity.title, + courseEntity.description, + courseEntity.createdAt, + courseMemberEntity.id.countDistinct(), + curriculumEntity.id.countDistinct())) + .from(courseEntity) + .leftJoin(courseEntity.courseMemberList, courseMemberEntity) + .leftJoin(courseEntity.curriculumList, curriculumEntity) + .where(courseEntity.id.eq(courseId)) + .groupBy( + courseEntity.id, + courseEntity.title, + courseEntity.description, + courseEntity.createdAt) + .fetchOne(); + + return Optional.ofNullable(result); + } + + @Override + public boolean existsByMemberIdAndCourseIdAndRole(Long memberId, Long courseId, CourseRole role) { + Integer result = queryFactory + .selectOne() + .from(courseMemberEntity) + .where( + courseMemberEntity.memberId.eq(memberId), + courseMemberEntity.course.id.eq(courseId), + courseMemberEntity.courseRole.eq(role)) + .fetchFirst(); + + return result != null; + } + + @Override + public boolean existsByMemberIdAndCourseIdAndRoleIn(Long memberId, Long courseId, List roles) { + Integer result = queryFactory + .selectOne() + .from(courseMemberEntity) + .where( + courseMemberEntity.memberId.eq(memberId), + courseMemberEntity.course.id.eq(courseId), + courseMemberEntity.courseRole.in(roles)) + .fetchFirst(); + + return result != null; + } + + @Override + public boolean existsByMemberIdAndCourseId(Long memberId, Long courseId) { + Integer result = queryFactory + .selectOne() + .from(courseMemberEntity) + .where( + courseMemberEntity.memberId.eq(memberId), + courseMemberEntity.course.id.eq(courseId)) + .fetchFirst(); + + return result != null; + } +} diff --git a/adapter/persistence/src/main/java/me/chan99k/learningmanager/course/JpaCourseRepository.java b/adapter/persistence/src/main/java/me/chan99k/learningmanager/course/JpaCourseRepository.java index 3adfe852..755d61bb 100644 --- a/adapter/persistence/src/main/java/me/chan99k/learningmanager/course/JpaCourseRepository.java +++ b/adapter/persistence/src/main/java/me/chan99k/learningmanager/course/JpaCourseRepository.java @@ -1,87 +1,13 @@ package me.chan99k.learningmanager.course; -import java.util.List; import java.util.Optional; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; import me.chan99k.learningmanager.course.entity.CourseEntity; -import me.chan99k.learningmanager.member.CourseParticipationInfo; -public interface JpaCourseRepository extends JpaRepository { - Optional findByTitle(String title); - - @Query("SELECT c FROM CourseEntity c JOIN c.courseMemberList cm " + - "WHERE c.id = :courseId AND cm.memberId = :memberId AND cm.courseRole = 'MANAGER'") - Optional findManagedCourseById(@Param("courseId") Long courseId, - @Param("memberId") Long memberId); - - @Query("SELECT c FROM CourseEntity c JOIN c.courseMemberList cm " + - "WHERE cm.memberId = :memberId AND cm.courseRole = 'MANAGER'") - List findManagedCoursesByMemberId(@Param("memberId") Long memberId); - - @Query("SELECT c FROM CourseEntity c JOIN c.courseMemberList cm " + - "WHERE cm.memberId = :memberId") - List findParticipatingCoursesByMemberId(@Param("memberId") Long memberId); - - @Query("SELECT new me.chan99k.learningmanager.member.CourseParticipationInfo(" + - "c.id, c.title, c.description, cm.courseRole) " + - "FROM CourseEntity c JOIN c.courseMemberList cm " + - "WHERE cm.memberId = :memberId") - List findParticipatingCoursesWithRoleByMemberId(@Param("memberId") Long memberId); +public interface JpaCourseRepository extends JpaRepository, CustomCourseRepository { - @Query(""" - SELECT new me.chan99k.learningmanager.course.CourseDetailInfo( - c.id, - c.title, - c.description, - c.createdAt, - COUNT(DISTINCT cm.id), - COUNT(DISTINCT cur.id) - ) - FROM CourseEntity c - LEFT JOIN c.courseMemberList cm - LEFT JOIN c.curriculumList cur - WHERE c.id = :courseId - GROUP BY c.id, c.title, c.description, c.createdAt - """) - Optional findCourseBasicDetailsById(Long courseId); - - @Query("SELECT new me.chan99k.learningmanager.course.CourseMemberInfo(" + - "cm.memberId, m.nickname, a.email, cm.courseRole, cm.createdAt) " + - "FROM CourseMemberEntity cm " + - "JOIN MemberEntity m ON cm.memberId = m.id " + - "JOIN AccountEntity a ON a.member.id = m.id " + - "WHERE cm.course.id = :courseId " + - "ORDER BY cm.createdAt DESC") - Page findCourseMembersByCourseId(@Param("courseId") Long courseId, Pageable pageable); - - // === 인가(Authorization) 관련 쿼리 === - - @Query("SELECT COUNT(cm) > 0 FROM CourseMemberEntity cm " + - "WHERE cm.memberId = :memberId AND cm.course.id = :courseId AND cm.courseRole = :role") - boolean existsByMemberIdAndCourseIdAndRole( - @Param("memberId") Long memberId, - @Param("courseId") Long courseId, - @Param("role") CourseRole role - ); - - @Query("SELECT COUNT(cm) > 0 FROM CourseMemberEntity cm " + - "WHERE cm.memberId = :memberId AND cm.course.id = :courseId AND cm.courseRole IN :roles") - boolean existsByMemberIdAndCourseIdAndRoleIn( - @Param("memberId") Long memberId, - @Param("courseId") Long courseId, - @Param("roles") List roles - ); + Optional findByTitle(String title); - @Query("SELECT COUNT(cm) > 0 FROM CourseMemberEntity cm " + - "WHERE cm.memberId = :memberId AND cm.course.id = :courseId") - boolean existsByMemberIdAndCourseId( - @Param("memberId") Long memberId, - @Param("courseId") Long courseId - ); } diff --git a/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/CustomMemberRepository.java b/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/CustomMemberRepository.java new file mode 100644 index 00000000..841c02d6 --- /dev/null +++ b/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/CustomMemberRepository.java @@ -0,0 +1,16 @@ +package me.chan99k.learningmanager.member; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Limit; + +import me.chan99k.learningmanager.member.entity.MemberEntity; + +public interface CustomMemberRepository { + + List findMemberEmailPairs(List emails, Limit limit); + + Optional findByAccountsEmail(String email); + +} diff --git a/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/CustomMemberRepositoryImpl.java b/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/CustomMemberRepositoryImpl.java new file mode 100644 index 00000000..83b46e68 --- /dev/null +++ b/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/CustomMemberRepositoryImpl.java @@ -0,0 +1,52 @@ +package me.chan99k.learningmanager.member; + +import static me.chan99k.learningmanager.member.entity.QAccountEntity.*; +import static me.chan99k.learningmanager.member.entity.QMemberEntity.*; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.data.domain.Limit; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +import me.chan99k.learningmanager.member.entity.MemberEntity; +import me.chan99k.learningmanager.member.mapper.MemberMapper; + +public class CustomMemberRepositoryImpl implements CustomMemberRepository { + private final JPAQueryFactory queryFactory; + + public CustomMemberRepositoryImpl(JPAQueryFactory queryFactory) { + this.queryFactory = queryFactory; + } + + @Override + public List findMemberEmailPairs(List emails, Limit limit) { + var query = queryFactory + .select(memberEntity, accountEntity.email) + .from(memberEntity) + .join(memberEntity.accounts, accountEntity) + .where(accountEntity.email.in(emails)); + + if (limit.isLimited()) { + query.limit(limit.max()); + } + + return query.fetch().stream() + .map(tuple -> new MemberEmailPair( + MemberMapper.toDomain(Objects.requireNonNull(tuple.get(memberEntity))), + Objects.requireNonNull(tuple.get(accountEntity.email)).address())) + .toList(); + } + + @Override + public Optional findByAccountsEmail(String email) { + MemberEntity foundMember = queryFactory.selectFrom(memberEntity) + .join(memberEntity.accounts, accountEntity) + .where(accountEntity.email.eq(Email.of(email))) + .fetchOne(); + + return Optional.ofNullable(foundMember); + } +} diff --git a/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/JpaMemberRepository.java b/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/JpaMemberRepository.java index c6c668d6..5cd5564e 100644 --- a/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/JpaMemberRepository.java +++ b/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/JpaMemberRepository.java @@ -1,23 +1,13 @@ package me.chan99k.learningmanager.member; -import java.util.List; import java.util.Optional; -import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; import me.chan99k.learningmanager.member.entity.MemberEntity; -public interface JpaMemberRepository extends JpaRepository { - - @Query("SELECT DISTINCT m FROM MemberEntity m JOIN FETCH m.accounts a WHERE a.email = :email") - Optional findByAccountsEmail(@Param("email") String email); +public interface JpaMemberRepository extends JpaRepository, CustomMemberRepository { Optional findByNickname(String nickname); - @Query("SELECT DISTINCT m FROM MemberEntity m JOIN FETCH m.accounts a WHERE a.email IN :emails") - List findByAccountsEmailIn(@Param("emails") List emails, Limit limit); } - diff --git a/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/MemberQueryAdapter.java b/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/MemberQueryAdapter.java index 475d02dc..0163560b 100644 --- a/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/MemberQueryAdapter.java +++ b/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/MemberQueryAdapter.java @@ -1,13 +1,11 @@ package me.chan99k.learningmanager.member; -import java.util.ArrayList; import java.util.List; import java.util.Optional; import org.springframework.data.domain.Limit; import org.springframework.stereotype.Repository; -import me.chan99k.learningmanager.member.entity.MemberEntity; import me.chan99k.learningmanager.member.mapper.MemberMapper; @Repository @@ -39,16 +37,6 @@ public Optional findByNickName(Nickname nickname) { @Override public List findMembersByEmails(List emails, int limit) { - List emailStrings = emails.stream().map(Email::address).toList(); - List entities = jpaMemberRepository.findByAccountsEmailIn(emailStrings, Limit.of(limit)); - - List result = new ArrayList<>(); - for (MemberEntity entity : entities) { - Member member = MemberMapper.toDomain(entity); - entity.getAccounts().stream() - .filter(acc -> emailStrings.contains(acc.getEmail())) - .forEach(acc -> result.add(new MemberEmailPair(member, acc.getEmail()))); - } - return result; + return jpaMemberRepository.findMemberEmailPairs(emails, Limit.of(limit)); } } diff --git a/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/entity/AccountEntity.java b/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/entity/AccountEntity.java index 3aa1f5c6..d7515882 100644 --- a/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/entity/AccountEntity.java +++ b/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/entity/AccountEntity.java @@ -5,6 +5,7 @@ import jakarta.persistence.CollectionTable; import jakarta.persistence.Column; +import jakarta.persistence.Convert; import jakarta.persistence.ElementCollection; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -15,6 +16,8 @@ import jakarta.persistence.Table; import me.chan99k.learningmanager.common.MutableEntity; import me.chan99k.learningmanager.member.AccountStatus; +import me.chan99k.learningmanager.member.Email; +import me.chan99k.learningmanager.member.mapper.EmailConverter; @Entity @Table(name = "account") @@ -27,8 +30,9 @@ public class AccountEntity extends MutableEntity { @Enumerated(EnumType.STRING) private AccountStatus status; + @Convert(converter = EmailConverter.class) @Column(name = "email", nullable = false, unique = true) - private String email; + private Email email; @ElementCollection @CollectionTable( @@ -56,12 +60,12 @@ public void setStatus(AccountStatus status) { this.status = status; } - public String getEmail() { + public Email getEmail() { return email; } public void setEmail(String email) { - this.email = email; + this.email = Email.of(email); } public List getCredentials() { diff --git a/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/entity/MemberEntity.java b/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/entity/MemberEntity.java index 6f998987..58897a26 100644 --- a/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/entity/MemberEntity.java +++ b/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/entity/MemberEntity.java @@ -3,10 +3,9 @@ import java.util.ArrayList; import java.util.List; -import jakarta.persistence.AttributeOverride; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; -import jakarta.persistence.Embedded; +import jakarta.persistence.Convert; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; @@ -15,13 +14,14 @@ import me.chan99k.learningmanager.common.MutableEntity; import me.chan99k.learningmanager.member.Email; import me.chan99k.learningmanager.member.MemberStatus; +import me.chan99k.learningmanager.member.mapper.EmailConverter; @Entity @Table(name = "member") public class MemberEntity extends MutableEntity { - @Embedded - @AttributeOverride(name = "address", column = @Column(name = "primary_email")) + @Convert(converter = EmailConverter.class) + @Column(name = "primary_email") private Email primaryEmail; @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) diff --git a/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/mapper/AccountMapper.java b/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/mapper/AccountMapper.java index f4fdb27a..3dbf6df9 100644 --- a/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/mapper/AccountMapper.java +++ b/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/mapper/AccountMapper.java @@ -7,7 +7,6 @@ import me.chan99k.learningmanager.member.Account; import me.chan99k.learningmanager.member.Credential; -import me.chan99k.learningmanager.member.Email; import me.chan99k.learningmanager.member.entity.AccountEntity; import me.chan99k.learningmanager.member.entity.CredentialEmbeddable; import me.chan99k.learningmanager.member.entity.MemberEntity; @@ -57,7 +56,7 @@ public static Account toDomain(AccountEntity entity) { return Account.reconstitute( entity.getId(), entity.getStatus(), - Email.of(entity.getEmail()), + entity.getEmail(), credentials, entity.getCreatedAt(), entity.getCreatedBy(), diff --git a/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/mapper/EmailConverter.java b/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/mapper/EmailConverter.java new file mode 100644 index 00000000..f3f3aa3d --- /dev/null +++ b/adapter/persistence/src/main/java/me/chan99k/learningmanager/member/mapper/EmailConverter.java @@ -0,0 +1,19 @@ +package me.chan99k.learningmanager.member.mapper; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import me.chan99k.learningmanager.member.Email; + +@Converter(autoApply = true) +public class EmailConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(Email email) { + return email != null ? email.address() : null; + } + + @Override + public Email convertToEntityAttribute(String address) { + return address != null ? Email.of(address) : null; + } +} diff --git a/adapter/persistence/src/main/java/me/chan99k/learningmanager/session/CustomSessionRepository.java b/adapter/persistence/src/main/java/me/chan99k/learningmanager/session/CustomSessionRepository.java new file mode 100644 index 00000000..3c7e0deb --- /dev/null +++ b/adapter/persistence/src/main/java/me/chan99k/learningmanager/session/CustomSessionRepository.java @@ -0,0 +1,72 @@ +package me.chan99k.learningmanager.session; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import me.chan99k.learningmanager.course.CourseRole; +import me.chan99k.learningmanager.session.dto.SessionInfo; +import me.chan99k.learningmanager.session.entity.SessionEntity; + +public interface CustomSessionRepository { + + Optional findManagedSessionById(Long sessionId, Long memberId, CourseRole courseRole); + + Page findAllWithFilters( + SessionType type, + SessionLocation location, + Instant startDate, + Instant endDate, + Pageable pageable + ); + + Page findByCourseIdWithFilters( + Long courseId, + SessionType type, + SessionLocation location, + Instant startDate, + Instant endDate, + Boolean includeChildSessions, + Pageable pageable + ); + + Page findByCurriculumIdWithFilters( + Long curriculumId, + SessionType type, + SessionLocation location, + Instant startDate, + Instant endDate, + Boolean includeChildSessions, + Pageable pageable + ); + + Page findByMemberIdWithFilters( + Long memberId, + SessionType type, + SessionLocation location, + Instant startDate, + Instant endDate, + Pageable pageable + ); + + List findByYearMonth( + Instant startOfMonth, + Instant startOfNextMonth, + SessionType type, + SessionLocation location, + Long courseId, + Long curriculumId + ); + + List findIdsByPeriodAndFilters( + Instant startDate, + Instant endDate, + Long courseId, + Long curriculumId + ); + + List findSessionInfoProjectionByIds(List sessionIds); +} diff --git a/adapter/persistence/src/main/java/me/chan99k/learningmanager/session/CustomSessionRepositoryImpl.java b/adapter/persistence/src/main/java/me/chan99k/learningmanager/session/CustomSessionRepositoryImpl.java new file mode 100644 index 00000000..8b869d1f --- /dev/null +++ b/adapter/persistence/src/main/java/me/chan99k/learningmanager/session/CustomSessionRepositoryImpl.java @@ -0,0 +1,236 @@ +package me.chan99k.learningmanager.session; + +import static me.chan99k.learningmanager.course.entity.QCourseEntity.*; +import static me.chan99k.learningmanager.course.entity.QCourseMemberEntity.*; +import static me.chan99k.learningmanager.course.entity.QCurriculumEntity.*; +import static me.chan99k.learningmanager.session.entity.QSessionEntity.*; +import static me.chan99k.learningmanager.session.entity.QSessionParticipantEntity.*; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; + +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import me.chan99k.learningmanager.course.CourseRole; +import me.chan99k.learningmanager.session.dto.SessionInfo; +import me.chan99k.learningmanager.session.entity.SessionEntity; + +public class CustomSessionRepositoryImpl implements CustomSessionRepository { + + private final JPAQueryFactory queryFactory; + + public CustomSessionRepositoryImpl(JPAQueryFactory queryFactory) { + this.queryFactory = queryFactory; + } + + @Override + public Optional findManagedSessionById(Long sessionId, Long memberId, CourseRole courseRole) { + SessionEntity result = queryFactory + .selectFrom(sessionEntity) + .leftJoin(courseEntity).on(sessionEntity.courseId.eq(courseEntity.id)) + .leftJoin(courseEntity.courseMemberList, courseMemberEntity) + .on(courseMemberEntity.courseRole.eq(courseRole)) + .where( + sessionEntity.id.eq(sessionId), + sessionEntity.courseId.isNull() + .or(courseMemberEntity.memberId.eq(memberId))) + .fetchOne(); + + return Optional.ofNullable(result); + } + + // ========== 동적 필터 + 페이징 (BooleanBuilder) ========== + + @Override + public Page findAllWithFilters(SessionType type, SessionLocation location, + Instant startDate, Instant endDate, Pageable pageable) { + + BooleanBuilder builder = createCommonFilterBuilder(type, location, startDate, endDate); + + List content = queryFactory + .selectFrom(sessionEntity) + .where(builder) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(sessionEntity.scheduledAt.desc()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(sessionEntity.count()) + .from(sessionEntity) + .where(builder); + + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } + + @Override + public Page findByCourseIdWithFilters(Long courseId, SessionType type, SessionLocation location, + Instant startDate, Instant endDate, Boolean includeChildSessions, Pageable pageable) { + + BooleanBuilder builder = createCommonFilterBuilder(type, location, startDate, endDate); + builder.and(sessionEntity.courseId.eq(courseId)); + + return findWithFiltersAndPaging(includeChildSessions, pageable, builder); + } + + @Override + public Page findByCurriculumIdWithFilters(Long curriculumId, SessionType type, + SessionLocation location, + Instant startDate, Instant endDate, Boolean includeChildSessions, Pageable pageable) { + + BooleanBuilder builder = createCommonFilterBuilder(type, location, startDate, endDate); + builder.and(sessionEntity.curriculumId.eq(curriculumId)); + + return findWithFiltersAndPaging(includeChildSessions, pageable, builder); + } + + @Override + public Page findByMemberIdWithFilters(Long memberId, SessionType type, SessionLocation location, + Instant startDate, Instant endDate, Pageable pageable) { + + BooleanBuilder builder = createCommonFilterBuilder(type, location, startDate, endDate); + + List content = queryFactory + .selectFrom(sessionEntity) + .join(sessionEntity.participants, sessionParticipantEntity) + .where( + builder, + sessionParticipantEntity.memberId.eq(memberId)) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(sessionEntity.scheduledAt.desc()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(sessionEntity.count()) + .from(sessionEntity) + .join(sessionEntity.participants, sessionParticipantEntity) + .where( + builder, + sessionParticipantEntity.memberId.eq(memberId)); + + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } + + // ========== 동적 필터 (페이징 없음) ========== + + @Override + public List findByYearMonth(Instant startOfMonth, Instant startOfNextMonth, + SessionType type, SessionLocation location, Long courseId, Long curriculumId) { + + BooleanBuilder builder = new BooleanBuilder(); + builder.and(sessionEntity.scheduledAt.goe(startOfMonth)); + builder.and(sessionEntity.scheduledAt.lt(startOfNextMonth)); + + if (type != null) { + builder.and(sessionEntity.type.eq(type)); + } + if (location != null) { + builder.and(sessionEntity.location.eq(location)); + } + if (courseId != null) { + builder.and(sessionEntity.courseId.eq(courseId)); + } + if (curriculumId != null) { + builder.and(sessionEntity.curriculumId.eq(curriculumId)); + } + + return queryFactory + .selectFrom(sessionEntity) + .where(builder) + .orderBy(sessionEntity.scheduledAt.asc()) + .fetch(); + } + + @Override + public List findIdsByPeriodAndFilters(Instant startDate, Instant endDate, Long courseId, Long curriculumId) { + BooleanBuilder builder = new BooleanBuilder(); + builder.and(sessionEntity.scheduledAt.gt(startDate)); + builder.and(sessionEntity.scheduledAt.lt(endDate)); + + if (courseId != null) { + builder.and(sessionEntity.courseId.eq(courseId)); + } + if (curriculumId != null) { + builder.and(sessionEntity.curriculumId.eq(curriculumId)); + } + + return queryFactory + .select(sessionEntity.id) + .from(sessionEntity) + .where(builder) + .fetch(); + } + + // ========== DTO Projection ========== + + @Override + public List findSessionInfoProjectionByIds(List sessionIds) { + return queryFactory + .select(Projections.constructor(SessionInfo.class, + sessionEntity.id, + sessionEntity.title, + sessionEntity.scheduledAt, + sessionEntity.courseId, + courseEntity.title, + sessionEntity.curriculumId, + curriculumEntity.title)) + .from(sessionEntity) + .leftJoin(courseEntity).on(sessionEntity.courseId.eq(courseEntity.id)) + .leftJoin(curriculumEntity).on(sessionEntity.curriculumId.eq(curriculumEntity.id)) + .where(sessionEntity.id.in(sessionIds)) + .fetch(); + } + + // ========== 프라이빗 메서드 ========== + + private BooleanBuilder createCommonFilterBuilder(SessionType type, SessionLocation location, + Instant startDate, Instant endDate) { + BooleanBuilder builder = new BooleanBuilder(); + + if (type != null) { + builder.and(sessionEntity.type.eq(type)); + } + if (location != null) { + builder.and(sessionEntity.location.eq(location)); + } + if (startDate != null) { + builder.and(sessionEntity.scheduledAt.goe(startDate)); + } + if (endDate != null) { + builder.and(sessionEntity.scheduledAt.loe(endDate)); + } + + return builder; + } + + private Page findWithFiltersAndPaging(Boolean includeChildSessions, Pageable pageable, + BooleanBuilder builder) { + if (!Boolean.TRUE.equals(includeChildSessions)) { + builder.and(sessionEntity.parent.isNull()); + } + + List content = queryFactory + .selectFrom(sessionEntity) + .where(builder) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(sessionEntity.scheduledAt.desc()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(sessionEntity.count()) + .from(sessionEntity) + .where(builder); + + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); + } +} diff --git a/adapter/persistence/src/main/java/me/chan99k/learningmanager/session/JpaSessionRepository.java b/adapter/persistence/src/main/java/me/chan99k/learningmanager/session/JpaSessionRepository.java index 9d363a88..205fcaa3 100644 --- a/adapter/persistence/src/main/java/me/chan99k/learningmanager/session/JpaSessionRepository.java +++ b/adapter/persistence/src/main/java/me/chan99k/learningmanager/session/JpaSessionRepository.java @@ -1,129 +1,16 @@ package me.chan99k.learningmanager.session; -import java.time.Instant; import java.util.List; -import java.util.Optional; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import me.chan99k.learningmanager.session.dto.SessionInfo; import me.chan99k.learningmanager.session.entity.SessionEntity; -public interface JpaSessionRepository extends JpaRepository { +public interface JpaSessionRepository extends JpaRepository, CustomSessionRepository { List findByCourseId(Long courseId); List findByCurriculumId(Long curriculumId); List findByParentId(Long parentId); - - @Query("SELECT s FROM SessionEntity s " + - "LEFT JOIN CourseEntity c ON s.courseId = c.id " + - "LEFT JOIN c.courseMemberList cm ON cm.courseRole = :courseRole " + - "WHERE s.id = :sessionId " + - "AND (s.courseId IS NULL OR cm.memberId = :memberId)") - Optional findManagedSessionById(@Param("sessionId") Long sessionId, - @Param("memberId") Long memberId, - @Param("courseRole") me.chan99k.learningmanager.course.CourseRole courseRole); - - @Query("SELECT s FROM SessionEntity s WHERE " + - "(:type IS NULL OR s.type = :type) AND " + - "(:location IS NULL OR s.location = :location) AND " + - "(:startDate IS NULL OR s.scheduledAt >= :startDate) AND " + - "(:endDate IS NULL OR s.scheduledAt <= :endDate)") - Page findAllWithFilters( - @Param("type") SessionType type, - @Param("location") SessionLocation location, - @Param("startDate") Instant startDate, - @Param("endDate") Instant endDate, - Pageable pageable - ); - - @Query("SELECT s FROM SessionEntity s WHERE " + - "s.courseId = :courseId AND " + - "(:includeChildSessions = true OR s.parent IS NULL) AND " + - "(:type IS NULL OR s.type = :type) AND " + - "(:location IS NULL OR s.location = :location) AND " + - "(:startDate IS NULL OR s.scheduledAt >= :startDate) AND " + - "(:endDate IS NULL OR s.scheduledAt <= :endDate)") - Page findByCourseIdWithFilters( - @Param("courseId") Long courseId, - @Param("type") SessionType type, - @Param("location") SessionLocation location, - @Param("startDate") Instant startDate, - @Param("endDate") Instant endDate, - @Param("includeChildSessions") Boolean includeChildSessions, - Pageable pageable - ); - - @Query("SELECT s FROM SessionEntity s WHERE " + - "s.curriculumId = :curriculumId AND " + - "(:includeChildSessions = true OR s.parent IS NULL) AND " + - "(:type IS NULL OR s.type = :type) AND " + - "(:location IS NULL OR s.location = :location) AND " + - "(:startDate IS NULL OR s.scheduledAt >= :startDate) AND " + - "(:endDate IS NULL OR s.scheduledAt <= :endDate)") - Page findByCurriculumIdWithFilters( - @Param("curriculumId") Long curriculumId, - @Param("type") SessionType type, - @Param("location") SessionLocation location, - @Param("startDate") Instant startDate, - @Param("endDate") Instant endDate, - @Param("includeChildSessions") Boolean includeChildSessions, - Pageable pageable - ); - - @Query("SELECT s FROM SessionEntity s " + - "JOIN s.participants p " + - "WHERE p.memberId = :memberId AND " + - "(:type IS NULL OR s.type = :type) AND " + - "(:location IS NULL OR s.location = :location) AND " + - "(:startDate IS NULL OR s.scheduledAt >= :startDate) AND " + - "(:endDate IS NULL OR s.scheduledAt <= :endDate)") - Page findByMemberIdWithFilters( - @Param("memberId") Long memberId, - @Param("type") SessionType type, - @Param("location") SessionLocation location, - @Param("startDate") Instant startDate, - @Param("endDate") Instant endDate, - Pageable pageable - ); - - @Query("SELECT s FROM SessionEntity s WHERE " + - "s.scheduledAt >= :startOfMonth AND s.scheduledAt < :startOfNextMonth AND " + - "(:type IS NULL OR s.type = :type) AND " + - "(:location IS NULL OR s.location = :location) AND " + - "(:courseId IS NULL OR s.courseId = :courseId) AND " + - "(:curriculumId IS NULL OR s.curriculumId = :curriculumId)") - List findByYearMonth( - @Param("startOfMonth") Instant startOfMonth, - @Param("startOfNextMonth") Instant startOfNextMonth, - @Param("type") SessionType type, - @Param("location") SessionLocation location, - @Param("courseId") Long courseId, - @Param("curriculumId") Long curriculumId - ); - - @Query("SELECT s.id FROM SessionEntity s WHERE " + - "s.scheduledAt > :startDate AND s.scheduledAt < :endDate AND " + - "(:courseId IS NULL OR s.courseId = :courseId) AND " + - "(:curriculumId IS NULL OR s.curriculumId = :curriculumId)") - List findIdsByPeriodAndFilters( - @Param("startDate") Instant startDate, - @Param("endDate") Instant endDate, - @Param("courseId") Long courseId, - @Param("curriculumId") Long curriculumId - ); - - @Query("SELECT new me.chan99k.learningmanager.session.dto.SessionInfo(" + - "s.id, s.title, s.scheduledAt, s.courseId, c.title, s.curriculumId, cur.title) " + - "FROM SessionEntity s " + - "LEFT JOIN CourseEntity c ON s.courseId = c.id " + - "LEFT JOIN CurriculumEntity cur ON s.curriculumId = cur.id " + - "WHERE s.id IN :sessionIds") - List findSessionInfoProjectionByIds(@Param("sessionIds") List sessionIds); } diff --git a/adapter/persistence/src/main/java/me/chan99k/learningmanager/session/SessionQueryAdapter.java b/adapter/persistence/src/main/java/me/chan99k/learningmanager/session/SessionQueryAdapter.java index bfafb37c..331ee583 100644 --- a/adapter/persistence/src/main/java/me/chan99k/learningmanager/session/SessionQueryAdapter.java +++ b/adapter/persistence/src/main/java/me/chan99k/learningmanager/session/SessionQueryAdapter.java @@ -113,8 +113,10 @@ public PageResult findByMemberIdWithFilters(Long memberId, SessionType } @Override - public List findByYearMonth(YearMonth yearMonth, SessionType type, SessionLocation location, - Long courseId, Long curriculumId) { + public List findByYearMonth( + YearMonth yearMonth, SessionType type, SessionLocation location, + Long courseId, Long curriculumId + ) { LocalDate startOfMonth = yearMonth.atDay(1); LocalDate startOfNextMonth = yearMonth.plusMonths(1).atDay(1); @@ -128,8 +130,10 @@ public List findByYearMonth(YearMonth yearMonth, SessionType type, Sess } @Override - public List findSessionIdsByPeriodAndFilters(Instant startDate, Instant endDate, Long courseId, - Long curriculumId) { + public List findSessionIdsByPeriodAndFilters( + Instant startDate, Instant endDate, Long courseId, + Long curriculumId + ) { return jpaRepository.findIdsByPeriodAndFilters(startDate, endDate, courseId, curriculumId); } diff --git a/adapter/persistence/src/test/java/me/chan99k/learningmanager/config/TestJpaConfig.java b/adapter/persistence/src/test/java/me/chan99k/learningmanager/config/TestJpaConfig.java index b4866e87..d478fb3a 100644 --- a/adapter/persistence/src/test/java/me/chan99k/learningmanager/config/TestJpaConfig.java +++ b/adapter/persistence/src/test/java/me/chan99k/learningmanager/config/TestJpaConfig.java @@ -7,6 +7,10 @@ import org.springframework.data.domain.AuditorAware; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import jakarta.persistence.EntityManager; + @TestConfiguration @EnableJpaAuditing public class TestJpaConfig { @@ -14,4 +18,9 @@ public class TestJpaConfig { public AuditorAware auditorAware() { return () -> Optional.of(1L); } -} \ No newline at end of file + + @Bean + public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) { + return new JPAQueryFactory(entityManager); + } +} diff --git a/build-logic/src/main/kotlin/lm.java-jpa.gradle.kts b/build-logic/src/main/kotlin/lm.java-jpa.gradle.kts index 04e934c6..db23fb98 100644 --- a/build-logic/src/main/kotlin/lm.java-jpa.gradle.kts +++ b/build-logic/src/main/kotlin/lm.java-jpa.gradle.kts @@ -12,6 +12,12 @@ dependencies { "runtimeOnly"(catalog.findLibrary("mysql-connector").get()) "runtimeOnly"(catalog.findLibrary("h2").get()) + // QueryDSL + "implementation"(variantOf(catalog.findLibrary("querydsl-jpa").get()) { classifier("jakarta") }) + "annotationProcessor"(variantOf(catalog.findLibrary("querydsl-apt").get()) { classifier("jakarta") }) + "annotationProcessor"("jakarta.annotation:jakarta.annotation-api") + "annotationProcessor"("jakarta.persistence:jakarta.persistence-api") + "testImplementation"(catalog.findLibrary("testcontainers-mysql").get()) "testImplementation"(catalog.findLibrary("testcontainers-junit").get()) } diff --git a/docs/Prd_v0_0_7.md b/docs/Prd_v0_0_9.md similarity index 89% rename from docs/Prd_v0_0_7.md rename to docs/Prd_v0_0_9.md index a017324a..b0a97bff 100644 --- a/docs/Prd_v0_0_7.md +++ b/docs/Prd_v0_0_9.md @@ -1,6 +1,6 @@ # 제품 요구사항 명세서 (PRD): Learning Manager -> v.0.0.7 | 25.12.04 +> v.0.0.9 | 25.12.14 --- ## 1. 개요 (Overview) @@ -33,7 +33,7 @@ ### 언어 및 프레임워크 -- **Java**: 17 +- **Java**: 21 - **Spring Boot**: 3 ### 데이터베이스 @@ -42,6 +42,12 @@ - **MongoDB**: 출석 시스템용 NoSQL DB (이벤트 소싱 기반 출석 데이터) - **H2**: 테스트용 인메모리 RDB +### 데이터 접근 계층 + +- **Spring Data JPA**: 기본 CRUD 및 단순 쿼리 +- **QueryDSL 5.1**: 타입 안전한 동적 쿼리 (복잡한 조건 검색, DTO Projection) +- **Spring Data MongoDB**: MongoDB 접근 (Criteria API 기반 동적 쿼리) + --- ## 4. 핵심 기능 명세 @@ -249,9 +255,9 @@ - `save()`: 회원 정보 변경 사항을 DB에 저장(또는 수정)합니다. - `saveHistory()`: 회원 상태 변경 이력을 DB에 기록합니다. -#### 6.1.5 `MemberStatusHistory` : 회원 상태 변경 이력 (미구현) +#### 6.1.5 `MemberStatusHistory` : 회원 상태 변경 이력 (부분 구현) -> ⚠️ **현재 미구현**: 도메인 모델은 정의되어 있으나, 실제 상태 변경 시 이력 기록 로직이 구현되지 않음 +> ⚠️ **부분 구현**: 도메인 모델은 정의되어 있으나, 실제 상태 변경 시 이력 기록 로직이 구현되지 않음 > > 회원의 상태가 변경될 때마다 해당 기록을 저장하여 추적합니다. @@ -259,7 +265,7 @@ - **status** (MemberStatus), **reason** (String) - **changedAt** (Instant) -**구현 계획**: Member 상태 변경 메서드에서 자동으로 이력 기록하는 로직 추가 필요 +**구현 상태**: 도메인 모델 정의 완료. 이력 자동 기록 로직 구현 필요 ### 6.2 Course @@ -403,6 +409,22 @@ - **LATE**: 지각 (추후 확장) - **LEFT_EARLY**: 조퇴 (추후 확장) +#### 6.4.4 출석 수정 기능 (Attendance Correction) + +> 출석 기록에 오류가 있을 경우, 수강생이 수정을 요청하고 관리자가 승인/거절하는 워크플로우를 지원합니다. + +##### provides + +- `requestCorrection()`: 수강생이 출석 수정을 요청합니다. +- `approveCorrection()`: 관리자가 출석 수정 요청을 승인합니다. +- `rejectCorrection()`: 관리자가 출석 수정 요청을 거절합니다. + +##### 구현 상태 + +- ✅ 출석 수정 요청 API (`POST /api/v1/attendance/{attendanceId}/correction`) +- ✅ 출석 수정 승인 API (`POST /api/v1/attendance/{attendanceId}/correction/approve`) +- ✅ 출석 수정 거절 API (`POST /api/v1/attendance/{attendanceId}/correction/reject`) + ### 6.5 Auth #### 6.5.1 `RefreshToken` : 리프레시 토큰 @@ -452,7 +474,38 @@ --- -### 6.6 Notification (미구현) +### 6.6 Admin (시스템 관리) + +#### 6.6.1 `SystemRole` : 시스템 역할 + +> 시스템 전역에서 사용자의 권한을 정의합니다. 역할은 계층 구조를 가집니다. + +- **ADMIN** (Tier 1): 최고 관리자 - 모든 시스템 기능 접근 가능 +- **SUPERVISOR** (Tier 2): 감독자 - 일부 관리 기능 접근 가능 +- **MANAGER** (Tier 3): 매니저 - 과정 관리 기능 접근 가능 +- **MENTEE** (Tier 4): 멘티 - 기본 사용자 권한 + +##### 역할 다중화 (Role Multiplexing) + +- 한 사용자가 여러 SystemRole을 가질 수 있습니다. +- 역할은 `member_system_role` 테이블에서 별도로 관리됩니다. + +##### provides + +- `grantRole()`: 특정 회원에게 시스템 역할을 부여합니다. +- `revokeRole()`: 특정 회원의 시스템 역할을 회수합니다. +- `getRoles()`: 특정 회원의 모든 시스템 역할을 조회합니다. + +##### 구현 상태 + +- ✅ 역할 부여 API (`POST /api/v1/admin/members/{memberId}/roles`) +- ✅ 역할 회수 API (`DELETE /api/v1/admin/members/{memberId}/roles/{role}`) +- ✅ 역할 조회 API (`GET /api/v1/admin/members/{memberId}/roles`) +- ✅ 권한 에스컬레이션 방지 (SUPERVISOR는 ADMIN 부여 불가) + +--- + +### 6.7 Notification (미구현) #### 6.6.1 `Notification` : 알림 (미구현) @@ -476,4 +529,4 @@ - 세션 시작 알림 - 과정 멤버 초대 알림 ---- \ No newline at end of file +--- diff --git a/docs/Usecases_v0_0_7.md b/docs/Usecases_v0_0_9.md similarity index 80% rename from docs/Usecases_v0_0_7.md rename to docs/Usecases_v0_0_9.md index 568f8a03..02e86d22 100644 --- a/docs/Usecases_v0_0_7.md +++ b/docs/Usecases_v0_0_9.md @@ -1,13 +1,15 @@ # 유스케이스 명세 (Use Case Specification) -> v.0.0.7 | 25.12.04
+> v.0.0.9 | 25.12.14
> 이 문서는 Learning Manager 시스템의 유스케이스를 도메인 경계별로 정의하고, 각 도메인 내에서 구현 우선순위에 따라 정렬합니다. +> +> **범례**: ✅ 구현 완료 | ⚠️ 부분 구현 | ❌ 미구현 --- ## 1. Member -### 1.1 [P0] 사용자 가입 (Member Registration) +### 1.1 [P0] ✅ 사용자 가입 (Member Registration) - **주요 액터(Actor):** 사용자(게스트), 시스템 - **사전 조건:** 사용자는 시스템에 로그인되어 있지 않은 상태이다. @@ -27,7 +29,7 @@ - **(유효하지 않은 인증 링크):** 인증 링크가 만료되었거나 유효하지 않을 경우, "유효하지 않은 인증 링크입니다."라는 메시지를 반환한다. - **연관 도메인:** Notification, Account (서브 도메인) -### 1.2 [P0] 사용자 로그인 (Member Login) +### 1.2 [P0] ✅ 사용자 로그인 (Member Login) - **주요 액터(Actor):** 사용자(게스트) - **사전 조건:** 사용자는 시스템에 가입되어 있고 `ACTIVE` 상태이다. @@ -43,7 +45,7 @@ - **(비활성 계정):** 아직 이메일 인증을 완료하지 않은 `PENDING` 상태의 계정일 경우, "계정 활성화가 필요합니다."라는 메시지를 반환한다. - **연관 도메인:** Account (서브 도메인), Auth -### 1.3 [P1] 비밀번호 재설정 (Password Reset) +### 1.3 [P1] ✅ 비밀번호 재설정 (Password Reset) - **주요 액터(Actor):** 사용자(게스트) - **사전 조건:** 사용자가 자신의 비밀번호를 잊었다. @@ -65,7 +67,7 @@ - 비밀번호 재설정 시 동일한 비밀번호 방지 - **연관 도메인:** Notification, Account (서브 도메인) -### 1.4 [P1] 프로필 수정 (Profile Update) +### 1.4 [P1] ✅ 프로필 수정 (Profile Update) - **주요 액터(Actor):** 사용자(회원) - **사전 조건:** 회원은 시스템에 로그인되어 있다. @@ -78,7 +80,7 @@ - **(중복된 닉네임):** 이미 사용 중인 닉네임일 경우, "이미 사용 중인 닉네임입니다."라는 메시지를 반환한다. - **연관 도메인:** 없음 -### 1.5 [P1] 비밀번호 변경 (Password Change) +### 1.5 [P1] ✅ 비밀번호 변경 (Password Change) - **주요 액터(Actor):** 사용자(회원) - **사전 조건:** 회원은 시스템에 로그인되어 있다. @@ -90,7 +92,7 @@ - **(현재 비밀번호 불일치):** 현재 비밀번호가 일치하지 않을 경우, "현재 비밀번호가 일치하지 않습니다."라는 메시지를 반환한다. - **연관 도메인:** Account (서브 도메인) -### 1.6 [P3] 인증 수단 추가 (Account Addition) +### 1.6 [P3] ❌ 인증 수단 추가 (Account Addition) - **주요 액터(Actor):** 사용자(회원) - **사전 조건:** 회원은 시스템에 로그인되어 있다. @@ -102,7 +104,7 @@ - **(인증 수단 중복):** 추가하려는 인증 수단이 이미 다른 회원에게 등록된 경우, "이미 다른 사용자와 연동된 계정입니다."라는 메시지를 반환한다. - **연관 도메인:** Account (서브 도메인) -### 1.7 [P2] 회원 탈퇴 (Member Withdrawal) +### 1.7 [P2] ✅ 회원 탈퇴 (Member Withdrawal) - **주요 액터(Actor):** 사용자(회원) - **사전 조건:** 회원은 시스템에 로그인되어 있다. @@ -113,7 +115,7 @@ - **(진행 중인 스터디 존재):** 회원이 스터디장으로 활동 중인 과정이 있을 경우, "스터디장 권한을 다른 멤버에게 위임한 후 탈퇴할 수 있습니다."라는 메시지를 반환한다. - **연관 도메인:** Course -### 1.8 [P2] 회원 상태 변경 (관리자) (Member Status Change by Admin) +### 1.8 [P2] ✅ 회원 상태 변경 (관리자) (Member Status Change by Admin) - **주요 액터(Actor):** 사용자(관리자) - **사전 조건:** 관리자는 시스템에 로그인되어 있다. @@ -129,7 +131,7 @@ ## 2. Course -### 2.1 [P0] 스터디 과정 생성 (Course Creation) +### 2.1 [P0] ✅ 스터디 과정 생성 (Course Creation) - **주요 액터(Actor):** 사용자(스터디장) - **사전 조건:** 스터디장은 로그인되어 있으며, 스터디 개설 권한을 가지고 있다. @@ -141,7 +143,7 @@ - **(권한 없음):** 스터디 개설 권한이 없는 사용자가 생성을 시도할 경우, "권한이 없습니다."라는 메시지를 반환한다. - **연관 도메인:** Member -### 2.2 [P0] 스터디 과정에 멤버 추가 (Add Member to Course) +### 2.2 [P0] ✅ 스터디 과정에 멤버 추가 (Add Member to Course) - **주요 액터(Actor):** 사용자(스터디장) - **사전 조건:** 스터디장은 로그인되어 있으며, 멤버를 추가할 과정을 관리하고 있다. @@ -156,7 +158,7 @@ - **(존재하지 않는 회원):** 추가하려는 회원이 시스템에 존재하지 않을 경우, "존재하지 않는 회원입니다."라는 메시지를 반환한다. - **연관 도메인:** Member, Notification -### 2.4 [P1] 스터디 과정 정보 수정 (Course Update) +### 2.4 [P1] ✅ 스터디 과정 정보 수정 (Course Update) - **주요 액터(Actor):** 사용자(스터디장) - **사전 조건:** 스터디장은 로그인되어 있으며, 수정할 스터디 과정이 존재한다. @@ -168,7 +170,7 @@ - **(권한 없음):** 해당 과정의 스터디장이 아닌 사용자가 수정을 시도할 경우, "권한이 없습니다."라는 메시지를 반환한다. - **연관 도메인:** 없음 -### 2.6 [P1] 커리큘럼 생성 (Curriculum Creation) +### 2.6 [P1] ✅ 커리큘럼 생성 (Curriculum Creation) - **주요 액터(Actor):** 사용자(스터디장) - **사전 조건:** 스터디장은 로그인되어 있으며, 자신이 관리하는 스터디 과정이 존재한다. @@ -179,7 +181,7 @@ - **(권한 없음):** 해당 과정의 스터디장이 아닌 사용자가 생성을 시도할 경우, "권한이 없습니다."라는 메시지를 반환한다. - **연관 도메인:** 없음 -### 2.7 [P1] 커리큘럼 정보 수정 (Curriculum Update) +### 2.7 [P1] ✅ 커리큘럼 정보 수정 (Curriculum Update) - **주요 액터(Actor):** 사용자(스터디장) - **사전 조건:** 스터디장은 로그인되어 있으며, 수정할 커리큘럼이 존재한다. @@ -190,7 +192,7 @@ - **(권한 없음):** 해당 과정의 스터디장이 아닌 사용자가 수정을 시도할 경우, "권한이 없습니다."라는 메시지를 반환한다. - **연관 도메인:** 없음 -### 2.8 [P2] 스터디 과정 멤버 제외 (Course Member Removal) +### 2.8 [P2] ✅ 스터디 과정 멤버 제외 (Course Member Removal) - **주요 액터(Actor):** 사용자(스터디장) - **사전 조건:** 스터디장은 로그인되어 있으며, 제외할 멤버가 과정에 참여 중이다. @@ -201,7 +203,7 @@ - **(권한 없음):** 해당 과정의 스터디장이 아닌 사용자가 제외를 시도할 경우, "권한이 없습니다."라는 메시지를 반환한다. - **연관 도메인:** Member, Notification -### 2.9 [P2] 커리큘럼 삭제 (Curriculum Deletion) +### 2.9 [P2] ✅ 커리큘럼 삭제 (Curriculum Deletion) - **주요 액터(Actor):** 사용자(스터디장) - **사전 조건:** 스터디장은 로그인되어 있으며, 삭제할 커리큘럼이 존재한다. @@ -213,7 +215,7 @@ - **(하위 세션 존재):** 삭제하려는 커리큘럼에 세션이 존재할 경우, "하위 세션이 존재하여 삭제할 수 없습니다."라는 메시지를 반환한다. - **연관 도메인:** Session -### 2.10 [P2] 스터디 과정 삭제 (Course Deletion) +### 2.10 [P2] ✅ 스터디 과정 삭제 (Course Deletion) - **주요 액터(Actor):** 사용자(스터디장) - **사전 조건:** 스터디장은 로그인되어 있으며, 삭제할 과정의 관리자이다. @@ -229,7 +231,7 @@ ## 3. Session -### 3.1 [P0] 스터디 세션 생성 (Session Creation) +### 3.1 [P0] ✅ 스터디 세션 생성 (Session Creation) - **주요 액터(Actor):** 사용자(스터디장), 시스템 관리자 - **사전 조건:** @@ -249,7 +251,7 @@ - **(권한 없음):** 스터디장이 권한 없는 과정/커리큘럼에 세션 생성을 시도할 경우, "권한이 없습니다." 메시지를 반환한다. - **연관 도메인:** Course -### 3.2 [P1] 스터디 세션 정보 수정 (Session Update) +### 3.2 [P1] ✅ 스터디 세션 정보 수정 (Session Update) - **주요 액터(Actor):** 사용자(스터디장) - **사전 조건:** 스터디장은 로그인되어 있으며, 수정할 세션이 존재한다. @@ -262,7 +264,7 @@ - **시간 검증:** 모든 시간 관련 검증은 Clock을 사용하여 Asia/Seoul 시간대 기준으로 수행된다. - **연관 도메인:** 없음 -### 3.3 [P2] 스터디 세션 참여자 관리 (Session Participant Management) +### 3.3 [P2] ✅ 스터디 세션 참여자 관리 (Session Participant Management) - **주요 액터(Actor):** 사용자(스터디장) - **사전 조건:** 스터디장은 로그인되어 있으며, 관리할 세션이 존재한다. @@ -289,7 +291,7 @@ - 존재하지 않는 역할을 지정하는 경우, "올바르지 않은 참여자 역할입니다." 메시지를 반환한다. - **연관 도메인:** Member -### 3.4 [P2] 스터디 세션 삭제 (Session Deletion) +### 3.4 [P2] ✅ 스터디 세션 삭제 (Session Deletion) - **주요 액터(Actor):** 사용자(스터디장) - **사전 조건:** 스터디장은 로그인되어 있으며, 삭제할 세션이 존재한다. @@ -300,7 +302,7 @@ - **(권한 없음):** 해당 과정의 스터디장이 아닌 사용자가 삭제를 시도할 경우, "권한이 없습니다."라는 메시지를 반환한다. - **연관 도메인:** 없음 -### 3.5 [P0] 스터디 세션 목록 조회 (Session List Retrieval) +### 3.5 [P0] ✅ 스터디 세션 목록 조회 (Session List Retrieval) - **주요 액터(Actor):** 사용자(전체 회원, 스터디장) - **사전 조건:** 사용자는 시스템에 로그인되어 있다. @@ -314,7 +316,7 @@ - **(잘못된 파라미터):** 잘못된 필터 값이나 페이징 파라미터가 전달된 경우, 관련 오류 메시지를 반환한다. - **연관 도메인:** Session -### 3.6 [P0] 과정별 세션 목록 조회 (Course Session List Retrieval) +### 3.6 [P0] ✅ 과정별 세션 목록 조회 (Course Session List Retrieval) - **주요 액터(Actor):** 사용자(과정 참여자, 스터디장) - **사전 조건:** 사용자는 시스템에 로그인되어 있으며, 조회할 과정이 존재한다. @@ -329,7 +331,7 @@ - **(접근 권한 없음):** 비공개 과정에 대해 권한이 없는 사용자가 요청할 경우, "접근 권한이 없습니다." 메시지를 반환한다. - **연관 도메인:** Course, Session -### 3.7 [P0] 커리큘럼별 세션 목록 조회 (Curriculum Session List Retrieval) +### 3.7 [P0] ✅ 커리큘럼별 세션 목록 조회 (Curriculum Session List Retrieval) - **주요 액터(Actor):** 사용자(과정 참여자, 스터디장) - **사전 조건:** 사용자는 시스템에 로그인되어 있으며, 조회할 커리큘럼이 존재한다. @@ -348,7 +350,7 @@ ## 4. Attendance -### 4.1 [P0] 출석 기록 (Attendance Record) +### 4.1 [P0] ✅ 출석 기록 (Attendance Record) - **주요 액터(Actor):** 사용자(스터디 멤버) - **사전 조건:** 스터디 멤버는 로그인되어 있으며, 출석할 세션의 참여자이다. @@ -372,7 +374,7 @@ - **(이미 퇴실 완료):** 이미 퇴실한 멤버가 다시 퇴실을 시도할 경우, `NOT_CHECKED_IN` 오류 메시지를 반환한다. - **연관 도메인:** Member, Session -### 4.2 [P1] 출석 인증 수단 생성 (Attendance Token Generation) +### 4.2 [P1] ✅ 출석 인증 수단 생성 (Attendance Token Generation) - **주요 액터(Actor):** 사용자(스터디장) - **사전 조건:** 스터디장은 로그인되어 있으며, 출석 체크할 세션이 존재한다. @@ -384,7 +386,7 @@ - **(권한 없음):** 해당 과정의 스터디장이 아닌 사용자가 요청할 경우, "권한이 없습니다."라는 메시지를 반환한다. - **연관 도메인:** Session -### 4.3 [P1] 출석 현황 조회 (Attendance Status View) +### 4.3 [P1] ✅ 출석 현황 조회 (Attendance Status View) - **주요 액터(Actor):** 사용자(스터디 멤버, 스터디장) - **사전 조건:** 사용자는 로그인되어 있다. @@ -397,11 +399,53 @@ - **(권한 없음):** 과정에 참여하지 않은 사용자가 조회를 시도할 경우, "권한이 없습니다."라는 메시지를 반환한다. - **연관 도메인:** Member, Course, Session +### 4.4 [P1] ✅ 출석 수정 요청 (Attendance Correction Request) + +- **주요 액터(Actor):** 사용자(스터디 멤버) +- **사전 조건:** 스터디 멤버는 로그인되어 있으며, 수정할 출석 기록이 존재한다. +- **API 엔드포인트:** `POST /api/v1/attendance/{attendanceId}/correction` +- **성공 시나리오:** + 1. 스터디 멤버가 출석 기록 수정을 요청한다. + 2. 시스템은 수정 요청 이벤트를 출석 기록에 추가한다. + 3. 시스템은 관리자에게 검토가 필요함을 알린다. + +- **실패 시나리오:** + - **(권한 없음):** 본인의 출석 기록이 아닌 경우, "권한이 없습니다."라는 메시지를 반환한다. + - **(이미 수정 요청 중):** 이미 수정 요청이 진행 중인 경우, 오류 메시지를 반환한다. +- **연관 도메인:** Member, Session + +### 4.5 [P1] ✅ 출석 수정 승인 (Attendance Correction Approval) + +- **주요 액터(Actor):** 사용자(스터디장, 관리자) +- **사전 조건:** 관리자는 로그인되어 있으며, 승인할 수정 요청이 존재한다. +- **API 엔드포인트:** `POST /api/v1/attendance/{attendanceId}/correction/approve` +- **성공 시나리오:** + 1. 관리자가 출석 수정 요청을 검토하고 승인한다. + 2. 시스템은 출석 상태를 요청된 상태로 변경한다. + 3. 시스템은 승인 이벤트를 기록한다. + +- **실패 시나리오:** + - **(권한 없음):** 해당 과정의 관리자가 아닌 경우, "권한이 없습니다."라는 메시지를 반환한다. +- **연관 도메인:** Member, Session + +### 4.6 [P1] ✅ 출석 수정 거절 (Attendance Correction Rejection) + +- **주요 액터(Actor):** 사용자(스터디장, 관리자) +- **사전 조건:** 관리자는 로그인되어 있으며, 거절할 수정 요청이 존재한다. +- **API 엔드포인트:** `POST /api/v1/attendance/{attendanceId}/correction/reject` +- **성공 시나리오:** + 1. 관리자가 출석 수정 요청을 검토하고 거절한다. + 2. 시스템은 거절 사유와 함께 거절 이벤트를 기록한다. + +- **실패 시나리오:** + - **(권한 없음):** 해당 과정의 관리자가 아닌 경우, "권한이 없습니다."라는 메시지를 반환한다. +- **연관 도메인:** Member, Session + --- ## 5. Auth -### 5.1 [P0] 액세스 토큰 갱신 (Refresh Access Token) +### 5.1 [P0] ✅ 액세스 토큰 갱신 (Refresh Access Token) - **주요 액터(Actor):** 사용자(회원), 클라이언트 애플리케이션 - **사전 조건:** 사용자는 유효한 Refresh Token을 보유하고 있다. @@ -418,7 +462,7 @@ - **(존재하지 않는 토큰):** DB에 해당 Refresh Token이 없는 경우, "유효하지 않은 토큰입니다."라는 메시지를 반환한다. - **연관 도메인:** Member -### 5.2 [P0] 로그아웃 (Token Revocation) +### 5.2 [P0] ✅ 로그아웃 (Token Revocation) - **주요 액터(Actor):** 사용자(회원) - **사전 조건:** 사용자는 로그인된 상태이다. @@ -436,7 +480,7 @@ - Access Token은 Stateless이므로 즉시 무효화되지 않음 - 짧은 Access Token TTL 설정으로 보안 리스크 최소화 -### 5.3 [P2] 전체 세션 로그아웃 (Revoke All Tokens) +### 5.3 [P2] ✅ 전체 세션 로그아웃 (Revoke All Tokens) - **주요 액터(Actor):** 사용자(회원) - **사전 조건:** 사용자는 로그인된 상태이다. @@ -449,13 +493,63 @@ - **실패 시나리오:** - 특별한 실패 시나리오 없음 (권한 검증은 Access Token으로 수행) - **연관 도메인:** Member -- **구현 상태:** ⚠️ 미구현 +- **구현 상태:** ✅ 구현 완료 + +--- + +## 6. Admin (시스템 관리) + +### 6.1 [P1] ✅ 시스템 역할 부여 (Grant System Role) + +- **주요 액터(Actor):** 사용자(ADMIN, SUPERVISOR) +- **사전 조건:** 관리자는 로그인되어 있으며, ADMIN 또는 SUPERVISOR 역할을 보유하고 있다. +- **API 엔드포인트:** `POST /api/v1/admin/members/{memberId}/roles` +- **성공 시나리오:** + 1. 관리자가 특정 회원에게 시스템 역할 부여를 요청한다. + 2. 시스템은 대상 회원의 존재 여부를 확인한다. + 3. 시스템은 권한 에스컬레이션 여부를 검증한다. (SUPERVISOR는 ADMIN 부여 불가) + 4. 시스템은 역할을 부여하고 201 Created를 반환한다. + +- **실패 시나리오:** + - **(권한 없음):** ADMIN 또는 SUPERVISOR가 아닌 사용자가 요청할 경우, 403 Forbidden을 반환한다. + - **(권한 에스컬레이션):** SUPERVISOR가 ADMIN 역할을 부여하려는 경우, 오류를 반환한다. + - **(회원 없음):** 대상 회원이 존재하지 않는 경우, 400 Bad Request를 반환한다. +- **연관 도메인:** Member + +### 6.2 [P1] ✅ 시스템 역할 회수 (Revoke System Role) + +- **주요 액터(Actor):** 사용자(ADMIN, SUPERVISOR) +- **사전 조건:** 관리자는 로그인되어 있으며, ADMIN 또는 SUPERVISOR 역할을 보유하고 있다. +- **API 엔드포인트:** `DELETE /api/v1/admin/members/{memberId}/roles/{role}` +- **성공 시나리오:** + 1. 관리자가 특정 회원의 시스템 역할 회수를 요청한다. + 2. 시스템은 대상 회원과 역할의 존재 여부를 확인한다. + 3. 시스템은 역할을 회수하고 204 No Content를 반환한다. + +- **실패 시나리오:** + - **(권한 없음):** ADMIN 또는 SUPERVISOR가 아닌 사용자가 요청할 경우, 403 Forbidden을 반환한다. + - **(회원 없음):** 대상 회원이 존재하지 않는 경우, 400 Bad Request를 반환한다. +- **연관 도메인:** Member + +### 6.3 [P1] ✅ 시스템 역할 조회 (Retrieve System Roles) + +- **주요 액터(Actor):** 사용자(ADMIN, SUPERVISOR) +- **사전 조건:** 관리자는 로그인되어 있으며, ADMIN 또는 SUPERVISOR 역할을 보유하고 있다. +- **API 엔드포인트:** `GET /api/v1/admin/members/{memberId}/roles` +- **성공 시나리오:** + 1. 관리자가 특정 회원의 시스템 역할 조회를 요청한다. + 2. 시스템은 대상 회원의 모든 시스템 역할을 반환한다. + +- **실패 시나리오:** + - **(권한 없음):** ADMIN 또는 SUPERVISOR가 아닌 사용자가 요청할 경우, 403 Forbidden을 반환한다. + - **(회원 없음):** 대상 회원이 존재하지 않는 경우, 400 Bad Request를 반환한다. +- **연관 도메인:** Member --- -## 6. Notification +## 7. Notification -### 6.1 [P4] 알림 발송 (Notification Send) +### 7.1 [P4] ❌ 알림 발송 (Notification Send) - **주요 액터(Actor):** 시스템 (이벤트 기반) - **사전 조건:** 알림을 발송해야 하는 특정 이벤트가 발생했다. (예: 과정 참여 승인, 세션 시작 임박, 멤버 초대) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 208427be..c532a400 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,9 @@ testcontainers = "1.19.0" # Code Coverage jacoco = "0.8.12" +# QueryDSL +querydsl = "5.1.0" + [libraries] # Spring Boot Starters spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" } @@ -90,6 +93,10 @@ angus-mail = { module = "org.eclipse.angus:angus-mail" } # Dotenv spring-dotenv = { module = "me.paulschwarz:spring-dotenv", version = "4.0.0" } +# QueryDSL +querydsl-jpa = { module = "com.querydsl:querydsl-jpa", version.ref = "querydsl" } +querydsl-apt = { module = "com.querydsl:querydsl-apt", version.ref = "querydsl" } + [plugins] spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" } spring-dependency-management = { id = "io.spring.dependency-management", version.ref = "spring-dependency-management" }