From 28d814f1836282ef16676a0d936fe6c100246b60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=EC=9A=B0=EC=84=9D=20=28Woosuk=20Kwon=29?= Date: Sun, 28 Jan 2024 12:01:07 +0900 Subject: [PATCH 1/4] Merge Develop To Main (#160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#148): add life-map view count * feat : Goal 갯수가 50개 이상일 때, 생성 요청을 보내는 경우 예외를 발생시킨다. (#151) * [🌎 Feature] X: 최근 로그인 시점 / Y: 사용자 수 Admin 그래프 통계 추가 (1) (#153) * feat(#150): UserLoginLog 엔티티 생성 * feat(#150): UserLoginLog Repository 추가 * feat(#150): 유저 로그인 시점 로그 추가 * test : Goal 갯수가 50개 이상일 때, 생성 요청을 보내는 경우 예외를 발생시키는 테스트 코드 작성 (#151) * refactor : LifeMap의 Goal 갯수 검증 책임을 Service가 아닌 LifeMap가 수행 (#151) * refactor : Goal에 Task를 추가할 때, Goal이 추가하도록 변경 (#151) * refactor : Goal에 Task를 추가할 때, Goal이 추가하도록 변경 (#151) * feature : Goal에 Task를 추가할 때 50개 이상인 경우 예외를 발생시킨다. (#151) * feat(#148): add history count * chore(#148): ktlint * chore: ktlint * ktlint formatting (#151) * refactor : Goal에 Task를 추가할 때 50개 이상인 경우 예외를 발생시키는 테스트 코드 작성 (#151) * [🌎 Feature] Goal 조회시 자신의 Goal인 경우 Public 검사 제외 코드 추가 (#158) * refactor : 유저가 조회하는 Goal의 자신의 Goal이 아닐 때만 LifeMap의 Public 여부를 확인할 (#157) * test : 유저가 조회하는 Goal의 자신의 Goal이 아닐 때만 LifeMap의 Public 여부를 확인하는 테스트 코드 작성 (#157) * ktlint formatting (#157) * [🌎 Feature] X: 최근 로그인 시점 / Y: 사용자 수 Admin 그래프 통계 추가 (#159) * feat(#150): UserLoginLog 엔티티 생성 * feat(#150): UserLoginLog Repository 추가 * feat(#150): 유저 로그인 시점 로그 추가 * fix: UserLoginLog 파일 명 수정 * feat(#150): active user 확인용 admin api 개발 * [🌎 Feature] X: 최근 로그인 시점 / Y: 사용자 수 Admin 그래프 통계 추가 (#161) * feat(#150): UserLoginLog 엔티티 생성 * feat(#150): UserLoginLog Repository 추가 * feat(#150): 유저 로그인 시점 로그 추가 * fix: UserLoginLog 파일 명 수정 * feat(#150): active user 확인용 admin api 개발 * feat(#150): Active User 통계 Admin UI 작업 * fix: apply klint * fix: Active User 관련 통계 문구 수정 --------- Co-authored-by: ManHyuk Co-authored-by: binary_ho --- admin-ui/src/client/dashboard.ts | 5 ++ .../page/index/dashboard-statistics.tsx | 74 +++++++++++----- .../admin/dashboard/DashboardService.kt | 32 +++++++ .../controller/DashboardController.kt | 3 - .../controller/response/DashboardResponse.kt | 5 +- .../admin/dashboard/dto/ActiveUserStatics.kt | 7 ++ .../api/auth/service/OAuth2UserService.kt | 4 +- .../kotlin/io/raemian/api/goal/GoalService.kt | 22 +++-- .../api/goal/controller/GoalController.kt | 3 +- .../io/raemian/api/lifemap/LifeMapService.kt | 56 ++++++++++-- .../lifemap/controller/LifeMapController.kt | 2 +- .../controller/OpenLifeMapController.kt | 15 +++- .../api/lifemap/domain/LifeMapCountDTO.kt | 17 ++++ .../raemian/api/lifemap/domain/LifeMapDTO.kt | 1 + .../api/lifemap/domain/LifeMapResponse.kt | 28 ++++-- .../io/raemian/api/log/UserLoginLogService.kt | 10 +-- .../io/raemian/api/support/error/ErrorInfo.kt | 14 +++ .../error/MaxGoalCountExceededException.kt | 5 ++ .../error/MaxTaskCountExceededException.kt | 5 ++ .../kotlin/io/raemian/api/task/TaskService.kt | 11 +++ .../io/raemian/api/user/domain/UserSubset.kt | 1 + .../api/integration/goal/GoalServiceTest.kt | 87 +++++++++++++++++-- .../integration/lifemap/LifeMapServiceTest.kt | 9 -- .../api/integration/task/TaskServiceTest.kt | 41 ++++++++- .../io/raemian/storage/db/core/goal/Goal.kt | 23 ++++- .../storage/db/core/lifemap/LifeMap.kt | 8 ++ .../storage/db/core/lifemap/LifeMapCount.kt | 33 ++++++- .../storage/db/core/lifemap/LifeMapHistory.kt | 27 ++++++ .../core/lifemap/LifeMapHistoryRepository.kt | 7 ++ .../log/{LogLoginLog.kt => UserLoginLog.kt} | 2 +- .../db/core/log/UserLoginLogRepository.kt | 2 + 31 files changed, 480 insertions(+), 79 deletions(-) create mode 100644 backend/application/admin/src/main/kotlin/io/raemian/admin/dashboard/dto/ActiveUserStatics.kt create mode 100644 backend/application/api/src/main/kotlin/io/raemian/api/lifemap/domain/LifeMapCountDTO.kt create mode 100644 backend/application/api/src/main/kotlin/io/raemian/api/support/error/MaxGoalCountExceededException.kt create mode 100644 backend/application/api/src/main/kotlin/io/raemian/api/support/error/MaxTaskCountExceededException.kt create mode 100644 backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/lifemap/LifeMapHistory.kt create mode 100644 backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/lifemap/LifeMapHistoryRepository.kt rename backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/log/{LogLoginLog.kt => UserLoginLog.kt} (99%) diff --git a/admin-ui/src/client/dashboard.ts b/admin-ui/src/client/dashboard.ts index 665bdf44..8cbfaad8 100644 --- a/admin-ui/src/client/dashboard.ts +++ b/admin-ui/src/client/dashboard.ts @@ -13,6 +13,11 @@ export interface IDashboard { total: number; todayIncrease: number; }; + activeUserStatics: { + perTodayPercent: number; + perWeekPercent: number; + perMonthPercent: number; + } } export interface IDashboardResponse { diff --git a/admin-ui/src/components/page/index/dashboard-statistics.tsx b/admin-ui/src/components/page/index/dashboard-statistics.tsx index 231e0058..8c0ec6b5 100644 --- a/admin-ui/src/components/page/index/dashboard-statistics.tsx +++ b/admin-ui/src/components/page/index/dashboard-statistics.tsx @@ -2,6 +2,7 @@ import { IDashboardResponse } from "@/client/dashboard"; import { ArrowUp } from "lucide-react"; import React from "react"; import CountUp from "react-countup"; +import {Divider} from "antd"; interface IDashboardStaticsProps { data: IDashboardResponse; @@ -20,39 +21,68 @@ const renderTodayIncrease = (value: number, unit: string) => { const DashboardStatistics = ({ data }: IDashboardStaticsProps) => { return ( - <> -
-
-
유저
-
+ <> +
+
+
유저
+
+
+
+ 명 +
+
{renderTodayIncrease(data.body.userStatics.todayIncrease, '명')}
+
+
+
+
+
목표
- 명 +
-
{renderTodayIncrease(data.body.userStatics.todayIncrease, '명')}
+
{renderTodayIncrease(data.body.goalStatics.todayIncrease, '건')}
-
-
-
목표
-
-
- 건 +
+
세부 목표
+
+
+ 건 +
+
{renderTodayIncrease(data.body.taskStatics.todayIncrease, '건')}
-
{renderTodayIncrease(data.body.goalStatics.todayIncrease, '건')}
-
-
세부 목표
-
-
- 건 + +
+
+
오늘 접속 유저 비율
+
+
+
+ {data.body.activeUserStatics.perTodayPercent}% +
+
+
+
+
+
최근 1주 접속 유저 비율
+
+
+ {data.body.activeUserStatics.perWeekPercent}% +
+
+
+
+
최근 1달 접속 유저 비율
+
+
+ {data.body.activeUserStatics.perMonthPercent}% +
-
{renderTodayIncrease(data.body.taskStatics.todayIncrease, '건')}
-
- + ); }; diff --git a/backend/application/admin/src/main/kotlin/io/raemian/admin/dashboard/DashboardService.kt b/backend/application/admin/src/main/kotlin/io/raemian/admin/dashboard/DashboardService.kt index 220d48f2..a4b973fe 100644 --- a/backend/application/admin/src/main/kotlin/io/raemian/admin/dashboard/DashboardService.kt +++ b/backend/application/admin/src/main/kotlin/io/raemian/admin/dashboard/DashboardService.kt @@ -1,11 +1,13 @@ package io.raemian.admin.dashboard import io.raemian.admin.dashboard.controller.response.DashboardResponse +import io.raemian.admin.dashboard.dto.ActiveUserStatics import io.raemian.admin.dashboard.dto.GoalStatics import io.raemian.admin.dashboard.dto.TaskStatics import io.raemian.admin.dashboard.dto.UserStatics import io.raemian.storage.db.core.goal.Goal import io.raemian.storage.db.core.goal.GoalRepository +import io.raemian.storage.db.core.log.UserLoginLogRepository import io.raemian.storage.db.core.task.Task import io.raemian.storage.db.core.task.TaskRepository import io.raemian.storage.db.core.user.User @@ -13,12 +15,14 @@ import io.raemian.storage.db.core.user.UserRepository import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDate +import kotlin.math.round @Service class DashboardService( private val userRepository: UserRepository, private val goalRepository: GoalRepository, private val taskRepository: TaskRepository, + private val userLoginLogRepository: UserLoginLogRepository, ) { @Transactional(readOnly = true) @@ -27,6 +31,7 @@ class DashboardService( getUserStatics(), getGoalStatics(), getTaskStatics(), + getActiveUserStatics(), ) } @@ -53,4 +58,31 @@ class DashboardService( return TaskStatics(taskCount, todayCreatedTasks.size) } + + private fun getActiveUserStatics(): ActiveUserStatics { + val userCount = userRepository.count() + + val activeUserCountPerToday = userLoginLogRepository + .countUserLoginLogByLatestLoginAtGreaterThanEqual(LocalDate.now().atStartOfDay()) + + val activeUserCountPerWeek = userLoginLogRepository + .countUserLoginLogByLatestLoginAtGreaterThanEqual(LocalDate.now().minusWeeks(1).atStartOfDay()) + + val activeUserCountPerMonth = userLoginLogRepository + .countUserLoginLogByLatestLoginAtGreaterThanEqual(LocalDate.now().minusMonths(1).atStartOfDay()) + + return ActiveUserStatics( + perTodayPercent = calculatePercent(activeUserCountPerToday, userCount), + perWeekPercent = calculatePercent(activeUserCountPerWeek, userCount), + perMonthPercent = calculatePercent(activeUserCountPerMonth, userCount), + ) + } + + private fun calculatePercent(numerator: Long, denominator: Long): Double { + if (denominator == 0L) { + return 0.0 + } + + return round(numerator.toDouble().div(denominator) * 10000) / 100 + } } diff --git a/backend/application/admin/src/main/kotlin/io/raemian/admin/dashboard/controller/DashboardController.kt b/backend/application/admin/src/main/kotlin/io/raemian/admin/dashboard/controller/DashboardController.kt index e16f6fe3..319304b8 100644 --- a/backend/application/admin/src/main/kotlin/io/raemian/admin/dashboard/controller/DashboardController.kt +++ b/backend/application/admin/src/main/kotlin/io/raemian/admin/dashboard/controller/DashboardController.kt @@ -7,9 +7,6 @@ import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController -import java.net.URI - -fun String.toUri(): URI = URI.create(this) @RestController @RequestMapping("/dashboard") diff --git a/backend/application/admin/src/main/kotlin/io/raemian/admin/dashboard/controller/response/DashboardResponse.kt b/backend/application/admin/src/main/kotlin/io/raemian/admin/dashboard/controller/response/DashboardResponse.kt index 08804287..74e37ffc 100644 --- a/backend/application/admin/src/main/kotlin/io/raemian/admin/dashboard/controller/response/DashboardResponse.kt +++ b/backend/application/admin/src/main/kotlin/io/raemian/admin/dashboard/controller/response/DashboardResponse.kt @@ -1,5 +1,6 @@ package io.raemian.admin.dashboard.controller.response +import io.raemian.admin.dashboard.dto.ActiveUserStatics import io.raemian.admin.dashboard.dto.GoalStatics import io.raemian.admin.dashboard.dto.TaskStatics import io.raemian.admin.dashboard.dto.UserStatics @@ -8,14 +9,16 @@ data class DashboardResponse( val userStatics: UserStatics, val goalStatics: GoalStatics, val taskStatics: TaskStatics, + val activeUserStatics: ActiveUserStatics, ) { companion object { fun from( userStatics: UserStatics, goalStatics: GoalStatics, taskStatics: TaskStatics, + activeUserStatics: ActiveUserStatics, ): DashboardResponse { - return DashboardResponse(userStatics, goalStatics, taskStatics) + return DashboardResponse(userStatics, goalStatics, taskStatics, activeUserStatics) } } } diff --git a/backend/application/admin/src/main/kotlin/io/raemian/admin/dashboard/dto/ActiveUserStatics.kt b/backend/application/admin/src/main/kotlin/io/raemian/admin/dashboard/dto/ActiveUserStatics.kt new file mode 100644 index 00000000..0ee512b3 --- /dev/null +++ b/backend/application/admin/src/main/kotlin/io/raemian/admin/dashboard/dto/ActiveUserStatics.kt @@ -0,0 +1,7 @@ +package io.raemian.admin.dashboard.dto + +data class ActiveUserStatics( + val perTodayPercent: Double, + val perWeekPercent: Double, + val perMonthPercent: Double, +) diff --git a/backend/application/api/src/main/kotlin/io/raemian/api/auth/service/OAuth2UserService.kt b/backend/application/api/src/main/kotlin/io/raemian/api/auth/service/OAuth2UserService.kt index a2dbe9bb..d7b346d3 100644 --- a/backend/application/api/src/main/kotlin/io/raemian/api/auth/service/OAuth2UserService.kt +++ b/backend/application/api/src/main/kotlin/io/raemian/api/auth/service/OAuth2UserService.kt @@ -18,7 +18,7 @@ import org.springframework.transaction.annotation.Transactional class OAuth2UserService( private val userRepository: UserRepository, private val lifeMapRepository: LifeMapRepository, - private val userLoginLogService: UserLoginLogService + private val userLoginLogService: UserLoginLogService, ) : DefaultOAuth2UserService() { companion object { @@ -99,7 +99,7 @@ class OAuth2UserService( userLoginLogService.upsertLatestLogin(user.id) - return user; + return user } private fun createUser(email: String, image: String, oAuthProvider: OAuthProvider): User { diff --git a/backend/application/api/src/main/kotlin/io/raemian/api/goal/GoalService.kt b/backend/application/api/src/main/kotlin/io/raemian/api/goal/GoalService.kt index 9ec4361c..7f47a66c 100644 --- a/backend/application/api/src/main/kotlin/io/raemian/api/goal/GoalService.kt +++ b/backend/application/api/src/main/kotlin/io/raemian/api/goal/GoalService.kt @@ -5,6 +5,7 @@ import io.raemian.api.goal.controller.response.CreateGoalResponse import io.raemian.api.goal.controller.response.GoalResponse import io.raemian.api.sticker.StickerService import io.raemian.api.support.RaemianLocalDate +import io.raemian.api.support.error.MaxGoalCountExceededException import io.raemian.api.support.error.PrivateLifeMapException import io.raemian.api.tag.TagService import io.raemian.storage.db.core.goal.Goal @@ -25,9 +26,9 @@ class GoalService( ) { @Transactional(readOnly = true) - fun getById(id: Long): GoalResponse { + fun getById(id: Long, userId: Long): GoalResponse { val goal = goalRepository.getById(id) - validateLifeMapPublic(goal.lifeMap) + validateAnotherUserLifeMapPublic(userId, goal.lifeMap) return GoalResponse(goal) } @@ -35,9 +36,10 @@ class GoalService( fun create(userId: Long, createGoalRequest: CreateGoalRequest): CreateGoalResponse { val lifeMap = lifeMapRepository.findFirstByUserId(userId) ?: createFirstLifeMap(userId) + val goal = createGoal(createGoalRequest, lifeMap) + addNewGoal(lifeMap, goal) - lifeMap.addGoal(goal) lifeMapRepository.save(lifeMap) return CreateGoalResponse(goal) } @@ -59,11 +61,11 @@ class GoalService( val deadline = RaemianLocalDate.of(yearOfDeadline, monthOfDeadLine) val sticker = stickerService.getById(stickerId) val tag = tagService.getById(tagId) - return Goal(lifeMap, title, deadline, sticker, tag, description!!, emptyList()) + return Goal(lifeMap, title, deadline, sticker, tag, description!!) } - private fun validateLifeMapPublic(lifeMap: LifeMap) { - if (!lifeMap.isPublic) { + private fun validateAnotherUserLifeMapPublic(userId: Long, lifeMap: LifeMap) { + if (lifeMap.user.id != userId && !lifeMap.isPublic) { throw PrivateLifeMapException() } } @@ -73,4 +75,12 @@ class GoalService( throw SecurityException() } } + + private fun addNewGoal(lifeMap: LifeMap, goal: Goal) { + try { + lifeMap.addGoal(goal) + } catch (exception: IllegalArgumentException) { + throw MaxGoalCountExceededException() + } + } } diff --git a/backend/application/api/src/main/kotlin/io/raemian/api/goal/controller/GoalController.kt b/backend/application/api/src/main/kotlin/io/raemian/api/goal/controller/GoalController.kt index aca87663..0af5c40e 100644 --- a/backend/application/api/src/main/kotlin/io/raemian/api/goal/controller/GoalController.kt +++ b/backend/application/api/src/main/kotlin/io/raemian/api/goal/controller/GoalController.kt @@ -29,10 +29,11 @@ class GoalController( @Operation(summary = "목표 단건 조회 API") @GetMapping("/{goalId}") fun getByUserId( + @AuthenticationPrincipal currentUser: CurrentUser, @PathVariable("goalId") goalId: Long, ): ResponseEntity> = ResponseEntity.ok( - ApiResponse.success(goalService.getById(goalId)), + ApiResponse.success(goalService.getById(goalId, currentUser.id)), ) @Operation(summary = "목표 생성 API") diff --git a/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/LifeMapService.kt b/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/LifeMapService.kt index 6cfa7968..65d9b683 100644 --- a/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/LifeMapService.kt +++ b/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/LifeMapService.kt @@ -1,13 +1,17 @@ package io.raemian.api.lifemap +import io.raemian.api.lifemap.domain.LifeMapCountDTO import io.raemian.api.lifemap.domain.LifeMapDTO import io.raemian.api.lifemap.domain.UpdatePublicRequest import io.raemian.api.support.error.PrivateLifeMapException import io.raemian.storage.db.core.lifemap.LifeMap import io.raemian.storage.db.core.lifemap.LifeMapCount import io.raemian.storage.db.core.lifemap.LifeMapCountRepository +import io.raemian.storage.db.core.lifemap.LifeMapHistory +import io.raemian.storage.db.core.lifemap.LifeMapHistoryRepository import io.raemian.storage.db.core.lifemap.LifeMapRepository import io.raemian.storage.db.core.user.UserRepository +import org.springframework.scheduling.annotation.Async import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -16,6 +20,7 @@ class LifeMapService( private val lifeMapRepository: LifeMapRepository, private val userRepository: UserRepository, private val lifeMapCountRepository: LifeMapCountRepository, + private val lifeMapHistoryRepository: LifeMapHistoryRepository, ) { @Transactional(readOnly = true) @@ -50,22 +55,59 @@ class LifeMapService( lifeMap.updatePublic(updatePublicRequest.isPublic) } + @Transactional + fun getLifeMapCount(lifeMapId: Long): LifeMapCountDTO { + val lifeMapCount = lifeMapCountRepository.findByLifeMapId(lifeMapId = lifeMapId) + ?: lifeMapCountRepository.save(LifeMapCount.of(lifeMapId)) + return LifeMapCountDTO(lifeMapCount) + } + + @Transactional(readOnly = true) + fun getViewCount(lifeMapId: Long): Long { + val lifeMapCount = lifeMapCountRepository.findByLifeMapId(lifeMapId = lifeMapId) + return lifeMapCount?.viewCount ?: 0 + } + @Transactional(readOnly = true) - fun getCount(lifeMapId: Long): Long { + fun getHistoryCount(lifeMapId: Long): Long { val lifeMapCount = lifeMapCountRepository.findByLifeMapId(lifeMapId = lifeMapId) - return lifeMapCount?.count ?: 0 + return lifeMapCount?.historyCount ?: 0 + } + + @Transactional + fun addViewCount(lifeMapId: Long): Long { + val lifeMapCount = lifeMapCountRepository.findByLifeMapId(lifeMapId) + ?: LifeMapCount.of(lifeMapId) + val added = lifeMapCount.addViewCount() + val saved = lifeMapCountRepository.save(added) + return saved.viewCount + } + + @Async + fun addHistoryCount(lifeMapId: Long) { + val lifeMapCount = lifeMapCountRepository.findByLifeMapId(lifeMapId) + ?: LifeMapCount.of(lifeMapId) + val added = lifeMapCount.addHistoryCount() + val saved = lifeMapCountRepository.save(added) + } + + @Async + fun upsertLifeMapHistory(userId: Long, lifeMapId: Long) { + val history = lifeMapHistoryRepository.findByLifeMapIdAndUserId(lifeMapId = lifeMapId, userId = userId) + + if (history == null) { + addHistoryCount(lifeMapId) + lifeMapHistoryRepository.save(LifeMapHistory.of(lifeMapId = lifeMapId, userId = userId)) + } } @Transactional fun addCount(lifeMapId: Long): Long { val lifeMapCount = lifeMapCountRepository.findByLifeMapId(lifeMapId) - ?: LifeMapCount( - lifeMapId = lifeMapId, - count = 0, - ) + ?: LifeMapCount.of(lifeMapId) val added = lifeMapCount.addCount() val saved = lifeMapCountRepository.save(added) - return saved.count + return saved.historyCount } private fun validateLifeMapPublic(lifeMap: LifeMap) = diff --git a/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/controller/LifeMapController.kt b/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/controller/LifeMapController.kt index 0c804d6d..c9e6e315 100644 --- a/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/controller/LifeMapController.kt +++ b/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/controller/LifeMapController.kt @@ -26,7 +26,7 @@ class LifeMapController( @AuthenticationPrincipal currentUser: CurrentUser, ): ResponseEntity> { val lifeMap = lifeMapService.findFirstByUserId(currentUser.id) - val count = lifeMapService.getCount(lifeMap.lifeMapId) + val count = lifeMapService.getLifeMapCount(lifeMap.lifeMapId) return ResponseEntity .ok(ApiResponse.success(LifeMapResponse(lifeMap, count))) } diff --git a/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/controller/OpenLifeMapController.kt b/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/controller/OpenLifeMapController.kt index d70e54ad..795e8299 100644 --- a/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/controller/OpenLifeMapController.kt +++ b/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/controller/OpenLifeMapController.kt @@ -1,10 +1,12 @@ package io.raemian.api.lifemap.controller +import io.raemian.api.auth.domain.CurrentUser import io.raemian.api.lifemap.LifeMapService import io.raemian.api.lifemap.domain.LifeMapResponse import io.raemian.api.support.response.ApiResponse import io.swagger.v3.oas.annotations.Operation import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RestController @@ -16,9 +18,18 @@ class OpenLifeMapController( @Operation(summary = "UserName으로 인생 지도 조회 API") @GetMapping("/open/life-map/{username}") - fun findAllByUserName(@PathVariable("username") username: String): ResponseEntity> { + fun findAllByUserName( + @AuthenticationPrincipal currentUser: CurrentUser?, + @PathVariable("username") username: String, + ): ResponseEntity> { val lifeMap = lifeMapService.findFirstByUserName(username) - val count = lifeMapService.addCount(lifeMap.lifeMapId) + val count = lifeMapService.addViewCount(lifeMap.lifeMapId) + + if (currentUser != null) { + if (currentUser.id != lifeMap.user?.id) { + lifeMapService.upsertLifeMapHistory(userId = currentUser.id, lifeMapId = lifeMap.lifeMapId) + } + } return ResponseEntity .ok(ApiResponse.success(LifeMapResponse(lifeMap, count))) diff --git a/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/domain/LifeMapCountDTO.kt b/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/domain/LifeMapCountDTO.kt new file mode 100644 index 00000000..331db797 --- /dev/null +++ b/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/domain/LifeMapCountDTO.kt @@ -0,0 +1,17 @@ +package io.raemian.api.lifemap.domain + +import io.raemian.storage.db.core.lifemap.LifeMapCount + +data class LifeMapCountDTO( + val id: Long, + val lifeMapId: Long, + val viewCount: Long, + val historyCount: Long, +) { + constructor(lifeMapCount: LifeMapCount) : this( + id = lifeMapCount.id!!, + lifeMapId = lifeMapCount.lifeMapId, + viewCount = lifeMapCount.viewCount, + historyCount = lifeMapCount.historyCount, + ) +} diff --git a/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/domain/LifeMapDTO.kt b/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/domain/LifeMapDTO.kt index b23d1d63..d616c0d3 100644 --- a/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/domain/LifeMapDTO.kt +++ b/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/domain/LifeMapDTO.kt @@ -25,6 +25,7 @@ data class LifeMapDTO( goals = lifeMap.goals.map(::GoalDto), goalsCount = lifeMap.goals.size, user = UserSubset( + id = user.id ?: 0, nickname = user.nickname!!, image = user.image, ), diff --git a/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/domain/LifeMapResponse.kt b/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/domain/LifeMapResponse.kt index 67bfc9cf..758d5902 100644 --- a/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/domain/LifeMapResponse.kt +++ b/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/domain/LifeMapResponse.kt @@ -8,18 +8,36 @@ data class LifeMapResponse( val goals: List, val goalsCount: Int, val user: UserSubset? = null, - val view: ViewResponse, + val count: CountResponse, ) { - constructor(lifeMapDTO: LifeMapDTO, count: Long) : this( + constructor(lifeMapDTO: LifeMapDTO, lifeMapCountDTO: LifeMapCountDTO) : this( lifeMapId = lifeMapDTO.lifeMapId, isPublic = lifeMapDTO.isPublic, goals = lifeMapDTO.goals, goalsCount = lifeMapDTO.goalsCount, user = lifeMapDTO.user, - view = ViewResponse(count), + count = CountResponse(lifeMapCountDTO), ) - data class ViewResponse( - val count: Long, + constructor(lifeMapDTO: LifeMapDTO, viewCount: Long) : this( + lifeMapId = lifeMapDTO.lifeMapId, + isPublic = lifeMapDTO.isPublic, + goals = lifeMapDTO.goals, + goalsCount = lifeMapDTO.goalsCount, + user = lifeMapDTO.user, + count = CountResponse(viewCount), ) + + data class CountResponse( + val view: Long, + val history: Long? = null, + ) { + constructor(lifeMapCountDTO: LifeMapCountDTO) : this( + view = lifeMapCountDTO.viewCount, + history = lifeMapCountDTO.historyCount, + ) + constructor(viewCount: Long) : this( + view = viewCount, + ) + } } diff --git a/backend/application/api/src/main/kotlin/io/raemian/api/log/UserLoginLogService.kt b/backend/application/api/src/main/kotlin/io/raemian/api/log/UserLoginLogService.kt index 727e6ffd..49ed075f 100644 --- a/backend/application/api/src/main/kotlin/io/raemian/api/log/UserLoginLogService.kt +++ b/backend/application/api/src/main/kotlin/io/raemian/api/log/UserLoginLogService.kt @@ -7,13 +7,13 @@ import org.springframework.stereotype.Service import java.time.LocalDateTime @Service -class UserLoginLogService ( - private val userLoginLogRepository: UserLoginLogRepository +class UserLoginLogService( + private val userLoginLogRepository: UserLoginLogRepository, ) { @Async fun upsertLatestLogin(userId: Long?) { - if(userId == null) { - return; + if (userId == null) { + return } val userLoginLog = userLoginLogRepository.findByUserId(userId) @@ -21,4 +21,4 @@ class UserLoginLogService ( userLoginLogRepository.save(userLoginLog.updateLatestLogin()) } -} \ No newline at end of file +} diff --git a/backend/application/api/src/main/kotlin/io/raemian/api/support/error/ErrorInfo.kt b/backend/application/api/src/main/kotlin/io/raemian/api/support/error/ErrorInfo.kt index 8363bd12..a17e7555 100644 --- a/backend/application/api/src/main/kotlin/io/raemian/api/support/error/ErrorInfo.kt +++ b/backend/application/api/src/main/kotlin/io/raemian/api/support/error/ErrorInfo.kt @@ -22,4 +22,18 @@ enum class ErrorInfo( "유저의 인생 지도가 비공개 상태입니다.", LogLevel.INFO, ), + + MAX_GOAL_COUNT_EXCEEDED_EXCEPTION( + HttpStatus.BAD_REQUEST, + 1002, + "목표 최대 갯수를 초과했습니다.", + LogLevel.INFO, + ), + + MAX_TASK_COUNT_EXCEEDED_EXCEPTION( + HttpStatus.BAD_REQUEST, + 1003, + "세부 목표의 최대 갯수를 초과했습니다.", + LogLevel.INFO, + ), } diff --git a/backend/application/api/src/main/kotlin/io/raemian/api/support/error/MaxGoalCountExceededException.kt b/backend/application/api/src/main/kotlin/io/raemian/api/support/error/MaxGoalCountExceededException.kt new file mode 100644 index 00000000..28954201 --- /dev/null +++ b/backend/application/api/src/main/kotlin/io/raemian/api/support/error/MaxGoalCountExceededException.kt @@ -0,0 +1,5 @@ +package io.raemian.api.support.error + +class MaxGoalCountExceededException : CoreApiException( + ErrorInfo.MAX_GOAL_COUNT_EXCEEDED_EXCEPTION, +) diff --git a/backend/application/api/src/main/kotlin/io/raemian/api/support/error/MaxTaskCountExceededException.kt b/backend/application/api/src/main/kotlin/io/raemian/api/support/error/MaxTaskCountExceededException.kt new file mode 100644 index 00000000..8c47478d --- /dev/null +++ b/backend/application/api/src/main/kotlin/io/raemian/api/support/error/MaxTaskCountExceededException.kt @@ -0,0 +1,5 @@ +package io.raemian.api.support.error + +class MaxTaskCountExceededException : CoreApiException( + ErrorInfo.MAX_TASK_COUNT_EXCEEDED_EXCEPTION, +) diff --git a/backend/application/api/src/main/kotlin/io/raemian/api/task/TaskService.kt b/backend/application/api/src/main/kotlin/io/raemian/api/task/TaskService.kt index c1861a56..f11b2b19 100644 --- a/backend/application/api/src/main/kotlin/io/raemian/api/task/TaskService.kt +++ b/backend/application/api/src/main/kotlin/io/raemian/api/task/TaskService.kt @@ -1,5 +1,6 @@ package io.raemian.api.task +import io.raemian.api.support.error.MaxTaskCountExceededException import io.raemian.api.task.controller.request.CreateTaskRequest import io.raemian.api.task.controller.request.RewriteTaskRequest import io.raemian.api.task.controller.request.UpdateTaskCompletionRequest @@ -23,6 +24,8 @@ class TaskService( validateCurrentUserIsGoalOwner(currentUserId, goal) val task = Task.createTask(goal, createTaskRequest.description) + addNewTask(goal, task) + taskRepository.save(task) return CreateTaskResponse(task.id!!, task.description) } @@ -62,4 +65,12 @@ class TaskService( throw SecurityException() } } + + private fun addNewTask(goal: Goal, task: Task) { + try { + goal.addTask(task) + } catch (exception: IllegalArgumentException) { + throw MaxTaskCountExceededException() + } + } } diff --git a/backend/application/api/src/main/kotlin/io/raemian/api/user/domain/UserSubset.kt b/backend/application/api/src/main/kotlin/io/raemian/api/user/domain/UserSubset.kt index 254f9186..993f2708 100644 --- a/backend/application/api/src/main/kotlin/io/raemian/api/user/domain/UserSubset.kt +++ b/backend/application/api/src/main/kotlin/io/raemian/api/user/domain/UserSubset.kt @@ -1,6 +1,7 @@ package io.raemian.api.user.domain data class UserSubset( + val id: Long, val nickname: String, val image: String, ) diff --git a/backend/application/api/src/test/kotlin/io/raemian/api/integration/goal/GoalServiceTest.kt b/backend/application/api/src/test/kotlin/io/raemian/api/integration/goal/GoalServiceTest.kt index e40ae66d..1922721a 100644 --- a/backend/application/api/src/test/kotlin/io/raemian/api/integration/goal/GoalServiceTest.kt +++ b/backend/application/api/src/test/kotlin/io/raemian/api/integration/goal/GoalServiceTest.kt @@ -2,6 +2,7 @@ package io.raemian.api.integration.goal import io.raemian.api.goal.GoalService import io.raemian.api.goal.controller.request.CreateGoalRequest +import io.raemian.api.support.error.MaxGoalCountExceededException import io.raemian.api.support.error.PrivateLifeMapException import io.raemian.storage.db.core.goal.Goal import io.raemian.storage.db.core.goal.GoalRepository @@ -25,7 +26,6 @@ import org.springframework.transaction.annotation.Transactional import java.time.LocalDate @SpringBootTest -@Transactional class GoalServiceTest { companion object { @@ -73,21 +73,20 @@ class GoalServiceTest { sticker = STICKER_FIXTURE, tag = TAG_FIXTURE, description = "내용", - tasks = emptyList(), ) goalRepository.save(goal) // when // then Assertions.assertThatCode { - goalService.getById(goal.id!!) + goalService.getById(goal.id!!, USER_FIXTURE.id!!) }.doesNotThrowAnyException() } @Test - @DisplayName("목표 조회시, 목표 주인의 Goals 공개 여부가 false일 때, 예외를 발생시킨다.") + @DisplayName("목표 조회시, 목표가 다른 유저의 목표이고, Goals 공개 여부가 false일 때, 예외를 발생시킨다.") @Transactional - fun validateUserGoalsPublicTest() { + fun validateAnotherUserLifeMapPublicTest() { // given val lifeMap = entityManager.find(LifeMap::class.java, LIFE_MAP_FIXTURE.id) lifeMap.updatePublic(false) @@ -99,19 +98,44 @@ class GoalServiceTest { sticker = STICKER_FIXTURE, tag = TAG_FIXTURE, description = "목표 설명.", - tasks = emptyList(), ) goalRepository.save(goal) // when // then Assertions.assertThatThrownBy { - goalService.getById(goal.id!!) + goalService.getById(goal.id!!, USER_FIXTURE.id!! + 1) }.isInstanceOf(PrivateLifeMapException::class.java) } + @Test + @DisplayName("목표 조회시, 목표가 자신의 목표이면, Goals 공개 여부가 false여도 예외를 발생시키지 않는다.") + @Transactional + fun validateAnotherUserLifeMapPublicTest2() { + // given + val lifeMap = entityManager.find(LifeMap::class.java, LIFE_MAP_FIXTURE.id) + lifeMap.updatePublic(false) + + val goal = Goal( + lifeMap = lifeMap, + title = "목표", + deadline = LocalDate.MAX, + sticker = STICKER_FIXTURE, + tag = TAG_FIXTURE, + description = "목표 설명.", + ) + goalRepository.save(goal) + + // when + // then + Assertions.assertThatCode { + goalService.getById(goal.id!!, USER_FIXTURE.id!!) + }.doesNotThrowAnyException() + } + @Test @DisplayName("Goal을 생성할 수 있다.") + @Transactional fun createGoalTest() { val createGoalRequest = CreateGoalRequest( title = "title", @@ -142,6 +166,41 @@ class GoalServiceTest { ) } + @Test + @DisplayName("목표 생성시 LifeMap의 목표가 50개 이상이라면 예외를 발생시킨다.") + @Transactional + fun validateMaxGoalCountTest() { + // given + val lifeMap = entityManager.find(LifeMap::class.java, LIFE_MAP_FIXTURE.id) + val createGoalRequest = CreateGoalRequest( + title = "title", + description = "description", + stickerId = STICKER_FIXTURE.id!!, + tagId = TAG_FIXTURE.id!!, + yearOfDeadline = "2023", + monthOfDeadline = "12", + ) + + // when + // Goal 50개 추가 + repeat(49) { + addNewGoalToLifeMap(lifeMap) + } + entityManager.merge(lifeMap) + + // when + // then + // 49개일 떄는 통과한다. + Assertions.assertThatCode { + goalService.create(USER_FIXTURE.id!!, createGoalRequest) + }.doesNotThrowAnyException() + + // 50개일 때는 실패한다. + Assertions.assertThatThrownBy { + goalService.create(USER_FIXTURE.id!!, createGoalRequest) + }.isInstanceOf(MaxGoalCountExceededException::class.java) + } + @Test @DisplayName("Goal을 삭제할 수 있다.") @Transactional @@ -154,7 +213,6 @@ class GoalServiceTest { deadline = LocalDate.now(), sticker = STICKER_FIXTURE, tag = TAG_FIXTURE, - tasks = emptyList(), ) goalRepository.save(goal) @@ -165,4 +223,17 @@ class GoalServiceTest { // then assertThat(goalRepository.findById(goal.id!!).isEmpty).isTrue() } + + private fun addNewGoalToLifeMap(lifeMap: LifeMap) { + val goal = Goal( + lifeMap = lifeMap, + title = "목표", + deadline = LocalDate.MAX, + sticker = STICKER_FIXTURE, + tag = TAG_FIXTURE, + description = "목표 설명", + ) + + lifeMap.addGoal(goal) + } } diff --git a/backend/application/api/src/test/kotlin/io/raemian/api/integration/lifemap/LifeMapServiceTest.kt b/backend/application/api/src/test/kotlin/io/raemian/api/integration/lifemap/LifeMapServiceTest.kt index 1c1fad7d..6f1c6c1e 100644 --- a/backend/application/api/src/test/kotlin/io/raemian/api/integration/lifemap/LifeMapServiceTest.kt +++ b/backend/application/api/src/test/kotlin/io/raemian/api/integration/lifemap/LifeMapServiceTest.kt @@ -72,7 +72,6 @@ class LifeMapServiceTest { sticker = STICKER_FIXTURE, tag = TAG_FIXTURE, description = "", - tasks = emptyList(), ) val goal2 = Goal( @@ -82,7 +81,6 @@ class LifeMapServiceTest { sticker = STICKER_FIXTURE, tag = TAG_FIXTURE, description = "", - tasks = emptyList(), ) val lifeMap = lifeMapRepository.findFirstByUserId(USER_FIXTURE.id!!) ?: fail() @@ -115,7 +113,6 @@ class LifeMapServiceTest { sticker = STICKER_FIXTURE, tag = TAG_FIXTURE, description = "", - tasks = emptyList(), ) val goal2 = Goal( @@ -125,7 +122,6 @@ class LifeMapServiceTest { sticker = STICKER_FIXTURE, tag = TAG_FIXTURE, description = "", - tasks = emptyList(), ) val lifeMap = lifeMapRepository.findFirstByUserId(USER_FIXTURE.id!!) ?: fail() @@ -173,7 +169,6 @@ class LifeMapServiceTest { sticker = STICKER_FIXTURE, tag = TAG_FIXTURE, description = "", - tasks = emptyList(), ) val goal2 = Goal( @@ -183,7 +178,6 @@ class LifeMapServiceTest { sticker = STICKER_FIXTURE, tag = TAG_FIXTURE, description = "", - tasks = emptyList(), ) val lifeMap = lifeMapRepository.findFirstByUserId(USER_FIXTURE.id!!) ?: fail() @@ -221,7 +215,6 @@ class LifeMapServiceTest { sticker = STICKER_FIXTURE, tag = TAG_FIXTURE, description = "", - tasks = emptyList(), ) val deadline이_내일이고_가장_나중에_만들어진_객체 = Goal( @@ -232,7 +225,6 @@ class LifeMapServiceTest { sticker = STICKER_FIXTURE, tag = TAG_FIXTURE, description = "", - tasks = emptyList(), ) val deadline이_오늘인_객체 = Goal( @@ -242,7 +234,6 @@ class LifeMapServiceTest { sticker = STICKER_FIXTURE, tag = TAG_FIXTURE, description = "", - tasks = emptyList(), ) // when diff --git a/backend/application/api/src/test/kotlin/io/raemian/api/integration/task/TaskServiceTest.kt b/backend/application/api/src/test/kotlin/io/raemian/api/integration/task/TaskServiceTest.kt index e899b063..fc2e11b4 100644 --- a/backend/application/api/src/test/kotlin/io/raemian/api/integration/task/TaskServiceTest.kt +++ b/backend/application/api/src/test/kotlin/io/raemian/api/integration/task/TaskServiceTest.kt @@ -1,5 +1,6 @@ package io.raemian.api.integration.task +import io.raemian.api.support.error.MaxTaskCountExceededException import io.raemian.api.task.TaskService import io.raemian.api.task.controller.request.CreateTaskRequest import io.raemian.api.task.controller.request.RewriteTaskRequest @@ -15,6 +16,7 @@ import io.raemian.storage.db.core.user.Authority import io.raemian.storage.db.core.user.User import io.raemian.storage.db.core.user.enums.OAuthProvider import jakarta.persistence.EntityManager +import org.assertj.core.api.Assertions import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName @@ -49,7 +51,6 @@ class TaskServiceTest { sticker = STICKER_FIXTURE, tag = TAG_FIXTURE, description = "description", - tasks = emptyList(), ) } @@ -106,6 +107,39 @@ class TaskServiceTest { assertThat(task.isDone).isEqualTo(false) } + @Test + @DisplayName("Task 생성시 Goal의 Task가 50개 이상이라면 예외를 발생시킨다.") + @Transactional + fun validateMaxTaskCountTest() { + // given + val goal = entityManager.find(Goal::class.java, GOAL_FIXTURE.id) + + // when + // Task 50개 추가 + repeat(49) { + addNewTaskToGoal(goal) + } + entityManager.merge(goal) + + // when + // then + // 49개일 떄는 통과한다. + Assertions.assertThatCode { + taskService.create( + currentUserId = USER_FIXTURE.id!!, + CreateTaskRequest(GOAL_FIXTURE.id!!, "description"), + ) + }.doesNotThrowAnyException() + + // 50개일 때는 실패한다. + Assertions.assertThatThrownBy { + taskService.create( + currentUserId = USER_FIXTURE.id!!, + CreateTaskRequest(GOAL_FIXTURE.id!!, "description"), + ) + }.isInstanceOf(MaxTaskCountExceededException::class.java) + } + @Test @DisplayName("Task의 Description을 수정할 수 있다.") fun rewriteTest() { @@ -160,4 +194,9 @@ class TaskServiceTest { val task = taskRepository.findById(newTask.id!!) assertThat(task.isEmpty).isTrue() } + + private fun addNewTaskToGoal(goal: Goal) { + val task = Task.createTask(GOAL_FIXTURE, "") + goal.addTask(task) + } } diff --git a/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/goal/Goal.kt b/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/goal/Goal.kt index fff2843e..c7dacb7c 100644 --- a/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/goal/Goal.kt +++ b/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/goal/Goal.kt @@ -44,10 +44,27 @@ class Goal( @Nationalized val description: String = "", - @OneToMany(mappedBy = "goal", cascade = [CascadeType.REMOVE], fetch = FetchType.LAZY) - val tasks: List, + @OneToMany( + mappedBy = "goal", + cascade = [CascadeType.REMOVE, CascadeType.MERGE], + fetch = FetchType.LAZY, + ) + val tasks: MutableList = ArrayList(), @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long? = null, -) : BaseEntity() +) : BaseEntity() { + + companion object { + private const val MAX_TASK_COUNT = 50 + } + + fun addTask(task: Task) { + validateMaxTaskCount() + tasks.add(task) + } + + private fun validateMaxTaskCount() = + require(tasks.size < MAX_TASK_COUNT) +} diff --git a/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/lifemap/LifeMap.kt b/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/lifemap/LifeMap.kt index f7ce1837..9c55e219 100644 --- a/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/lifemap/LifeMap.kt +++ b/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/lifemap/LifeMap.kt @@ -37,11 +37,16 @@ class LifeMap( val id: Long? = null, ) : BaseEntity() { + companion object { + private const val MAX_GOAL_COUNT = 50 + } + fun updatePublic(isPublic: Boolean) { this.isPublic = isPublic } fun addGoal(goal: Goal) { + validateMaxGoalCount() this.goals.add(goal) } @@ -51,4 +56,7 @@ class LifeMap( .thenByDescending { it.createdAt }, ) } + + private fun validateMaxGoalCount() = + require(goals.size < MAX_GOAL_COUNT) } diff --git a/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/lifemap/LifeMapCount.kt b/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/lifemap/LifeMapCount.kt index 91ba8a65..316854e7 100644 --- a/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/lifemap/LifeMapCount.kt +++ b/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/lifemap/LifeMapCount.kt @@ -10,15 +10,44 @@ import jakarta.persistence.Table @Table(name = "LIFE_MAP_COUNT") class LifeMapCount( val lifeMapId: Long, - val count: Long, + val viewCount: Long, + val historyCount: Long, @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long? = null, ) { + companion object { + fun of(lifeMapId: Long): LifeMapCount { + return LifeMapCount( + lifeMapId = lifeMapId, + viewCount = 0, + historyCount = 0, + ) + } + } + fun addViewCount(): LifeMapCount { + return LifeMapCount( + lifeMapId = lifeMapId, + viewCount = viewCount + 1, + historyCount = historyCount, + id = id, + ) + } + + fun addHistoryCount(): LifeMapCount { + return LifeMapCount( + lifeMapId = lifeMapId, + viewCount = viewCount, + historyCount = historyCount + 1, + id = id, + ) + } + fun addCount(): LifeMapCount { return LifeMapCount( lifeMapId = lifeMapId, - count = count + 1, + viewCount = viewCount + 1, + historyCount = historyCount + 1, id = id, ) } diff --git a/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/lifemap/LifeMapHistory.kt b/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/lifemap/LifeMapHistory.kt new file mode 100644 index 00000000..86607cfd --- /dev/null +++ b/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/lifemap/LifeMapHistory.kt @@ -0,0 +1,27 @@ +package io.raemian.storage.db.core.lifemap + +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table + +@Entity +@Table(name = "LIFE_MAP_HISTORY") +class LifeMapHistory( + val lifeMapId: Long, + val userId: Long, + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, +) { + + companion object { + fun of(lifeMapId: Long, userId: Long): LifeMapHistory { + return LifeMapHistory( + lifeMapId = lifeMapId, + userId = userId, + ) + } + } +} diff --git a/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/lifemap/LifeMapHistoryRepository.kt b/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/lifemap/LifeMapHistoryRepository.kt new file mode 100644 index 00000000..9f498291 --- /dev/null +++ b/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/lifemap/LifeMapHistoryRepository.kt @@ -0,0 +1,7 @@ +package io.raemian.storage.db.core.lifemap + +import org.springframework.data.repository.CrudRepository + +interface LifeMapHistoryRepository : CrudRepository { + fun findByLifeMapIdAndUserId(lifeMapId: Long, userId: Long): LifeMapHistory? +} diff --git a/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/log/LogLoginLog.kt b/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/log/UserLoginLog.kt similarity index 99% rename from backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/log/LogLoginLog.kt rename to backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/log/UserLoginLog.kt index 38a41c77..355f638c 100644 --- a/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/log/LogLoginLog.kt +++ b/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/log/UserLoginLog.kt @@ -27,4 +27,4 @@ class UserLoginLog( return this } -} \ No newline at end of file +} diff --git a/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/log/UserLoginLogRepository.kt b/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/log/UserLoginLogRepository.kt index c57c3e4f..ba247730 100644 --- a/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/log/UserLoginLogRepository.kt +++ b/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/log/UserLoginLogRepository.kt @@ -1,8 +1,10 @@ package io.raemian.storage.db.core.log import org.springframework.data.jpa.repository.JpaRepository +import java.time.LocalDateTime interface UserLoginLogRepository : JpaRepository { fun findByUserId(userId: Long): UserLoginLog? + fun countUserLoginLogByLatestLoginAtGreaterThanEqual(latestLoginAt: LocalDateTime): Long } From af7f5a3c93266a77eb96acc244c23dea211b1a89 Mon Sep 17 00:00:00 2001 From: wkwon Date: Fri, 2 Feb 2024 18:10:08 +0900 Subject: [PATCH 2/4] Merge Develop To Main --- .../io/raemian/api/lifemap/controller/LifeMapController.kt | 3 ++- .../raemian/api/lifemap/controller/OpenLifeMapController.kt | 5 ++++- .../kotlin/io/raemian/api/lifemap/domain/LifeMapResponse.kt | 3 ++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/controller/LifeMapController.kt b/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/controller/LifeMapController.kt index 3ef62d85..076a1502 100644 --- a/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/controller/LifeMapController.kt +++ b/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/controller/LifeMapController.kt @@ -1,6 +1,7 @@ package io.raemian.api.lifemap.controller import io.raemian.api.auth.domain.CurrentUser +import io.raemian.api.cheer.CheeringServcie import io.raemian.api.lifemap.LifeMapService import io.raemian.api.lifemap.domain.LifeMapResponse import io.raemian.api.lifemap.domain.UpdatePublicRequest @@ -31,7 +32,7 @@ class LifeMapController( val cheeringCount = cheeringServcie.getCheeringCount(currentUser.id) return ResponseEntity - .ok(ApiResponse.success(LifeMapResponse(lifeMap, count))) + .ok(ApiResponse.success(LifeMapResponse(lifeMap, count, cheeringCount))) } @Operation(summary = "인생 지도 공개 여부를 수정하는 API") diff --git a/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/controller/OpenLifeMapController.kt b/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/controller/OpenLifeMapController.kt index 36dabd8c..bf651497 100644 --- a/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/controller/OpenLifeMapController.kt +++ b/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/controller/OpenLifeMapController.kt @@ -20,7 +20,10 @@ class OpenLifeMapController( @Operation(summary = "UserName으로 인생 지도 조회 API") @GetMapping("/open/life-map/{username}") - fun findAllByUserName(@PathVariable("username") username: String): ResponseEntity> { + fun findAllByUserName( + @AuthenticationPrincipal currentUser: CurrentUser?, + @PathVariable("username") username: String, + ): ResponseEntity> { val lifeMap = lifeMapService.findFirstByUserName(username) val count = lifeMapService.addViewCount(lifeMap.lifeMapId) val cheeringCount = cheeringServcie.getCheeringCount(username) diff --git a/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/domain/LifeMapResponse.kt b/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/domain/LifeMapResponse.kt index ea39d46a..b91fdd3f 100644 --- a/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/domain/LifeMapResponse.kt +++ b/backend/application/api/src/main/kotlin/io/raemian/api/lifemap/domain/LifeMapResponse.kt @@ -1,5 +1,6 @@ package io.raemian.api.lifemap.domain +import io.raemian.api.cheer.controller.response.CheeringCountResponse import io.raemian.api.user.domain.UserSubset data class LifeMapResponse( @@ -8,7 +9,7 @@ data class LifeMapResponse( val goals: List, val goalsCount: Int, val user: UserSubset? = null, - val view: ViewResponse, + val count: CountResponse, ) { constructor(lifeMapDTO: LifeMapDTO, lifeMapCountDTO: LifeMapCountDTO, cheeringCount: Long) : this( lifeMapId = lifeMapDTO.lifeMapId, From bdc91192eb41d3b3f44056c7a8f1ef2bfcbedfae Mon Sep 17 00:00:00 2001 From: wkwon Date: Wed, 28 Feb 2024 00:19:04 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EC=9D=91=EC=9B=90=EC=9E=90=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EC=BF=BC=EB=A6=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/kotlin/io/raemian/api/cheer/CheeringService.kt | 4 ++-- .../io/raemian/storage/db/core/cheer/CheererRepository.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/application/api/src/main/kotlin/io/raemian/api/cheer/CheeringService.kt b/backend/application/api/src/main/kotlin/io/raemian/api/cheer/CheeringService.kt index 8ec784fa..f26af79e 100644 --- a/backend/application/api/src/main/kotlin/io/raemian/api/cheer/CheeringService.kt +++ b/backend/application/api/src/main/kotlin/io/raemian/api/cheer/CheeringService.kt @@ -89,7 +89,7 @@ class CheeringService( return if (contentSize < pageSize) { true } else { - !cheererRepository.existsByLifeMapIdAndCheeringAtGreaterThanOrderByCheeringAtDesc(lifeMapId, cheeringSquad.last().cheeringAt) + !cheererRepository.existsByLifeMapIdAndCheeringAtLessThanOrderByCheeringAtDesc(lifeMapId, cheeringSquad.last().cheeringAt) } } @@ -97,7 +97,7 @@ class CheeringService( return if (cheeringAt == null) { cheererRepository.findByLifeMapIdOrderByCheeringAtDesc(lifeMapId, pageable) } else { - cheererRepository.findByLifeMapIdAndCheeringAtGreaterThanOrderByCheeringAtDesc(lifeMapId, cheeringAt, pageable) + cheererRepository.findByLifeMapIdAndCheeringAtLessThanOrderByCheeringAtDesc(lifeMapId, cheeringAt, pageable) } } diff --git a/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/cheer/CheererRepository.kt b/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/cheer/CheererRepository.kt index 13ddefd7..cb6543af 100644 --- a/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/cheer/CheererRepository.kt +++ b/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/cheer/CheererRepository.kt @@ -6,9 +6,9 @@ import java.time.LocalDateTime interface CheererRepository : JpaRepository { - fun findByLifeMapIdAndCheeringAtGreaterThanOrderByCheeringAtDesc(lifeMapId: Long, cheeringAt: LocalDateTime, pageable: Pageable): List + fun findByLifeMapIdAndCheeringAtLessThanOrderByCheeringAtDesc(lifeMapId: Long, cheeringAt: LocalDateTime, pageable: Pageable): List fun findByLifeMapIdOrderByCheeringAtDesc(lifeMapId: Long, pageable: Pageable): List - fun existsByLifeMapIdAndCheeringAtGreaterThanOrderByCheeringAtDesc(lifeMapId: Long, cheeringAt: LocalDateTime): Boolean + fun existsByLifeMapIdAndCheeringAtLessThanOrderByCheeringAtDesc(lifeMapId: Long, cheeringAt: LocalDateTime): Boolean } From 72425c5fd81c19000449e03b12bdbdc0931f3994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=EC=9A=B0=EC=84=9D=20=28Woosuk=20Kwon=29?= Date: Sun, 10 Mar 2024 16:08:47 +0900 Subject: [PATCH 4/4] Merge develop to main (#212) --- .../controller/request/UpdateUserRequest.kt | 4 +- .../raemian/api/config/WebSecurityConfig.kt | 5 +- .../io/raemian/api/support/CookieUtils.kt | 55 +++++++++++++++ ...kieOAuth2AuthorizationRequestRepository.kt | 68 +++++++++++++++++++ .../raemian/api/user/service/UserService.kt | 6 +- .../resources/application-security-dev.yml | 1 - .../resources/application-security-live.yml | 2 - .../io/raemian/storage/db/core/user/User.kt | 2 +- 8 files changed, 133 insertions(+), 10 deletions(-) create mode 100644 backend/application/api/src/main/kotlin/io/raemian/api/support/CookieUtils.kt create mode 100644 backend/application/api/src/main/kotlin/io/raemian/api/support/HttpCookieOAuth2AuthorizationRequestRepository.kt diff --git a/backend/application/api/src/main/kotlin/io/raemian/api/auth/controller/request/UpdateUserRequest.kt b/backend/application/api/src/main/kotlin/io/raemian/api/auth/controller/request/UpdateUserRequest.kt index 04ceda60..977c515e 100644 --- a/backend/application/api/src/main/kotlin/io/raemian/api/auth/controller/request/UpdateUserRequest.kt +++ b/backend/application/api/src/main/kotlin/io/raemian/api/auth/controller/request/UpdateUserRequest.kt @@ -4,12 +4,12 @@ import java.time.LocalDate data class UpdateUserRequest( val nickname: String, - val birth: LocalDate, + val birth: LocalDate?, ) data class UpdateUserInfoRequest( val nickname: String, - val birth: LocalDate, + val birth: LocalDate?, val username: String, val image: String, ) { diff --git a/backend/application/api/src/main/kotlin/io/raemian/api/config/WebSecurityConfig.kt b/backend/application/api/src/main/kotlin/io/raemian/api/config/WebSecurityConfig.kt index ab27fd98..fe3784b6 100644 --- a/backend/application/api/src/main/kotlin/io/raemian/api/config/WebSecurityConfig.kt +++ b/backend/application/api/src/main/kotlin/io/raemian/api/config/WebSecurityConfig.kt @@ -3,6 +3,7 @@ package io.raemian.api.config import io.raemian.api.auth.converter.TokenRequestEntityConverter import io.raemian.api.auth.domain.CurrentUser import io.raemian.api.auth.service.OAuth2UserService +import io.raemian.api.support.HttpCookieOAuth2AuthorizationRequestRepository import io.raemian.api.support.TokenProvider import jakarta.servlet.http.HttpServletResponse import org.slf4j.LoggerFactory @@ -38,6 +39,7 @@ class WebSecurityConfig( @Value("\${spring.profiles.active:local}") private val profile: String, private val tokenRequestEntityConverter: TokenRequestEntityConverter, + private val httpCookieOAuth2AuthorizationRequestRepository: HttpCookieOAuth2AuthorizationRequestRepository, ) : SecurityConfigurerAdapter() { private val log = LoggerFactory.getLogger(javaClass) @@ -81,7 +83,8 @@ class WebSecurityConfig( } .oauth2Login { it.tokenEndpoint { it.accessTokenResponseClient(accessTokenResponseClient()) } - .userInfoEndpoint { endpoint -> endpoint.userService(oAuth2UserService) } + it.userInfoEndpoint { it.userService(oAuth2UserService) } + it.authorizationEndpoint { it.authorizationRequestRepository(httpCookieOAuth2AuthorizationRequestRepository) } it.successHandler { request, response, authentication -> val user = authentication.principal as CurrentUser response.contentType = MediaType.APPLICATION_JSON_VALUE diff --git a/backend/application/api/src/main/kotlin/io/raemian/api/support/CookieUtils.kt b/backend/application/api/src/main/kotlin/io/raemian/api/support/CookieUtils.kt new file mode 100644 index 00000000..d049311e --- /dev/null +++ b/backend/application/api/src/main/kotlin/io/raemian/api/support/CookieUtils.kt @@ -0,0 +1,55 @@ +package io.raemian.api.support + +import jakarta.servlet.http.Cookie +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.util.SerializationUtils +import java.util.Base64 +import java.util.Optional + +object CookieUtils { + fun getCookie(request: HttpServletRequest, name: String): Optional { + val cookies = request.cookies + if (cookies != null && cookies.isNotEmpty()) { + for (cookie in cookies) { + if (cookie.name == name) { + return Optional.of(cookie) + } + } + } + return Optional.empty() + } + + fun addCookie(response: HttpServletResponse, name: String, value: String, maxAge: Int) { + val cookie = Cookie(name, value) + cookie.path = "/" + cookie.isHttpOnly = true + cookie.maxAge = maxAge + response.addCookie(cookie) + } + + fun deleteCookie(request: HttpServletRequest, response: HttpServletResponse, name: String) { + val cookies = request.cookies + if (cookies != null && cookies.isNotEmpty()) { + for (cookie in cookies) { + if (cookie.name == name) { + cookie.value = "" + cookie.path = "/" + cookie.maxAge = 0 + response.addCookie(cookie) + } + } + } + } + + fun serialize(obj: Any?): String { + return Base64.getUrlEncoder() + .encodeToString( + SerializationUtils.serialize(obj), + ) + } + + fun deserialize(cookie: Cookie, cls: Class): T { + return cls.cast(SerializationUtils.deserialize(Base64.getUrlDecoder().decode(cookie.value))) + } +} diff --git a/backend/application/api/src/main/kotlin/io/raemian/api/support/HttpCookieOAuth2AuthorizationRequestRepository.kt b/backend/application/api/src/main/kotlin/io/raemian/api/support/HttpCookieOAuth2AuthorizationRequestRepository.kt new file mode 100644 index 00000000..995034cc --- /dev/null +++ b/backend/application/api/src/main/kotlin/io/raemian/api/support/HttpCookieOAuth2AuthorizationRequestRepository.kt @@ -0,0 +1,68 @@ +package io.raemian.api.support + +import jakarta.servlet.http.Cookie +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest +import org.springframework.stereotype.Component +import java.time.Duration + +@Component +class HttpCookieOAuth2AuthorizationRequestRepository() : AuthorizationRequestRepository { + + private val AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request" + private val EXPIRE_SECONDS: Int = Duration.ofSeconds(180).toMillis().toInt() + + override fun loadAuthorizationRequest(request: HttpServletRequest): OAuth2AuthorizationRequest? { + val state = this.getStateParameter(request) ?: return null + + val authorizationRequest: OAuth2AuthorizationRequest? = + CookieUtils.getCookie(request, AUTHORIZATION_REQUEST_COOKIE_NAME) + .map { cookie: Cookie -> + CookieUtils.deserialize(cookie, OAuth2AuthorizationRequest::class.java) + }.orElse(null) + + return if (authorizationRequest != null && state == authorizationRequest.state) { + authorizationRequest + } else { + null + } + } + override fun saveAuthorizationRequest( + authorizationRequest: OAuth2AuthorizationRequest?, + request: HttpServletRequest, + response: HttpServletResponse, + ) { + if (authorizationRequest == null) { + removeAuthorizationRequest(request, response) + return + } + + CookieUtils.addCookie( + response, + AUTHORIZATION_REQUEST_COOKIE_NAME, + CookieUtils.serialize(authorizationRequest), + EXPIRE_SECONDS, + ) + } + + override fun removeAuthorizationRequest( + request: HttpServletRequest, + response: HttpServletResponse, + ): OAuth2AuthorizationRequest? { + val authorizationRequest: OAuth2AuthorizationRequest? = this.loadAuthorizationRequest(request) + + if (authorizationRequest != null) { + removeAuthorizationRequestCookies(request, response) + } + + return authorizationRequest + } + + private fun removeAuthorizationRequestCookies(request: HttpServletRequest, response: HttpServletResponse) { + CookieUtils.deleteCookie(request, response, AUTHORIZATION_REQUEST_COOKIE_NAME) + } + + private fun getStateParameter(request: HttpServletRequest): String? = request.getParameter("state") +} diff --git a/backend/application/api/src/main/kotlin/io/raemian/api/user/service/UserService.kt b/backend/application/api/src/main/kotlin/io/raemian/api/user/service/UserService.kt index 00da5101..893aa985 100644 --- a/backend/application/api/src/main/kotlin/io/raemian/api/user/service/UserService.kt +++ b/backend/application/api/src/main/kotlin/io/raemian/api/user/service/UserService.kt @@ -17,7 +17,7 @@ class UserService( return UserDTO.of(user) } - fun updateNicknameAndBirth(id: Long, nickname: String, birth: LocalDate): UserDTO { + fun updateNicknameAndBirth(id: Long, nickname: String, birth: LocalDate?): UserDTO { val user = userRepository.getById(id) val updated = user.updateNicknameAndBirth( @@ -28,7 +28,7 @@ class UserService( return UserDTO.of(userRepository.save(updated)) } - fun updateBaseInfo(id: Long, nickname: String, birth: LocalDate, image: String): UserDTO { + fun updateBaseInfo(id: Long, nickname: String, birth: LocalDate?, image: String): UserDTO { val user = userRepository.getById(id) val updated = user.updateNicknameAndBirth( @@ -39,7 +39,7 @@ class UserService( return UserDTO.of(userRepository.save(updated)) } - fun update(id: Long, nickname: String, birth: LocalDate, username: String, image: String): UserDTO { + fun update(id: Long, nickname: String, birth: LocalDate?, username: String, image: String): UserDTO { val user = userRepository.getById(id) val updated = user diff --git a/backend/application/api/src/main/resources/application-security-dev.yml b/backend/application/api/src/main/resources/application-security-dev.yml index 60418211..ed824029 100644 --- a/backend/application/api/src/main/resources/application-security-dev.yml +++ b/backend/application/api/src/main/resources/application-security-dev.yml @@ -5,7 +5,6 @@ server: port: 8888 servlet: context-path: /dev/api - forward-headers-strategy: framework apple-login: url: https://appleid.apple.com diff --git a/backend/application/api/src/main/resources/application-security-live.yml b/backend/application/api/src/main/resources/application-security-live.yml index 1cb37055..d1f149fa 100644 --- a/backend/application/api/src/main/resources/application-security-live.yml +++ b/backend/application/api/src/main/resources/application-security-live.yml @@ -4,8 +4,6 @@ server: port: 8080 servlet: context-path: /live/api - forward-headers-strategy: framework - apple-login: url: https://appleid.apple.com diff --git a/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/user/User.kt b/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/user/User.kt index cd821758..261cbb91 100644 --- a/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/user/User.kt +++ b/backend/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/user/User.kt @@ -43,7 +43,7 @@ class User( @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long? = null, ) : BaseEntity() { - fun updateNicknameAndBirth(nickname: String, birth: LocalDate): User { + fun updateNicknameAndBirth(nickname: String, birth: LocalDate?): User { return User( email = email, nickname = nickname,