diff --git a/docs/plan/#18-goal-task-stats/plan.md b/docs/plan/#18-goal-task-stats/plan.md index a3cddc6..ba68393 100644 --- a/docs/plan/#18-goal-task-stats/plan.md +++ b/docs/plan/#18-goal-task-stats/plan.md @@ -19,4 +19,4 @@ - [x] 4단계: Infrastructure — ExposedTaskRepository에 집계 쿼리 구현 - [x] 5단계: Presentation — GoalTaskStatsDataLoader + GoalTaskStatsDataFetcher 구현 - [x] 6단계: 전체 테스트 통과 확인 및 검증 -- [ ] 7단계: 리팩토링 — 집계 로직을 Infrastructure에서 Application으로 이동 +- [x] 7단계: 리팩토링 — 집계 로직을 Infrastructure에서 Application으로 이동 diff --git a/docs/plan/#36-me-query/checklist.md b/docs/plan/#36-me-query/checklist.md new file mode 100644 index 0000000..eca73e9 --- /dev/null +++ b/docs/plan/#36-me-query/checklist.md @@ -0,0 +1,10 @@ +# 로그인 회원 본인 정보 조회 (me) Query 검증 체크리스트 + +## 필수 항목 +- [x] 아키텍처 원칙 준수 (docs/architecture.md 기준) +- [x] 레이어 의존성 규칙 위반 없음 +- [x] 테스트 코드 작성 완료 (Domain — 해당 없음, Application 필수) +- [x] 모든 테스트 통과 +- [x] 기존 테스트 깨지지 않음 +- [x] password 필드가 GraphQL 응답에 노출되지 않음 +- [x] DGS Codegen으로 GraphQL 타입 자동 생성 (수동 작성 없음) diff --git a/docs/plan/#36-me-query/plan.md b/docs/plan/#36-me-query/plan.md new file mode 100644 index 0000000..59347a6 --- /dev/null +++ b/docs/plan/#36-me-query/plan.md @@ -0,0 +1,13 @@ +# 로그인 회원 본인 정보 조회 (me) Query 구현 + +> Issue: #36 + +## 단계 + +- [x] 1단계: GraphQL 스키마에 Member 타입과 me Query 추가 +- [x] 2단계: DGS Codegen 실행하여 Member 타입 생성 +- [x] 3단계: Domain — MemberRepository에 findById 메서드 추가 (TDD) +- [x] 4단계: Application — AuthService에 getMe 메서드 추가 (TDD) +- [x] 5단계: Infrastructure — ExposedMemberRepository에 findById 구현 +- [x] 6단계: Presentation — AuthDataFetcher에 me Query 추가 +- [x] 7단계: 전체 테스트 통과 확인 diff --git a/src/main/kotlin/kr/io/team/loop/auth/application/service/AuthService.kt b/src/main/kotlin/kr/io/team/loop/auth/application/service/AuthService.kt index 26bd045..2d38141 100644 --- a/src/main/kotlin/kr/io/team/loop/auth/application/service/AuthService.kt +++ b/src/main/kotlin/kr/io/team/loop/auth/application/service/AuthService.kt @@ -1,9 +1,11 @@ package kr.io.team.loop.auth.application.service import kr.io.team.loop.auth.application.dto.AuthTokenDto +import kr.io.team.loop.auth.domain.model.Member import kr.io.team.loop.auth.domain.model.MemberCommand import kr.io.team.loop.auth.domain.repository.MemberRepository import kr.io.team.loop.common.config.JwtTokenProvider +import kr.io.team.loop.common.domain.MemberId import kr.io.team.loop.common.domain.exception.AuthenticationException import kr.io.team.loop.common.domain.exception.DuplicateEntityException import kr.io.team.loop.common.domain.exception.EntityNotFoundException @@ -39,4 +41,9 @@ class AuthService( val token = jwtTokenProvider.generateToken(member.id.value) return AuthTokenDto(accessToken = token) } + + @Transactional(readOnly = true) + fun getMe(memberId: MemberId): Member = + memberRepository.findById(memberId) + ?: throw EntityNotFoundException("Member not found: ${memberId.value}") } diff --git a/src/main/kotlin/kr/io/team/loop/auth/domain/repository/MemberRepository.kt b/src/main/kotlin/kr/io/team/loop/auth/domain/repository/MemberRepository.kt index 6941aa3..0d166ed 100644 --- a/src/main/kotlin/kr/io/team/loop/auth/domain/repository/MemberRepository.kt +++ b/src/main/kotlin/kr/io/team/loop/auth/domain/repository/MemberRepository.kt @@ -3,10 +3,13 @@ package kr.io.team.loop.auth.domain.repository import kr.io.team.loop.auth.domain.model.LoginId import kr.io.team.loop.auth.domain.model.Member import kr.io.team.loop.auth.domain.model.MemberCommand +import kr.io.team.loop.common.domain.MemberId interface MemberRepository { fun save(command: MemberCommand.Register): Member + fun findById(id: MemberId): Member? + fun findByLoginId(loginId: LoginId): Member? fun existsByLoginId(loginId: LoginId): Boolean diff --git a/src/main/kotlin/kr/io/team/loop/auth/infrastructure/persistence/ExposedMemberRepository.kt b/src/main/kotlin/kr/io/team/loop/auth/infrastructure/persistence/ExposedMemberRepository.kt index 5c434a2..3e3aacc 100644 --- a/src/main/kotlin/kr/io/team/loop/auth/infrastructure/persistence/ExposedMemberRepository.kt +++ b/src/main/kotlin/kr/io/team/loop/auth/infrastructure/persistence/ExposedMemberRepository.kt @@ -35,6 +35,13 @@ class ExposedMemberRepository : MemberRepository { ) } + override fun findById(id: MemberId): Member? = + MemberTable + .selectAll() + .where { MemberTable.memberId eq id.value } + .singleOrNull() + ?.toMember() + override fun findByLoginId(loginId: LoginId): Member? = MemberTable .selectAll() diff --git a/src/main/kotlin/kr/io/team/loop/auth/presentation/datafetcher/AuthDataFetcher.kt b/src/main/kotlin/kr/io/team/loop/auth/presentation/datafetcher/AuthDataFetcher.kt index dc35cf8..98c9ecb 100644 --- a/src/main/kotlin/kr/io/team/loop/auth/presentation/datafetcher/AuthDataFetcher.kt +++ b/src/main/kotlin/kr/io/team/loop/auth/presentation/datafetcher/AuthDataFetcher.kt @@ -2,19 +2,29 @@ package kr.io.team.loop.auth.presentation.datafetcher import com.netflix.graphql.dgs.DgsComponent import com.netflix.graphql.dgs.DgsMutation +import com.netflix.graphql.dgs.DgsQuery import com.netflix.graphql.dgs.InputArgument import kr.io.team.loop.auth.application.service.AuthService import kr.io.team.loop.auth.domain.model.LoginId +import kr.io.team.loop.auth.domain.model.Member import kr.io.team.loop.auth.domain.model.MemberCommand import kr.io.team.loop.auth.domain.model.Nickname import kr.io.team.loop.codegen.types.AuthToken import kr.io.team.loop.codegen.types.LoginInput import kr.io.team.loop.codegen.types.RegisterInput +import kr.io.team.loop.common.config.Authorize +import kr.io.team.loop.common.domain.MemberId +import kr.io.team.loop.codegen.types.Member as MemberGraphql @DgsComponent class AuthDataFetcher( private val authService: AuthService, ) { + @DgsQuery + fun me( + @Authorize memberId: Long, + ): MemberGraphql = authService.getMe(MemberId(memberId)).toGraphql() + @DgsMutation fun register( @InputArgument input: RegisterInput, @@ -41,4 +51,13 @@ class AuthDataFetcher( val result = authService.login(command) return AuthToken(accessToken = result.accessToken) } + + private fun Member.toGraphql(): MemberGraphql = + MemberGraphql( + id = id.value.toString(), + loginId = loginId.value, + nickname = nickname.value, + createdAt = createdAt.toString(), + updatedAt = updatedAt?.toString(), + ) } diff --git a/src/main/resources/schema/auth.graphqls b/src/main/resources/schema/auth.graphqls index 8e43258..3a6384c 100644 --- a/src/main/resources/schema/auth.graphqls +++ b/src/main/resources/schema/auth.graphqls @@ -1,3 +1,8 @@ +extend type Query { + "로그인된 회원 본인의 정보를 조회한다. 인증 필수." + me: Member! +} + extend type Mutation { "회원가입 후 액세스 토큰을 발급한다." register(input: RegisterInput!): AuthToken! @@ -29,3 +34,17 @@ input LoginInput { "비밀번호" password: String! } + +"회원 정보" +type Member { + "회원 고유 식별자" + id: ID! + "로그인 ID" + loginId: String! + "닉네임" + nickname: String! + "가입 일시 (ISO-8601)" + createdAt: String! + "최근 수정 일시 (ISO-8601, 수정 이력이 없으면 null)" + updatedAt: String +} diff --git a/src/test/kotlin/kr/io/team/loop/auth/application/service/AuthServiceTest.kt b/src/test/kotlin/kr/io/team/loop/auth/application/service/AuthServiceTest.kt index 92f97da..10a7c4b 100644 --- a/src/test/kotlin/kr/io/team/loop/auth/application/service/AuthServiceTest.kt +++ b/src/test/kotlin/kr/io/team/loop/auth/application/service/AuthServiceTest.kt @@ -122,4 +122,28 @@ class AuthServiceTest : } } } + + Given("내 정보 조회 시") { + When("존재하는 회원이면") { + every { memberRepository.findById(MemberId(1L)) } returns savedMember + + val result = authService.getMe(MemberId(1L)) + + Then("회원 정보를 반환한다") { + result.id shouldBe MemberId(1L) + result.loginId shouldBe LoginId("testuser") + result.nickname shouldBe Nickname("홍길동") + } + } + + When("존재하지 않는 회원이면") { + every { memberRepository.findById(MemberId(999L)) } returns null + + Then("예외가 발생한다") { + shouldThrow { + authService.getMe(MemberId(999L)) + } + } + } + } })