Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Learning Manager

> **v0.0.7** | 스터디 과정 관리 및 출석 시스템
> **v0.0.9** | 스터디 과정 관리 및 출석 시스템

회원의 온/오프라인 스터디 과정을 지원하는 백엔드 서비스입니다. 출석 관리, 일정 관리 등 반복적이고 비효율적인 업무를 자동화하여 **학습 과정 자체에 집중할 수 있는 인프라**를 제공합니다.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -163,7 +164,7 @@ learning-manager/

### 사전 요구사항

- Java 17+
- Java 21+
- Docker & Docker Compose
- Gradle 8.x

Expand Down Expand Up @@ -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/) | 시퀀스 다이어그램 |
Expand Down
2 changes: 1 addition & 1 deletion adapter/persistence/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ plugins {
dependencies {
implementation(project(":core:domain"))
implementation(project(":core:requires"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,4 +28,8 @@ public SystemRoleHierarchy systemRoleHierarchy() {
return new SystemRoleHierarchy();
}

@Bean
public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) {
return new JPAQueryFactory(entityManager);
}
}
Original file line number Diff line number Diff line change
@@ -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<CourseMemberInfo> findCourseMembersByCourseId(Long courseId, Pageable pageable);

// === Entity 조회 ===
Optional<CourseEntity> findManagedCourseById(Long courseId, Long memberId);

List<CourseEntity> findManagedCoursesByMemberId(Long memberId);

List<CourseEntity> findParticipatingCoursesByMemberId(Long memberId);

// === Projection 조회 ===
List<CourseParticipationInfo> findParticipatingCoursesWithRoleByMemberId(Long memberId);

Optional<CourseDetailInfo> findCourseBasicDetailsById(Long courseId);

// === 인가(Authorization) 관련 쿼리 ===

boolean existsByMemberIdAndCourseIdAndRole(Long memberId, Long courseId, CourseRole role);

boolean existsByMemberIdAndCourseIdAndRoleIn(Long memberId, Long courseId, List<CourseRole> roles);

boolean existsByMemberIdAndCourseId(Long memberId, Long courseId);
}
Original file line number Diff line number Diff line change
@@ -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<CourseMemberInfo> findCourseMembersByCourseId(Long courseId, Pageable pageable) {
List<Tuple> 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<CourseMemberInfo> 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<Long> countQuery = queryFactory
.select(courseMemberEntity.count())
.from(courseMemberEntity)
.where(courseMemberEntity.course.id.eq(courseId));

return PageableExecutionUtils.getPage(courseMemberInfos, pageable, countQuery::fetchOne);
}

@Override
public Optional<CourseEntity> 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<CourseEntity> 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<CourseEntity> findParticipatingCoursesByMemberId(Long memberId) {
return queryFactory
.selectFrom(courseEntity)
.join(courseEntity.courseMemberList, courseMemberEntity)
.where(courseMemberEntity.memberId.eq(memberId))
.fetch();
}

@Override
public List<CourseParticipationInfo> 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<CourseDetailInfo> 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<CourseRole> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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<CourseEntity, Long> {
Optional<CourseEntity> 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<CourseEntity> 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<CourseEntity> findManagedCoursesByMemberId(@Param("memberId") Long memberId);

@Query("SELECT c FROM CourseEntity c JOIN c.courseMemberList cm " +
"WHERE cm.memberId = :memberId")
List<CourseEntity> 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<CourseParticipationInfo> findParticipatingCoursesWithRoleByMemberId(@Param("memberId") Long memberId);
public interface JpaCourseRepository extends JpaRepository<CourseEntity, Long>, 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<CourseDetailInfo> 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<CourseMemberInfo> 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<CourseRole> roles
);
Optional<CourseEntity> 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
);
}
Original file line number Diff line number Diff line change
@@ -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<MemberEmailPair> findMemberEmailPairs(List<Email> emails, Limit limit);

Optional<MemberEntity> findByAccountsEmail(String email);

}
Loading
Loading