diff --git a/api/build.gradle.kts b/api/build.gradle.kts index dd547fe9..8557f8dd 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -6,6 +6,7 @@ plugins { dependencies { implementation(project(":core")) + implementation(libs.kotlin.logging) implementation(libs.spring.web) implementation(libs.spring.security) implementation(libs.spring.validation) diff --git a/api/src/main/kotlin/com/retoday/api/domain/auth/dto/request/LoginRequest.kt b/api/src/main/kotlin/com/retoday/api/domain/auth/dto/request/LoginRequest.kt index 46754e56..b942c396 100644 --- a/api/src/main/kotlin/com/retoday/api/domain/auth/dto/request/LoginRequest.kt +++ b/api/src/main/kotlin/com/retoday/api/domain/auth/dto/request/LoginRequest.kt @@ -2,14 +2,14 @@ package com.retoday.api.domain.auth.dto.request import com.fasterxml.jackson.annotation.JsonProperty import com.retoday.core.domain.auth.dto.command.LoginCommand -import com.retoday.core.domain.user.entity.Provider +import com.retoday.core.domain.user.entity.SocialProvider import jakarta.validation.constraints.NotBlank data class LoginRequest( @field:NotBlank @get:JsonProperty("oAuthToken") val oAuthToken: String, - val provider: Provider + val provider: SocialProvider ) { fun toCommand(): LoginCommand = LoginCommand( diff --git a/api/src/main/kotlin/com/retoday/api/global/security/RetodayAuthentication.kt b/api/src/main/kotlin/com/retoday/api/global/security/RetodayAuthentication.kt index 46412922..7190b631 100644 --- a/api/src/main/kotlin/com/retoday/api/global/security/RetodayAuthentication.kt +++ b/api/src/main/kotlin/com/retoday/api/global/security/RetodayAuthentication.kt @@ -1,7 +1,7 @@ package com.retoday.api.global.security import com.retoday.core.domain.user.entity.Role -import com.retoday.core.domain.user.entity.User +import com.retoday.core.global.jwt.JwtProvider import org.springframework.security.core.Authentication import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority @@ -16,9 +16,9 @@ data class RetodayAuthentication( fun from(payload: Map): RetodayAuthentication = with(payload) { RetodayAuthentication( - id = (get(User::id.name) as String).toLong(), + id = (get(JwtProvider.USER_ID_CLAIM) as String).toLong(), roles = - (get(User::roles.name) as String) + (get(JwtProvider.USER_ROLES_CLAIM) as String) .split(',') .map { Role.valueOf(it) } .toSet() diff --git a/api/src/main/resources/application.yaml b/api/src/main/resources/application.yaml index 636986bc..37e3f885 100644 --- a/api/src/main/resources/application.yaml +++ b/api/src/main/resources/application.yaml @@ -56,6 +56,8 @@ server: port: ${LOCAL_PORT:8080} web: uris: http://localhost:3000 +extension: + uris: http://sdadaa --- spring: config: diff --git a/api/src/testFixtures/kotlin/com/retoday/api/fixture/AuthFixtures.kt b/api/src/testFixtures/kotlin/com/retoday/api/fixture/AuthFixtures.kt index 8e7f004f..4878227c 100644 --- a/api/src/testFixtures/kotlin/com/retoday/api/fixture/AuthFixtures.kt +++ b/api/src/testFixtures/kotlin/com/retoday/api/fixture/AuthFixtures.kt @@ -3,8 +3,8 @@ package com.retoday.api.fixture import com.retoday.api.domain.auth.dto.request.LoginRequest import com.retoday.api.domain.auth.dto.request.RefreshRequest import com.retoday.api.global.security.RetodayAuthentication -import com.retoday.core.domain.user.entity.Provider import com.retoday.core.domain.user.entity.Role +import com.retoday.core.domain.user.entity.SocialProvider import com.retoday.core.fixture.ID import com.retoday.core.fixture.PROVIDER import com.retoday.core.fixture.ROLES @@ -21,7 +21,7 @@ fun createRetodayAuthentication( fun createLoginRequest( oAuthToken: String = TOKEN, - provider: Provider = PROVIDER + provider: SocialProvider = PROVIDER ): LoginRequest = LoginRequest( oAuthToken = oAuthToken, diff --git a/build.gradle.kts b/build.gradle.kts index e68570c3..b48ee8b4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,7 @@ plugins { alias(libs.plugins.kotlin.spring) apply false alias(libs.plugins.kotlin.lint) apply false alias(libs.plugins.spring.boot) apply false - alias(libs.plugins.spring.dependency.management) + alias(libs.plugins.spring.dependency.management) apply false } allprojects { @@ -51,8 +51,6 @@ subprojects { } dependencies { - implementation(rootProject.libs.kotlin.logging) - implementation(rootProject.libs.kotlin.reflect) testImplementation(rootProject.libs.bundles.test) } diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 0c30be7b..d84fcea4 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -1,32 +1,25 @@ plugins { - alias(libs.plugins.kotlin.jpa) -} - -allOpen { - annotation("jakarta.persistence.Entity") - annotation("jakarta.persistence.MappedSuperclass") - annotation("jakarta.persistence.Embeddable") + alias(libs.plugins.flyway) } dependencies { implementation(libs.spring.web) - implementation(libs.spring.data.jpa) + implementation(libs.spring.data.jdbc) implementation(libs.spring.data.redis) implementation(libs.spring.log4j2) - implementation(libs.hypersistence.utils) + implementation(libs.kotlin.logging) + implementation(libs.flyway.core) implementation(libs.google.gemini) implementation(libs.jackson.kotlin) - implementation(libs.bundles.jdsl) implementation(libs.bundles.jwt) - runtimeOnly(libs.mysql.connector) + runtimeOnly(libs.mysql.driver) + runtimeOnly(libs.flyway.mysql) testImplementation(libs.spring.test) - testImplementation(libs.h2) - testFixturesImplementation(libs.spring.data.jpa) - testFixturesImplementation(libs.jdsl.jpa) + testFixturesImplementation(libs.spring.data.jdbc) testFixturesImplementation(libs.bundles.test) testFixturesImplementation(libs.bundles.spring.test) - testFixturesImplementation(libs.bundles.test.containers) + testFixturesImplementation(libs.bundles.testcontainers) } tasks { diff --git a/core/src/main/kotlin/com/retoday/core/domain/auth/client/GoogleClient.kt b/core/src/main/kotlin/com/retoday/core/domain/auth/client/GoogleClient.kt index e12602c5..4ac3bf38 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/auth/client/GoogleClient.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/auth/client/GoogleClient.kt @@ -2,7 +2,7 @@ package com.retoday.core.domain.auth.client import com.retoday.core.domain.auth.dto.response.GetOAuthUserResponse import com.retoday.core.domain.auth.exception.InvalidOAuthTokenException -import com.retoday.core.domain.user.entity.Provider +import com.retoday.core.domain.user.entity.SocialProvider import com.retoday.core.global.annotation.Client import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus @@ -12,7 +12,7 @@ import org.springframework.web.client.requiredBody @Client class GoogleClient( private val restClient: RestClient -) : OAuthClient(provider = Provider.GOOGLE) { +) : OAuthClient(provider = SocialProvider.GOOGLE) { private companion object { const val USERINFO_ENDPOINT = "https://openidconnect.googleapis.com/v1/userinfo" const val ID_FIELD = "sub" diff --git a/core/src/main/kotlin/com/retoday/core/domain/auth/client/OAuthClient.kt b/core/src/main/kotlin/com/retoday/core/domain/auth/client/OAuthClient.kt index 78c99561..fec6048d 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/auth/client/OAuthClient.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/auth/client/OAuthClient.kt @@ -1,10 +1,10 @@ package com.retoday.core.domain.auth.client import com.retoday.core.domain.auth.dto.response.GetOAuthUserResponse -import com.retoday.core.domain.user.entity.Provider +import com.retoday.core.domain.user.entity.SocialProvider abstract class OAuthClient( - val provider: Provider + val provider: SocialProvider ) { protected companion object { const val AUTHORIZATION_HEADER_PREFIX = "Bearer " diff --git a/core/src/main/kotlin/com/retoday/core/domain/auth/dto/command/LoginCommand.kt b/core/src/main/kotlin/com/retoday/core/domain/auth/dto/command/LoginCommand.kt index c8eeb06a..b57c745b 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/auth/dto/command/LoginCommand.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/auth/dto/command/LoginCommand.kt @@ -1,8 +1,8 @@ package com.retoday.core.domain.auth.dto.command -import com.retoday.core.domain.user.entity.Provider +import com.retoday.core.domain.user.entity.SocialProvider data class LoginCommand( val oAuthToken: String, - val provider: Provider + val provider: SocialProvider ) diff --git a/core/src/main/kotlin/com/retoday/core/domain/auth/dto/response/GetOAuthUserResponse.kt b/core/src/main/kotlin/com/retoday/core/domain/auth/dto/response/GetOAuthUserResponse.kt index 5d3715e5..8d04ba51 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/auth/dto/response/GetOAuthUserResponse.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/auth/dto/response/GetOAuthUserResponse.kt @@ -1,10 +1,10 @@ package com.retoday.core.domain.auth.dto.response -import com.retoday.core.domain.user.entity.Provider +import com.retoday.core.domain.user.entity.SocialProvider data class GetOAuthUserResponse( val id: String, - val provider: Provider, + val provider: SocialProvider, val email: String, val firstName: String, val lastName: String, diff --git a/core/src/main/kotlin/com/retoday/core/domain/auth/service/AuthService.kt b/core/src/main/kotlin/com/retoday/core/domain/auth/service/AuthService.kt index cb5947dd..e7230e87 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/auth/service/AuthService.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/auth/service/AuthService.kt @@ -50,11 +50,11 @@ class AuthService( }.let { userRepository.save(it) } profileRepository - .findByUserId(user.id!!) + .findByUserId(user.id) ?.apply { synchronizeOAuthUser(getOAuthUserResponse) } .orElse { Profile( - userId = user.id!!, + userId = user.id, firstName = getOAuthUserResponse.firstName, lastName = getOAuthUserResponse.lastName, imageUrl = getOAuthUserResponse.imageUrl @@ -106,7 +106,7 @@ class AuthService( .also { refreshTokenRepository.save( RefreshToken( - userId = id!!, + userId = id, content = it, expiration = jwtProperties.refreshTokenExpiration.seconds ) diff --git a/core/src/main/kotlin/com/retoday/core/domain/history/entity/Category.kt b/core/src/main/kotlin/com/retoday/core/domain/history/entity/Category.kt deleted file mode 100644 index 99250c76..00000000 --- a/core/src/main/kotlin/com/retoday/core/domain/history/entity/Category.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.retoday.core.domain.history.entity - -enum class Category( - val label: String -) { - STUDY("학습"), - SHOPPING("쇼핑"), - GAME("게임"), - CONTENT("콘텐츠"), - COMMUNITY("커뮤니티"), - NEWS("뉴스/시사"), - FINANCE("금융/자산"), - LIFE("생활/편의"), - SURFING("웹서핑"), - DESIGN("디자인"), - AI("AI"), - DEVELOPMENT("개발"), - ETC("기타"); - - companion object { - private val byLabel: Map = entries.associateBy { it.label } - - fun fromLabel(label: String?): Category? = label?.trim()?.let { byLabel[it] } - } -} diff --git a/core/src/main/kotlin/com/retoday/core/domain/history/entity/History.kt b/core/src/main/kotlin/com/retoday/core/domain/history/entity/History.kt index 36051bf9..5c65ff14 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/history/entity/History.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/history/entity/History.kt @@ -1,23 +1,11 @@ package com.retoday.core.domain.history.entity import com.retoday.core.global.entity.BaseEntity -import io.hypersistence.utils.hibernate.id.Tsid -import jakarta.persistence.Entity -import jakarta.persistence.Id -import jakarta.persistence.Index -import jakarta.persistence.Table +import org.springframework.data.relational.core.mapping.Table import java.time.Instant -@Entity -@Table( - indexes = [ - Index(name = "idx_user_id_visited_at", columnList = "user_id, visited_at") - ] -) -class History( - @Id - @Tsid - val id: Long? = null, +@Table("history") +data class History( val userId: Long, val websiteId: Long, val pageId: Long, diff --git a/core/src/main/kotlin/com/retoday/core/domain/history/entity/Page.kt b/core/src/main/kotlin/com/retoday/core/domain/history/entity/Page.kt index b30d39bb..59d83e4c 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/history/entity/Page.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/history/entity/Page.kt @@ -1,21 +1,13 @@ package com.retoday.core.domain.history.entity import com.retoday.core.global.entity.BaseEntity -import io.hypersistence.utils.hibernate.id.Tsid -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.Id +import org.springframework.data.relational.core.mapping.Table +import java.time.Instant -@Entity -class Page( - @Id - @Tsid - val id: Long? = null, +@Table("page") +data class Page( val websiteId: Long, - @Column(nullable = false, length = 768, unique = true) val url: String, - @Column(length = 500) - var title: String? = null, - @Column(columnDefinition = "TEXT") - var description: String? = null + val title: String? = null, + val description: String? = null ) : BaseEntity() diff --git a/core/src/main/kotlin/com/retoday/core/domain/history/entity/Website.kt b/core/src/main/kotlin/com/retoday/core/domain/history/entity/Website.kt index 778cd4cf..101cf033 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/history/entity/Website.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/history/entity/Website.kt @@ -1,27 +1,19 @@ package com.retoday.core.domain.history.entity import com.retoday.core.global.entity.BaseEntity -import io.hypersistence.utils.hibernate.id.Tsid -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.Id +import org.springframework.data.relational.core.mapping.Table -@Entity -class Website( - @Id - @Tsid - val id: Long? = null, - @Column(nullable = false, unique = true) +@Table("website") +data class Website( val domain: String, var categoryId: Long? = null, - @Column(length = 500) var faviconUrl: String? = null ) : BaseEntity() { - fun updateCategory(newCategoryId: Long?) { - this.categoryId = newCategoryId + fun updateCategory(categoryId: Long?) { + this.categoryId = categoryId } - fun updateFaviconUrl(newFaviconUrl: String) { - this.faviconUrl = newFaviconUrl + fun updateFaviconUrl(faviconUrl: String) { + this.faviconUrl = faviconUrl } } diff --git a/core/src/main/kotlin/com/retoday/core/domain/history/entity/WebsiteCategory.kt b/core/src/main/kotlin/com/retoday/core/domain/history/entity/WebsiteCategory.kt index 9971ec9f..c92dd158 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/history/entity/WebsiteCategory.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/history/entity/WebsiteCategory.kt @@ -1,21 +1,10 @@ package com.retoday.core.domain.history.entity import com.retoday.core.global.entity.BaseEntity -import io.hypersistence.utils.hibernate.id.Tsid -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.EnumType -import jakarta.persistence.Enumerated -import jakarta.persistence.Id +import org.springframework.data.relational.core.mapping.Table -@Entity -class WebsiteCategory( - @Id - @Tsid - val id: Long? = null, - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 30, unique = true) +@Table("website_category") +data class WebsiteCategory( val code: WebsiteCategoryCode, - @Column(nullable = false, length = 50, unique = true) val name: String ) : BaseEntity() diff --git a/core/src/main/kotlin/com/retoday/core/domain/history/entity/WebsiteCategoryCode.kt b/core/src/main/kotlin/com/retoday/core/domain/history/entity/WebsiteCategoryCode.kt index 20f8eb59..52074f2f 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/history/entity/WebsiteCategoryCode.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/history/entity/WebsiteCategoryCode.kt @@ -15,5 +15,11 @@ enum class WebsiteCategoryCode( DESIGN("디자인"), DEVELOPMENT("개발"), AI("AI"), - OTHER("기타") + ETC("기타"); + + companion object { + private val byLabel: Map = entries.associateBy { it.defaultName } + + fun fromLabel(label: String?): WebsiteCategoryCode? = label?.trim()?.let { byLabel[it] } + } } diff --git a/core/src/main/kotlin/com/retoday/core/domain/history/repository/CustomHistoryRepository.kt b/core/src/main/kotlin/com/retoday/core/domain/history/repository/CustomHistoryRepository.kt new file mode 100644 index 00000000..e79b10af --- /dev/null +++ b/core/src/main/kotlin/com/retoday/core/domain/history/repository/CustomHistoryRepository.kt @@ -0,0 +1,164 @@ +package com.retoday.core.domain.history.repository + +import com.retoday.core.domain.history.dto.projection.WebsiteStatProjection +import com.retoday.core.domain.history.dto.projection.WebsiteStatWithCategoryProjection +import com.retoday.core.domain.history.dto.projection.WebsiteStatWithVisitCountProjection +import com.retoday.core.domain.history.dto.projection.WorkPatternHourlyCountProjection +import com.retoday.core.domain.recap.dto.projection.UserActivityProjection +import com.retoday.core.domain.recap.dto.projection.UserTimelineProjection +import org.springframework.data.jdbc.repository.query.Query +import java.time.Instant + +interface CustomHistoryRepository { + @Query( + """ + SELECT + TIMESTAMPDIFF(HOUR, :startedAt, h.visited_at) AS hour, + COUNT(*) AS count + FROM history h + WHERE h.user_id = :userId + AND h.visited_at >= :startedAt + AND h.visited_at < DATE_ADD(:startedAt, INTERVAL 1 DAY) + GROUP BY hour + ORDER BY hour + """ + ) + fun findHourlyHistoryCountsByUserId( + userId: Long, + startedAt: Instant + ): List + + @Query( + """ + SELECT + w.domain AS domain, + w.favicon_url AS favicon_url, + SUM( + TIMESTAMPDIFF( + SECOND, + GREATEST(h.visited_at, :startedAt), + LEAST(h.closed_at, :endedAt) + ) + ) AS stay_duration + FROM history h + JOIN website w ON w.id = h.website_id + WHERE h.user_id = :userId + AND h.visited_at < :endedAt + AND h.closed_at > :startedAt + GROUP BY h.website_id + ORDER BY stay_duration DESC + LIMIT 1 + """ + ) + fun findTopWebsiteStatByUserId( + userId: Long, + startedAt: Instant, + endedAt: Instant + ): WebsiteStatProjection? + + @Query( + """ + SELECT + w.domain AS domain, + w.favicon_url AS favicon_url, + wc.name AS category_name, + SUM( + TIMESTAMPDIFF( + SECOND, + GREATEST(h.visited_at, :startedAt), + LEAST(h.closed_at, :endedAt) + ) + ) AS stay_duration + FROM history h + JOIN website w ON w.id = h.website_id + LEFT JOIN website_category wc ON wc.id = w.category_id + WHERE h.user_id = :userId + AND h.visited_at < :endedAt + AND h.closed_at > :startedAt + GROUP BY h.website_id + ORDER BY stay_duration DESC + """ + ) + fun findWebsiteStatsWithCategoryByUserId( + userId: Long, + startedAt: Instant, + endedAt: Instant + ): List + + @Query( + """ + SELECT + w.domain AS domain, + w.favicon_url AS favicon_url, + COUNT(*) AS visit_count, + SUM( + TIMESTAMPDIFF( + SECOND, + GREATEST(h.visited_at, :startedAt), + LEAST(h.closed_at, :endedAt) + ) + ) AS stay_duration + FROM history h + JOIN website w ON w.id = h.website_id + WHERE h.user_id = :userId + AND h.visited_at < :endedAt + AND h.closed_at > :startedAt + GROUP BY h.website_id + ORDER BY visit_count DESC, stay_duration DESC + LIMIT :limit + """ + ) + fun findWebsiteStatsWithVisitCountByUserId( + userId: Long, + startedAt: Instant, + endedAt: Instant, + limit: Int + ): List + + @Query( + """ + SELECT + p.title AS title, + p.description AS description, + w.domain AS domain, + wc.name AS category_name, + TIMESTAMPDIFF(MINUTE, h.visited_at, h.closed_at) AS stay_duration + FROM history h + JOIN page p ON p.id = h.page_id + JOIN website w ON w.id = h.website_id + LEFT JOIN website_category wc ON wc.id = w.category_id + WHERE h.user_id = :userId + AND h.visited_at >= :startedAt + AND h.visited_at < :endedAt + """ + ) + fun findUserActivitiesForRecap( + userId: Long, + startedAt: Instant, + endedAt: Instant + ): List + + @Query( + """ + SELECT + p.title AS title, + p.description AS description, + wc.name AS category_name, + h.visited_at AS visited_at, + h.closed_at AS closed_at + FROM history h + JOIN page p ON p.id = h.page_id + JOIN website w ON w.id = h.website_id + LEFT JOIN website_category wc ON wc.id = w.category_id + WHERE h.user_id = :userId + AND h.visited_at >= :startedAt + AND h.visited_at < :endedAt + ORDER BY h.visited_at + """ + ) + fun findUserTimelinesForRecap( + userId: Long, + startedAt: Instant, + endedAt: Instant + ): List +} diff --git a/core/src/main/kotlin/com/retoday/core/domain/history/repository/HistoryRepository.kt b/core/src/main/kotlin/com/retoday/core/domain/history/repository/HistoryRepository.kt index f759db23..ccdc9f05 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/history/repository/HistoryRepository.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/history/repository/HistoryRepository.kt @@ -1,18 +1,10 @@ package com.retoday.core.domain.history.repository -import com.retoday.core.domain.history.dto.projection.WebsiteStatProjection -import com.retoday.core.domain.history.dto.projection.WebsiteStatWithCategoryProjection -import com.retoday.core.domain.history.dto.projection.WebsiteStatWithVisitCountProjection -import com.retoday.core.domain.history.dto.projection.WorkPatternHourlyCountProjection import com.retoday.core.domain.history.entity.History -import com.retoday.core.domain.recap.dto.projection.UserActivityProjection -import com.retoday.core.domain.recap.dto.projection.UserTimelineProjection -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.Query -import org.springframework.data.repository.query.Param +import org.springframework.data.repository.ListCrudRepository import java.time.Instant -interface HistoryRepository : JpaRepository { +interface HistoryRepository : ListCrudRepository, CustomHistoryRepository { fun findByUserIdAndPageIdAndVisitedAtAfter( userId: Long, pageId: Long, @@ -24,198 +16,4 @@ interface HistoryRepository : JpaRepository { visitedAt: Instant, closedAt: Instant ): List - - fun findFirstByUserIdAndVisitedAtGreaterThanEqualAndVisitedAtLessThanOrderByVisitedAtAsc( - userId: Long, - startedAt: Instant, - endedAt: Instant - ): History? - - fun findFirstByUserIdAndVisitedAtGreaterThanEqualAndVisitedAtLessThanOrderByClosedAtDesc( - userId: Long, - startedAt: Instant, - endedAt: Instant - ): History? - - @Query( - """ - SELECT - CAST(TIMESTAMPDIFF(HOUR, :startedAt, h.visited_at) AS SIGNED) AS hour, - CAST(COUNT(*) AS SIGNED) AS count - FROM history h - WHERE h.user_id = :userId - AND h.visited_at >= :startedAt - AND h.visited_at < DATE_ADD(:startedAt, INTERVAL 1 DAY) - GROUP BY hour - ORDER BY hour - """, - nativeQuery = true - ) - fun findHourlyHistoryCountsByUserId( - @Param("userId") - userId: Long, - @Param("startedAt") - startedAt: Instant - ): List - - @Query( - """ - SELECT - w.domain AS domain, - w.favicon_url AS faviconUrl, - CAST( - SUM( - TIMESTAMPDIFF( - SECOND, - GREATEST(h.visited_at, :startedAt), - LEAST(h.closed_at, :endedAt) - ) - ) AS SIGNED - ) AS stayDuration - FROM history h - JOIN website w ON w.id = h.website_id - WHERE h.user_id = :userId - AND h.visited_at BETWEEN DATE_SUB(:startedAt, INTERVAL 1 DAY) AND :endedAt - AND h.closed_at BETWEEN :startedAt AND DATE_ADD(:endedAt, INTERVAL 1 DAY) - GROUP BY h.website_id, w.domain, w.favicon_url - ORDER BY stayDuration DESC - LIMIT 1 - """, - nativeQuery = true - ) - fun findTopWebsiteStatByUserId( - @Param("userId") - userId: Long, - @Param("startedAt") - startedAt: Instant, - @Param("endedAt") - endedAt: Instant - ): WebsiteStatProjection? - - @Query( - """ - SELECT - w.domain AS domain, - w.favicon_url AS faviconUrl, - wc.name AS categoryName, - CAST( - SUM( - TIMESTAMPDIFF( - SECOND, - GREATEST(h.visited_at, :startedAt), - LEAST(h.closed_at, :endedAt) - ) - ) AS SIGNED - ) AS stayDuration - FROM history h - JOIN website w ON w.id = h.website_id - LEFT JOIN website_category wc ON wc.id = w.category_id - WHERE h.user_id = :userId - AND h.visited_at BETWEEN DATE_SUB(:startedAt, INTERVAL 1 DAY) AND :endedAt - AND h.closed_at BETWEEN :startedAt AND DATE_ADD(:endedAt, INTERVAL 1 DAY) - GROUP BY h.website_id, w.domain, w.favicon_url, wc.name - ORDER BY stayDuration DESC - """, - nativeQuery = true - ) - fun findWebsiteStatsWithCategoryByUserId( - @Param("userId") - userId: Long, - @Param("startedAt") - startedAt: Instant, - @Param("endedAt") - endedAt: Instant - ): List - - @Query( - """ - SELECT - w.domain AS domain, - w.favicon_url AS faviconUrl, - CAST(COUNT(*) AS SIGNED) AS visitCount, - CAST( - SUM( - TIMESTAMPDIFF( - SECOND, - GREATEST(h.visited_at, :startedAt), - LEAST(h.closed_at, :endedAt) - ) - ) AS SIGNED - ) AS stayDuration - FROM history h - JOIN website w ON w.id = h.website_id - WHERE h.user_id = :userId - AND h.visited_at BETWEEN DATE_SUB(:startedAt, INTERVAL 1 DAY) AND :endedAt - AND h.closed_at BETWEEN :startedAt AND DATE_ADD(:endedAt, INTERVAL 1 DAY) - GROUP BY h.website_id, w.domain, w.favicon_url - ORDER BY visitCount DESC, stayDuration DESC - LIMIT :limit - """, - nativeQuery = true - ) - fun findWebsiteStatsWithVisitCountByUserId( - @Param("userId") - userId: Long, - @Param("startedAt") - startedAt: Instant, - @Param("endedAt") - endedAt: Instant, - @Param("limit") - limit: Int - ): List - - @Query( - """ - SELECT - p.title AS title, - p.description AS description, - w.domain AS domain, - wc.name AS categoryName, - CAST(TIMESTAMPDIFF(MINUTE, h.visited_at, h.closed_at) AS SIGNED) AS stayDuration - FROM history h - JOIN page p ON p.id = h.page_id - JOIN website w ON w.id = h.website_id - LEFT JOIN website_category wc ON wc.id = w.category_id - WHERE h.user_id = :userId - AND h.visited_at >= :startedAt - AND h.visited_at < :endedAt - """, - nativeQuery = true - ) - fun findUserActivitiesForRecap( - @Param("userId") - userId: Long, - @Param("startedAt") - startedAt: Instant, - @Param("endedAt") - endedAt: Instant - ): List - - @Query( - """ - SELECT new com.retoday.core.domain.recap.dto.projection.UserTimelineProjection( - p.title, - p.description, - wc.name, - h.visitedAt, - h.closedAt - ) - FROM History h - JOIN Page p ON p.id = h.pageId - JOIN Website w ON w.id = h.websiteId - LEFT JOIN WebsiteCategory wc ON wc.id = w.categoryId - WHERE h.userId = :userId - AND h.visitedAt >= :startedAt - AND h.visitedAt < :endedAt - ORDER BY h.visitedAt - """ - ) - fun findUserTimelinesForRecap( - @Param("userId") - userId: Long, - @Param("startedAt") - startedAt: Instant, - @Param("endedAt") - endedAt: Instant - ): List } diff --git a/core/src/main/kotlin/com/retoday/core/domain/history/repository/PageRepository.kt b/core/src/main/kotlin/com/retoday/core/domain/history/repository/PageRepository.kt index 875f4525..747115ca 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/history/repository/PageRepository.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/history/repository/PageRepository.kt @@ -1,8 +1,8 @@ package com.retoday.core.domain.history.repository import com.retoday.core.domain.history.entity.Page -import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.repository.ListCrudRepository -interface PageRepository : JpaRepository { +interface PageRepository : ListCrudRepository { fun findByUrl(url: String): Page? } diff --git a/core/src/main/kotlin/com/retoday/core/domain/history/repository/WebsiteCategoryRepository.kt b/core/src/main/kotlin/com/retoday/core/domain/history/repository/WebsiteCategoryRepository.kt index b1e69306..0dd276ed 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/history/repository/WebsiteCategoryRepository.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/history/repository/WebsiteCategoryRepository.kt @@ -2,10 +2,10 @@ package com.retoday.core.domain.history.repository import com.retoday.core.domain.history.entity.WebsiteCategory import com.retoday.core.domain.history.entity.WebsiteCategoryCode -import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.repository.ListCrudRepository import org.springframework.stereotype.Repository @Repository -interface WebsiteCategoryRepository : JpaRepository { +interface WebsiteCategoryRepository : ListCrudRepository { fun findByCode(code: WebsiteCategoryCode): WebsiteCategory? } diff --git a/core/src/main/kotlin/com/retoday/core/domain/history/repository/WebsiteRepository.kt b/core/src/main/kotlin/com/retoday/core/domain/history/repository/WebsiteRepository.kt index 86d276d1..aa7aa642 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/history/repository/WebsiteRepository.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/history/repository/WebsiteRepository.kt @@ -1,10 +1,10 @@ package com.retoday.core.domain.history.repository import com.retoday.core.domain.history.entity.Website -import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.repository.ListCrudRepository import org.springframework.stereotype.Repository @Repository -interface WebsiteRepository : JpaRepository { +interface WebsiteRepository : ListCrudRepository { fun findByDomain(domain: String): Website? } diff --git a/core/src/main/kotlin/com/retoday/core/domain/history/service/HistoryService.kt b/core/src/main/kotlin/com/retoday/core/domain/history/service/HistoryService.kt index 6dcbbc40..81cd7791 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/history/service/HistoryService.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/history/service/HistoryService.kt @@ -2,16 +2,8 @@ package com.retoday.core.domain.history.service import com.retoday.core.domain.history.client.AICategoryClient import com.retoday.core.domain.history.dto.command.HistoryRecordCommand -import com.retoday.core.domain.history.dto.query.GetMyCategoryAnalysisQuery -import com.retoday.core.domain.history.dto.query.GetMyFrequentlyVisitedWebsitesQuery -import com.retoday.core.domain.history.dto.query.GetMyLongestStayedWebsiteQuery -import com.retoday.core.domain.history.dto.query.GetMyScreenTimesQuery -import com.retoday.core.domain.history.dto.query.GetMyWorkPatternQuery +import com.retoday.core.domain.history.dto.query.* import com.retoday.core.domain.history.dto.result.* -import com.retoday.core.domain.history.dto.result.GetMyCategoryAnalysesResult -import com.retoday.core.domain.history.dto.result.GetMyLongestStayedWebsiteResult -import com.retoday.core.domain.history.dto.result.GetMyScreenTimesResult -import com.retoday.core.domain.history.dto.result.HistoryRecordResult import com.retoday.core.domain.history.entity.History import com.retoday.core.domain.history.entity.Website import com.retoday.core.domain.history.entity.WebsiteCategoryCode @@ -72,22 +64,22 @@ class HistoryService( val website = websiteService.findOrCreate(command.domain, command.faviconUrl) val page = pageService.findOrCreate( - websiteId = website.id!!, + websiteId = website.id, url = command.normalizedUrl, title = command.title, description = command.description ) - checkDuplicateHistory(userId, page.id!!, command.visitedAt, command.tabId, command.normalizedUrl) + checkDuplicateHistory(userId, page.id, command.visitedAt, command.tabId, command.normalizedUrl) historyRepository - .save(createHistory(userId, website.id!!, page.id!!, command)) + .save(createHistory(userId, website.id, page.id, command)) .let { HistoryRecordResult( - historyId = it.id!!, - pageId = page.id!!, - websiteId = website.id!!, - recordedAt = it.createdAt + historyId = it.id, + pageId = page.id, + websiteId = website.id, + recordedAt = it.createdAt!! ) } } @@ -387,7 +379,8 @@ class HistoryService( .findByCode(predictedCode.toCategoryCodeOrNull() ?: throw InvalidCategoryException()) ?: throw InvalidCategoryException() - website.updateCategory(category.id!!) + website.updateCategory(category.id) + websiteService.save(website) } private fun String.toCategoryCodeOrNull(): WebsiteCategoryCode? = diff --git a/core/src/main/kotlin/com/retoday/core/domain/history/service/WebsiteService.kt b/core/src/main/kotlin/com/retoday/core/domain/history/service/WebsiteService.kt index 908eebef..0231040a 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/history/service/WebsiteService.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/history/service/WebsiteService.kt @@ -13,6 +13,8 @@ class WebsiteService( private val websiteRepository: WebsiteRepository, private val eventPublisher: ApplicationEventPublisher ) { + fun save(website: Website): Website = websiteRepository.save(website) + @Transactional fun findOrCreate( domain: String, @@ -20,15 +22,19 @@ class WebsiteService( ): Website = websiteRepository .findByDomain(domain) - ?.also { website -> + ?.let { website -> if (website.faviconUrl == null && faviconUrl != null) { website.updateFaviconUrl(faviconUrl) + websiteRepository.save(website) + } else { + website } } ?: try { websiteRepository .save(Website(domain = domain, faviconUrl = faviconUrl)) - .also { eventPublisher.publishEvent(WebsiteCategoryClassificationEvent(it.id!!, domain)) } + .also { eventPublisher.publishEvent(WebsiteCategoryClassificationEvent(it.id, domain)) } } catch (e: DataIntegrityViolationException) { - websiteRepository.findByDomain(domain)!! + websiteRepository.findByDomain(domain) + ?: throw IllegalStateException("Website not found after duplicate key violation: $domain") } } diff --git a/core/src/main/kotlin/com/retoday/core/domain/recap/component/ImagePolicyResolver.kt b/core/src/main/kotlin/com/retoday/core/domain/recap/component/ImagePolicyResolver.kt index 5150e0eb..e7df0915 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/recap/component/ImagePolicyResolver.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/recap/component/ImagePolicyResolver.kt @@ -1,6 +1,6 @@ package com.retoday.core.domain.recap.component -import com.retoday.core.domain.history.entity.Category +import com.retoday.core.domain.history.entity.WebsiteCategoryCode import com.retoday.core.domain.recap.dto.projection.UserActivityProjection import com.retoday.core.domain.recap.entity.RecapImage import com.retoday.core.domain.recap.properties.RecapImageProperties @@ -42,8 +42,7 @@ class ImagePolicyResolver( activities: List ): RecapImage { normalizeCategory(topCategoryName)?.let { topCategory -> - if (topCategory == Category.ETC) return@let - return RecapImage.valueOf(topCategory.name) + mapRecapImage(topCategory)?.let { return it } } val totalDurationMinutes = activities.sumOf { it.stayDuration.coerceAtLeast(0) } @@ -64,5 +63,22 @@ class ImagePolicyResolver( )[Random(userId xor firstVisitedAt.epochSecond).nextInt(3)] } - private fun normalizeCategory(raw: String?): Category? = Category.fromLabel(raw) + private fun normalizeCategory(raw: String?): WebsiteCategoryCode? = WebsiteCategoryCode.fromLabel(raw) + + private fun mapRecapImage(category: WebsiteCategoryCode): RecapImage? = + when (category) { + WebsiteCategoryCode.STUDY -> RecapImage.STUDY + WebsiteCategoryCode.SHOPPING -> RecapImage.SHOPPING + WebsiteCategoryCode.GAMING -> RecapImage.GAME + WebsiteCategoryCode.CONTENT -> RecapImage.CONTENT + WebsiteCategoryCode.COMMUNITY -> RecapImage.COMMUNITY + WebsiteCategoryCode.NEWS -> RecapImage.NEWS + WebsiteCategoryCode.FINANCE -> RecapImage.FINANCE + WebsiteCategoryCode.LIFESTYLE -> RecapImage.LIFE + WebsiteCategoryCode.BROWSING -> RecapImage.SURFING + WebsiteCategoryCode.DESIGN -> RecapImage.DESIGN + WebsiteCategoryCode.AI -> RecapImage.AI + WebsiteCategoryCode.DEVELOPMENT -> RecapImage.DEVELOPMENT + WebsiteCategoryCode.ETC -> null + } } diff --git a/core/src/main/kotlin/com/retoday/core/domain/recap/dto/response/RecapDetailResponse.kt b/core/src/main/kotlin/com/retoday/core/domain/recap/dto/response/RecapDetailResponse.kt index 1ade8bf6..a9c600cf 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/recap/dto/response/RecapDetailResponse.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/recap/dto/response/RecapDetailResponse.kt @@ -2,9 +2,9 @@ package com.retoday.core.domain.recap.dto.response import com.retoday.core.domain.recap.entity.Recap import com.retoday.core.domain.recap.entity.RecapStatus -import com.retoday.core.domain.recap.entity.Section -import com.retoday.core.domain.recap.entity.Timeline -import com.retoday.core.domain.recap.entity.Topic +import com.retoday.core.domain.recap.entity.RecapSection +import com.retoday.core.domain.recap.entity.RecapTimeline +import com.retoday.core.domain.recap.entity.RecapTopic import java.time.LocalDate import java.time.LocalDateTime import java.time.ZoneId @@ -47,9 +47,9 @@ data class RecapDetailResponse( fun of( recap: Recap, - sections: List
, - timelines: List, - topics: List, + sections: List, + timelines: List, + topics: List, zoneId: ZoneId ): RecapDetailResponse = RecapDetailResponse( diff --git a/core/src/main/kotlin/com/retoday/core/domain/recap/entity/Recap.kt b/core/src/main/kotlin/com/retoday/core/domain/recap/entity/Recap.kt index e5f64fb7..9104fd6a 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/recap/entity/Recap.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/recap/entity/Recap.kt @@ -1,30 +1,19 @@ package com.retoday.core.domain.recap.entity import com.retoday.core.global.entity.BaseEntity -import io.hypersistence.utils.hibernate.id.Tsid -import jakarta.persistence.* +import org.springframework.data.relational.core.mapping.Table import java.time.Instant import java.time.LocalDate -@Entity -@Table( - name = "recap", - uniqueConstraints = [ - UniqueConstraint(name = "idx_user_recap_date", columnNames = ["user_id", "recap_date"]) - ] -) -class Recap( - @Id - @Tsid - val id: Long? = null, +@Table("recap") +data class Recap( val userId: Long, val recapDate: LocalDate, - var title: String, - var summary: String, - var imageUrl: String? = null, - var startedAt: Instant, - var closedAt: Instant, - var model: String, - @Enumerated(EnumType.STRING) - var status: RecapStatus = RecapStatus.COMPLETED + val title: String, + val summary: String, + val imageUrl: String? = null, + val startedAt: Instant, + val closedAt: Instant, + val model: String, + val status: RecapStatus = RecapStatus.COMPLETED ) : BaseEntity() diff --git a/core/src/main/kotlin/com/retoday/core/domain/recap/entity/Section.kt b/core/src/main/kotlin/com/retoday/core/domain/recap/entity/RecapSection.kt similarity index 51% rename from core/src/main/kotlin/com/retoday/core/domain/recap/entity/Section.kt rename to core/src/main/kotlin/com/retoday/core/domain/recap/entity/RecapSection.kt index 3c6cf94b..103d6f5c 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/recap/entity/Section.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/recap/entity/RecapSection.kt @@ -1,15 +1,10 @@ package com.retoday.core.domain.recap.entity import com.retoday.core.global.entity.BaseEntity -import io.hypersistence.utils.hibernate.id.Tsid -import jakarta.persistence.* +import org.springframework.data.relational.core.mapping.Table -@Entity -@Table(name = "recap_section") -class Section( - @Id - @Tsid - val id: Long? = null, +@Table("recap_section") +data class RecapSection( val recapId: Long, val title: String, val content: String diff --git a/core/src/main/kotlin/com/retoday/core/domain/recap/entity/Timeline.kt b/core/src/main/kotlin/com/retoday/core/domain/recap/entity/RecapTimeline.kt similarity index 60% rename from core/src/main/kotlin/com/retoday/core/domain/recap/entity/Timeline.kt rename to core/src/main/kotlin/com/retoday/core/domain/recap/entity/RecapTimeline.kt index 4b1838d3..e038466c 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/recap/entity/Timeline.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/recap/entity/RecapTimeline.kt @@ -1,16 +1,11 @@ package com.retoday.core.domain.recap.entity import com.retoday.core.global.entity.BaseEntity -import io.hypersistence.utils.hibernate.id.Tsid -import jakarta.persistence.* +import org.springframework.data.relational.core.mapping.Table import java.time.LocalTime -@Entity -@Table(name = "recap_timeline") -class Timeline( - @Id - @Tsid - val id: Long? = null, +@Table("recap_timeline") +data class RecapTimeline( val recapId: Long, val startedAt: LocalTime, val endedAt: LocalTime, diff --git a/core/src/main/kotlin/com/retoday/core/domain/recap/entity/Topic.kt b/core/src/main/kotlin/com/retoday/core/domain/recap/entity/RecapTopic.kt similarity index 54% rename from core/src/main/kotlin/com/retoday/core/domain/recap/entity/Topic.kt rename to core/src/main/kotlin/com/retoday/core/domain/recap/entity/RecapTopic.kt index c9f1fbf0..ebd8fdbd 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/recap/entity/Topic.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/recap/entity/RecapTopic.kt @@ -1,15 +1,10 @@ package com.retoday.core.domain.recap.entity import com.retoday.core.global.entity.BaseEntity -import io.hypersistence.utils.hibernate.id.Tsid -import jakarta.persistence.* +import org.springframework.data.relational.core.mapping.Table -@Entity -@Table(name = "recap_topic") -class Topic( - @Id - @Tsid - val id: Long? = null, +@Table("recap_topic") +data class RecapTopic( val recapId: Long, val keyword: String, val title: String, diff --git a/core/src/main/kotlin/com/retoday/core/domain/recap/repository/RecapRepository.kt b/core/src/main/kotlin/com/retoday/core/domain/recap/repository/RecapRepository.kt index 6a738b20..8b976011 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/recap/repository/RecapRepository.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/recap/repository/RecapRepository.kt @@ -1,10 +1,10 @@ package com.retoday.core.domain.recap.repository import com.retoday.core.domain.recap.entity.Recap -import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.repository.ListCrudRepository import java.time.LocalDate -interface RecapRepository : JpaRepository { +interface RecapRepository : ListCrudRepository { // 특정 유저의 특정 날짜 리캡 조회 (하루 단위 조회용) fun findByUserIdAndRecapDate( userId: Long, diff --git a/core/src/main/kotlin/com/retoday/core/domain/recap/repository/SectionRepository.kt b/core/src/main/kotlin/com/retoday/core/domain/recap/repository/SectionRepository.kt index 2e277244..1c362743 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/recap/repository/SectionRepository.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/recap/repository/SectionRepository.kt @@ -1,9 +1,9 @@ package com.retoday.core.domain.recap.repository -import com.retoday.core.domain.recap.entity.Section -import org.springframework.data.jpa.repository.JpaRepository +import com.retoday.core.domain.recap.entity.RecapSection +import org.springframework.data.repository.ListCrudRepository -interface SectionRepository : JpaRepository { +interface SectionRepository : ListCrudRepository { // 특정 리캡에 속한 모든 섹션 조회 - fun findAllByRecapId(recapId: Long): List
+ fun findAllByRecapId(recapId: Long): List } diff --git a/core/src/main/kotlin/com/retoday/core/domain/recap/repository/TimelineRepository.kt b/core/src/main/kotlin/com/retoday/core/domain/recap/repository/TimelineRepository.kt index 3a24903d..50af9ca0 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/recap/repository/TimelineRepository.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/recap/repository/TimelineRepository.kt @@ -1,8 +1,8 @@ package com.retoday.core.domain.recap.repository -import com.retoday.core.domain.recap.entity.Timeline -import org.springframework.data.jpa.repository.JpaRepository +import com.retoday.core.domain.recap.entity.RecapTimeline +import org.springframework.data.repository.ListCrudRepository -interface TimelineRepository : JpaRepository { - fun findAllByRecapId(recapId: Long): List +interface TimelineRepository : ListCrudRepository { + fun findAllByRecapId(recapId: Long): List } diff --git a/core/src/main/kotlin/com/retoday/core/domain/recap/repository/TopicRepository.kt b/core/src/main/kotlin/com/retoday/core/domain/recap/repository/TopicRepository.kt index 851b2565..7aec7fd5 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/recap/repository/TopicRepository.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/recap/repository/TopicRepository.kt @@ -1,9 +1,9 @@ package com.retoday.core.domain.recap.repository -import com.retoday.core.domain.recap.entity.Topic -import org.springframework.data.jpa.repository.JpaRepository +import com.retoday.core.domain.recap.entity.RecapTopic +import org.springframework.data.repository.ListCrudRepository -interface TopicRepository : JpaRepository { +interface TopicRepository : ListCrudRepository { // 특정 리캡에 생성된 모든 주제(Topic) 조회 - fun findAllByRecapId(recapId: Long): List + fun findAllByRecapId(recapId: Long): List } diff --git a/core/src/main/kotlin/com/retoday/core/domain/recap/service/RecapService.kt b/core/src/main/kotlin/com/retoday/core/domain/recap/service/RecapService.kt index 543f7bad..7ccd4bd0 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/recap/service/RecapService.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/recap/service/RecapService.kt @@ -18,9 +18,9 @@ import com.retoday.core.domain.recap.dto.response.GeminiTopicResponse import com.retoday.core.domain.recap.dto.response.RecapDetailResponse import com.retoday.core.domain.recap.entity.Recap import com.retoday.core.domain.recap.entity.RecapStatus -import com.retoday.core.domain.recap.entity.Section -import com.retoday.core.domain.recap.entity.Timeline -import com.retoday.core.domain.recap.entity.Topic +import com.retoday.core.domain.recap.entity.RecapSection +import com.retoday.core.domain.recap.entity.RecapTimeline +import com.retoday.core.domain.recap.entity.RecapTopic import com.retoday.core.domain.recap.repository.RecapRepository import com.retoday.core.domain.recap.repository.SectionRepository import com.retoday.core.domain.recap.repository.TimelineRepository @@ -100,7 +100,7 @@ class RecapService( var timelineResponse = GeminiTimelineResponse() if (timelineProjections.isNotEmpty()) { val timelineRequests = timelineProjections.map { it.toRequest() } - timelineResponse = generateTimeline(name, timelineRequests) + timelineResponse = generateRecapTimeline(name, timelineRequests) } val categoryAnalyses = @@ -139,8 +139,8 @@ class RecapService( val sections = recapResponse.sections .map { - Section( - recapId = recap.id!!, + RecapSection( + recapId = recap.id, title = it.title, content = it.content ) @@ -149,8 +149,8 @@ class RecapService( val topics = topicResponse.topics .map { - Topic( - recapId = recap.id!!, + RecapTopic( + recapId = recap.id, keyword = it.keyword, title = it.title, content = it.content @@ -170,8 +170,8 @@ class RecapService( .toMinutes() .toInt() - Timeline( - recapId = recap.id!!, + RecapTimeline( + recapId = recap.id, startedAt = startedAt, endedAt = endedAt, title = item.title, @@ -216,9 +216,9 @@ class RecapService( recapRepository .findByUserIdAndRecapDate(userId, date) ?.let { - val sections = sectionRepository.findAllByRecapId(it.id!!) - val topics = topicRepository.findAllByRecapId(it.id!!) - val timelines = timelineRepository.findAllByRecapId(it.id!!) + val sections = sectionRepository.findAllByRecapId(it.id) + val topics = topicRepository.findAllByRecapId(it.id) + val timelines = timelineRepository.findAllByRecapId(it.id) val zoneId = profileRepository.findByUserId(userId)!!.timeZone.id RecapDetailResponse.of(it, sections, timelines, topics, zoneId) @@ -238,7 +238,7 @@ class RecapService( GeminiRecapResponse::class.java ) - fun generateTimeline( + fun generateRecapTimeline( name: String, activities: List ) = recapAIClient.generate( diff --git a/core/src/main/kotlin/com/retoday/core/domain/user/dto/projection/ProfileWithEmailProjection.kt b/core/src/main/kotlin/com/retoday/core/domain/user/dto/projection/ProfileWithEmailProjection.kt index 4c9a3813..0a954693 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/user/dto/projection/ProfileWithEmailProjection.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/user/dto/projection/ProfileWithEmailProjection.kt @@ -1,8 +1,19 @@ package com.retoday.core.domain.user.dto.projection -import com.retoday.core.domain.user.entity.Profile +import com.retoday.core.domain.user.entity.TimeZone +import java.time.Instant +import java.time.LocalTime data class ProfileWithEmailProjection( - val profile: Profile, + val id: Long, + val userId: Long, + val firstName: String, + val lastName: String, + val imageUrl: String, + val timeZone: TimeZone, + val recapPeriod: LocalTime?, + val createdAt: Instant, + val updatedAt: Instant?, + val deletedAt: Instant?, val email: String ) diff --git a/core/src/main/kotlin/com/retoday/core/domain/user/dto/result/GetMyProfileResult.kt b/core/src/main/kotlin/com/retoday/core/domain/user/dto/result/GetMyProfileResult.kt index af325a49..c69bbfef 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/user/dto/result/GetMyProfileResult.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/user/dto/result/GetMyProfileResult.kt @@ -19,17 +19,15 @@ data class GetMyProfileResult( profileWithEmail: ProfileWithEmailProjection, excludedDomains: List ): GetMyProfileResult = - with(profileWithEmail.profile) { - GetMyProfileResult( - id = id!!, - email = profileWithEmail.email, - firstName = firstName, - lastName = lastName, - imageUrl = imageUrl, - timeZone = timeZone, - recapPeriod = recapPeriod, - excludedDomains = excludedDomains - ) - } + GetMyProfileResult( + id = profileWithEmail.id, + email = profileWithEmail.email, + firstName = profileWithEmail.firstName, + lastName = profileWithEmail.lastName, + imageUrl = profileWithEmail.imageUrl, + timeZone = profileWithEmail.timeZone, + recapPeriod = profileWithEmail.recapPeriod, + excludedDomains = excludedDomains + ) } } diff --git a/core/src/main/kotlin/com/retoday/core/domain/user/entity/Profile.kt b/core/src/main/kotlin/com/retoday/core/domain/user/entity/Profile.kt index ee4df547..2476f6cf 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/user/entity/Profile.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/user/entity/Profile.kt @@ -2,23 +2,15 @@ package com.retoday.core.domain.user.entity import com.retoday.core.domain.auth.dto.response.GetOAuthUserResponse import com.retoday.core.global.entity.BaseEntity -import io.hypersistence.utils.hibernate.id.Tsid -import jakarta.persistence.Entity -import jakarta.persistence.EnumType -import jakarta.persistence.Enumerated -import jakarta.persistence.Id +import org.springframework.data.relational.core.mapping.Table import java.time.LocalTime -@Entity -class Profile( - @Id - @Tsid - val id: Long? = null, +@Table("profile") +data class Profile( val userId: Long, var firstName: String, var lastName: String, var imageUrl: String, - @Enumerated(EnumType.STRING) val timeZone: TimeZone = TimeZone.SEOUL, val recapPeriod: LocalTime? = null ) : BaseEntity() { diff --git a/core/src/main/kotlin/com/retoday/core/domain/user/entity/Provider.kt b/core/src/main/kotlin/com/retoday/core/domain/user/entity/SocialProvider.kt similarity index 67% rename from core/src/main/kotlin/com/retoday/core/domain/user/entity/Provider.kt rename to core/src/main/kotlin/com/retoday/core/domain/user/entity/SocialProvider.kt index b884eca6..eb9edd15 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/user/entity/Provider.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/user/entity/SocialProvider.kt @@ -1,5 +1,5 @@ package com.retoday.core.domain.user.entity -enum class Provider { +enum class SocialProvider { GOOGLE } diff --git a/core/src/main/kotlin/com/retoday/core/domain/user/entity/User.kt b/core/src/main/kotlin/com/retoday/core/domain/user/entity/User.kt index d16c06a7..76b9fc67 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/user/entity/User.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/user/entity/User.kt @@ -2,23 +2,15 @@ package com.retoday.core.domain.user.entity import com.retoday.core.domain.auth.dto.response.GetOAuthUserResponse import com.retoday.core.global.entity.BaseEntity -import io.hypersistence.utils.hibernate.id.Tsid -import jakarta.persistence.* +import org.springframework.data.relational.core.mapping.Table -@Entity -@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["provider", "social_id"])]) -class User( - @Id - @Tsid - val id: Long? = null, +@Table("user") +data class User( val socialId: String, var email: String, - @Enumerated(EnumType.STRING) - val provider: Provider, - @ElementCollection - @Enumerated(EnumType.STRING) - val roles: Set = setOf(Role.MEMBER), - var isActive: Boolean = true + val provider: SocialProvider, + val roles: String = Role.MEMBER.name, + val isActive: Boolean = true ) : BaseEntity() { fun synchronizeOAuthUser(getOAuthUserResponse: GetOAuthUserResponse) { email = getOAuthUserResponse.email diff --git a/core/src/main/kotlin/com/retoday/core/domain/user/entity/UserExcludedWebsiteDomain.kt b/core/src/main/kotlin/com/retoday/core/domain/user/entity/UserExcludedWebsiteDomain.kt index caf2f98d..e30c269a 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/user/entity/UserExcludedWebsiteDomain.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/user/entity/UserExcludedWebsiteDomain.kt @@ -1,18 +1,10 @@ package com.retoday.core.domain.user.entity import com.retoday.core.global.entity.BaseEntity -import io.hypersistence.utils.hibernate.id.Tsid -import jakarta.persistence.Entity -import jakarta.persistence.Id -import jakarta.persistence.Table -import jakarta.persistence.UniqueConstraint +import org.springframework.data.relational.core.mapping.Table -@Entity -@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["user_id", "domain"])]) -class UserExcludedWebsiteDomain( - @Id - @Tsid - val id: Long? = null, +@Table("user_excluded_website_domain") +data class UserExcludedWebsiteDomain( val userId: Long, val domain: String ) : BaseEntity() diff --git a/core/src/main/kotlin/com/retoday/core/domain/user/repository/CustomProfileRepository.kt b/core/src/main/kotlin/com/retoday/core/domain/user/repository/CustomProfileRepository.kt index 8696afae..3dc0280c 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/user/repository/CustomProfileRepository.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/user/repository/CustomProfileRepository.kt @@ -1,7 +1,35 @@ package com.retoday.core.domain.user.repository import com.retoday.core.domain.user.dto.projection.ProfileWithEmailProjection +import com.retoday.core.domain.user.entity.Profile +import com.retoday.core.domain.user.entity.TimeZone +import org.springframework.data.jdbc.repository.query.Query +import org.springframework.data.repository.query.Param interface CustomProfileRepository { + @Query( + """ + SELECT + p.*, + u.email + FROM profile p + JOIN `user` u ON u.id = p.user_id + WHERE u.id = :userId + """ + ) fun findByUserIdWithEmail(userId: Long): ProfileWithEmailProjection? + + @Query( + """ + SELECT p.* + FROM profile p + JOIN `user` u ON u.id = p.user_id + WHERE p.time_zone IN (:#{#timeZones.![name()]}) + AND u.is_active = true + """ + ) + fun findAllActiveByTimeZones( + @Param("timeZones") + timeZones: Collection + ): List } diff --git a/core/src/main/kotlin/com/retoday/core/domain/user/repository/CustomProfileRepositoryImpl.kt b/core/src/main/kotlin/com/retoday/core/domain/user/repository/CustomProfileRepositoryImpl.kt deleted file mode 100644 index a7da3641..00000000 --- a/core/src/main/kotlin/com/retoday/core/domain/user/repository/CustomProfileRepositoryImpl.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.retoday.core.domain.user.repository - -import com.linecorp.kotlinjdsl.dsl.jpql.jpql -import com.linecorp.kotlinjdsl.render.RenderContext -import com.linecorp.kotlinjdsl.support.spring.data.jpa.extension.createQuery -import com.retoday.core.domain.user.dto.projection.ProfileWithEmailProjection -import com.retoday.core.domain.user.entity.Profile -import com.retoday.core.domain.user.entity.User -import jakarta.persistence.EntityManager - -class CustomProfileRepositoryImpl( - private val entityManager: EntityManager, - private val renderContext: RenderContext -) : CustomProfileRepository { - override fun findByUserIdWithEmail(userId: Long): ProfileWithEmailProjection? { - val query = - jpql { - selectNew>( - entity(Profile::class), - path(User::email) - ).from( - entity(Profile::class), - join(User::class) - .on(path(User::id).equal(path(Profile::userId))) - ).where( - path(User::id).equal(userId) - ) - } - val result = - entityManager - .createQuery(query, renderContext) - .resultList - val (profile, email) = result.firstOrNull() ?: return null - - return ProfileWithEmailProjection( - profile = profile, - email = email - ) - } -} diff --git a/core/src/main/kotlin/com/retoday/core/domain/user/repository/ProfileRepository.kt b/core/src/main/kotlin/com/retoday/core/domain/user/repository/ProfileRepository.kt index 752289d9..7dfd1417 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/user/repository/ProfileRepository.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/user/repository/ProfileRepository.kt @@ -1,32 +1,10 @@ package com.retoday.core.domain.user.repository import com.retoday.core.domain.user.entity.Profile -import com.retoday.core.domain.user.entity.TimeZone -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.Query -import org.springframework.data.repository.query.Param +import org.springframework.data.repository.ListCrudRepository import org.springframework.stereotype.Repository @Repository -interface ProfileRepository : - JpaRepository, - CustomProfileRepository { - @Query( - """ - SELECT p - FROM Profile p - WHERE p.timeZone IN :timeZones - AND p.userId IN ( - SELECT u.id - FROM User u - WHERE u.isActive = true - ) - """ - ) - fun findAllActiveByTimeZones( - @Param("timeZones") - timeZones: Collection - ): List - +interface ProfileRepository : ListCrudRepository, CustomProfileRepository { fun findByUserId(userId: Long): Profile? } diff --git a/core/src/main/kotlin/com/retoday/core/domain/user/repository/UserExcludedWebsiteRepository.kt b/core/src/main/kotlin/com/retoday/core/domain/user/repository/UserExcludedWebsiteRepository.kt index 36ec98a5..bce9c548 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/user/repository/UserExcludedWebsiteRepository.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/user/repository/UserExcludedWebsiteRepository.kt @@ -1,11 +1,11 @@ package com.retoday.core.domain.user.repository import com.retoday.core.domain.user.entity.UserExcludedWebsiteDomain -import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.repository.ListCrudRepository import org.springframework.stereotype.Repository @Repository -interface UserExcludedWebsiteRepository : JpaRepository { +interface UserExcludedWebsiteRepository : ListCrudRepository { fun findAllByUserId(userId: Long): List fun existsByUserIdAndDomain( diff --git a/core/src/main/kotlin/com/retoday/core/domain/user/repository/UserRepository.kt b/core/src/main/kotlin/com/retoday/core/domain/user/repository/UserRepository.kt index c70dbd32..3b64cb8a 100644 --- a/core/src/main/kotlin/com/retoday/core/domain/user/repository/UserRepository.kt +++ b/core/src/main/kotlin/com/retoday/core/domain/user/repository/UserRepository.kt @@ -1,16 +1,14 @@ package com.retoday.core.domain.user.repository -import com.retoday.core.domain.user.entity.Provider +import com.retoday.core.domain.user.entity.SocialProvider import com.retoday.core.domain.user.entity.User -import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.repository.ListCrudRepository import org.springframework.stereotype.Repository @Repository -interface UserRepository : JpaRepository { - fun findAllByIsActiveTrue(): List - +interface UserRepository : ListCrudRepository { fun findBySocialIdAndProvider( socialId: String, - provider: Provider + provider: SocialProvider ): User? } diff --git a/core/src/main/kotlin/com/retoday/core/global/config/JdbcConfiguration.kt b/core/src/main/kotlin/com/retoday/core/global/config/JdbcConfiguration.kt new file mode 100644 index 00000000..b11567cd --- /dev/null +++ b/core/src/main/kotlin/com/retoday/core/global/config/JdbcConfiguration.kt @@ -0,0 +1,10 @@ +package com.retoday.core.global.config + +import org.springframework.context.annotation.Configuration +import org.springframework.data.jdbc.repository.config.EnableJdbcAuditing +import org.springframework.data.jdbc.repository.config.EnableJdbcRepositories + +@Configuration +@EnableJdbcAuditing +@EnableJdbcRepositories(basePackages = ["com.retoday.core.domain"]) +class JdbcConfiguration diff --git a/core/src/main/kotlin/com/retoday/core/global/config/JpaConfiguration.kt b/core/src/main/kotlin/com/retoday/core/global/config/JpaConfiguration.kt deleted file mode 100644 index bd7a25d2..00000000 --- a/core/src/main/kotlin/com/retoday/core/global/config/JpaConfiguration.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.retoday.core.global.config - -import org.springframework.boot.autoconfigure.domain.EntityScan -import org.springframework.context.annotation.Configuration -import org.springframework.data.jpa.repository.config.EnableJpaAuditing -import org.springframework.data.jpa.repository.config.EnableJpaRepositories - -@Configuration -@EnableJpaAuditing -@EnableJpaRepositories(basePackages = ["com.retoday.core.domain"]) -@EntityScan(basePackages = ["com.retoday.core.domain"]) -class JpaConfiguration diff --git a/core/src/main/kotlin/com/retoday/core/global/entity/BaseEntity.kt b/core/src/main/kotlin/com/retoday/core/global/entity/BaseEntity.kt index e67f8b2e..6b26d416 100644 --- a/core/src/main/kotlin/com/retoday/core/global/entity/BaseEntity.kt +++ b/core/src/main/kotlin/com/retoday/core/global/entity/BaseEntity.kt @@ -1,31 +1,31 @@ package com.retoday.core.global.entity -import jakarta.persistence.Column -import jakarta.persistence.EntityListeners -import jakarta.persistence.MappedSuperclass +import com.retoday.core.global.extension.createTsid import org.springframework.data.annotation.CreatedDate +import org.springframework.data.annotation.Id import org.springframework.data.annotation.LastModifiedDate -import org.springframework.data.jpa.domain.support.AuditingEntityListener +import org.springframework.data.annotation.Transient +import org.springframework.data.domain.Persistable import java.time.Instant -@MappedSuperclass -@EntityListeners(AuditingEntityListener::class) -abstract class BaseEntity { +abstract class BaseEntity : Persistable { + @Id + var id: Long = createTsid() + @CreatedDate - @Column(nullable = false, updatable = false) - var createdAt: Instant = Instant.EPOCH - protected set + var createdAt: Instant? = null @LastModifiedDate var updatedAt: Instant? = null - protected set var deletedAt: Instant? = null - protected set + + override fun getId(): Long = id + + @Transient + override fun isNew(): Boolean = createdAt == null fun softDelete() { - this.deletedAt = Instant.now() + deletedAt = Instant.now() } - - fun isDeleted(): Boolean = deletedAt != null } diff --git a/core/src/main/kotlin/com/retoday/core/global/extension/TsidExtensions.kt b/core/src/main/kotlin/com/retoday/core/global/extension/TsidExtensions.kt new file mode 100644 index 00000000..eee1cece --- /dev/null +++ b/core/src/main/kotlin/com/retoday/core/global/extension/TsidExtensions.kt @@ -0,0 +1,24 @@ +package com.retoday.core.global.extension + +import java.util.concurrent.ThreadLocalRandom +import java.util.concurrent.atomic.AtomicInteger + +private const val RANDOM_BITS = 22 +private const val RANDOM_MASK = (1 shl RANDOM_BITS) - 1 + +private val tsidSequence = AtomicInteger(0) + +@Volatile +private var tsidLastTimestamp = 0L + +fun createTsid(): Long { + synchronized(tsidSequence) { + val now = System.currentTimeMillis() + if (now != tsidLastTimestamp) { + tsidLastTimestamp = now + tsidSequence.set(ThreadLocalRandom.current().nextInt(RANDOM_MASK + 1)) + } + + return (now shl RANDOM_BITS) or (tsidSequence.getAndIncrement().toLong() and RANDOM_MASK.toLong()) + } +} diff --git a/core/src/main/kotlin/com/retoday/core/global/jwt/JwtProvider.kt b/core/src/main/kotlin/com/retoday/core/global/jwt/JwtProvider.kt index 30603e8d..6c5434f7 100644 --- a/core/src/main/kotlin/com/retoday/core/global/jwt/JwtProvider.kt +++ b/core/src/main/kotlin/com/retoday/core/global/jwt/JwtProvider.kt @@ -15,6 +15,8 @@ class JwtProvider( ) { companion object { private const val TOKEN_ISSUER = "retoday" + const val USER_ID_CLAIM = "id" + const val USER_ROLES_CLAIM = "roles" } fun createToken( @@ -25,8 +27,8 @@ class JwtProvider( createToken( expiration, mapOf( - ::id.name to id.toString(), - ::roles.name to roles.joinToString(",") + USER_ID_CLAIM to id.toString(), + USER_ROLES_CLAIM to roles ) ) } @@ -34,7 +36,7 @@ class JwtProvider( fun extractUserId(token: String): Long = try { extractPayload(token) - .run { get(User::id.name) as String } + .run { get(USER_ID_CLAIM) as String } .toLong() } catch (exception: JwtException) { throw InvalidAuthenticationException() diff --git a/core/src/main/resources/application-db.yaml b/core/src/main/resources/application-db.yaml index f9f9b997..6e92dc45 100644 --- a/core/src/main/resources/application-db.yaml +++ b/core/src/main/resources/application-db.yaml @@ -1,6 +1,3 @@ -spring: - jpa: - open-in-view: false datasource: main: driver-class-name: com.mysql.cj.jdbc.Driver @@ -9,13 +6,6 @@ spring: config: activate: on-profile: local - jpa: - hibernate: - ddl-auto: update - properties: - hibernate: - format_sql: true - show-sql: true data: redis: url: redis://localhost:6379 @@ -29,9 +19,6 @@ spring: config: activate: on-profile: dev - jpa: - hibernate: - ddl-auto: update data: redis: url: ${REDIS_URL} @@ -45,14 +32,11 @@ spring: config: activate: on-profile: prod - jpa: - hibernate: - ddl-auto: update data: redis: url: ${REDIS_URL} datasource: - main: - jdbc-url: ${MAIN_DB_URL} - username: ${MAIN_DB_USERNAME} - password: ${MAIN_DB_PASSWORD} + main: + jdbc-url: ${MAIN_DB_URL} + username: ${MAIN_DB_USERNAME} + password: ${MAIN_DB_PASSWORD} diff --git a/core/src/main/resources/db/migration/V1__init_schema.sql b/core/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 00000000..f1318324 --- /dev/null +++ b/core/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1,157 @@ +CREATE TABLE `user` +( + id BIGINT PRIMARY KEY, + social_id VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + provider ENUM ('GOOGLE') NOT NULL, + roles VARCHAR(100) NOT NULL, + is_active BIT(1) NOT NULL, + created_at TIMESTAMP(6) NOT NULL, + updated_at TIMESTAMP(6), + deleted_at TIMESTAMP(6), + CONSTRAINT uk_user_provider_social_id UNIQUE (provider, social_id) +); + +CREATE TABLE profile +( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + first_name VARCHAR(255) NOT NULL, + last_name VARCHAR(255) NOT NULL, + image_url VARCHAR(255) NOT NULL, + time_zone ENUM ('SEOUL') NOT NULL, + recap_period TIME(6), + created_at TIMESTAMP(6) NOT NULL, + updated_at TIMESTAMP(6), + deleted_at TIMESTAMP(6) +); + +CREATE TABLE website_category +( + id BIGINT PRIMARY KEY, + code ENUM ( + 'STUDY', + 'SHOPPING', + 'GAMING', + 'CONTENT', + 'COMMUNITY', + 'NEWS', + 'FINANCE', + 'LIFESTYLE', + 'BROWSING', + 'DESIGN', + 'DEVELOPMENT', + 'AI', + 'OTHER' + ) NOT NULL UNIQUE, + name VARCHAR(50) NOT NULL UNIQUE, + created_at TIMESTAMP(6) NOT NULL, + updated_at TIMESTAMP(6), + deleted_at TIMESTAMP(6) +); + +CREATE TABLE website +( + id BIGINT PRIMARY KEY, + domain VARCHAR(255) NOT NULL UNIQUE, + category_id BIGINT, + favicon_url VARCHAR(500), + created_at TIMESTAMP(6) NOT NULL, + updated_at TIMESTAMP(6), + deleted_at TIMESTAMP(6) +); + +CREATE TABLE page +( + id BIGINT PRIMARY KEY, + website_id BIGINT NOT NULL, + url VARCHAR(768) NOT NULL UNIQUE, + title VARCHAR(500), + description TEXT, + created_at TIMESTAMP(6) NOT NULL, + updated_at TIMESTAMP(6), + deleted_at TIMESTAMP(6) +); + +CREATE TABLE history +( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + website_id BIGINT NOT NULL, + page_id BIGINT NOT NULL, + visited_at TIMESTAMP(6) NOT NULL, + closed_at TIMESTAMP(6) NOT NULL, + is_closed BIT(1) NOT NULL, + scroll_depth INT, + created_at TIMESTAMP(6) NOT NULL, + updated_at TIMESTAMP(6), + deleted_at TIMESTAMP(6) +); + +CREATE INDEX idx_user_id_visited_at ON history (user_id, visited_at); +CREATE INDEX idx_user_id_closed_at ON history (user_id, closed_at); + +CREATE TABLE user_excluded_website_domain +( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + domain VARCHAR(255) NOT NULL, + created_at TIMESTAMP(6) NOT NULL, + updated_at TIMESTAMP(6), + deleted_at TIMESTAMP(6), + CONSTRAINT uk_user_excluded_website_domain UNIQUE (user_id, domain) +); + +CREATE TABLE recap +( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + recap_date DATE NOT NULL, + title VARCHAR(255) NOT NULL, + summary TEXT NOT NULL, + image_url VARCHAR(255), + started_at TIMESTAMP(6) NOT NULL, + closed_at TIMESTAMP(6) NOT NULL, + model VARCHAR(255) NOT NULL, + status ENUM ('COMPLETED', 'FAILED') NOT NULL, + created_at TIMESTAMP(6) NOT NULL, + updated_at TIMESTAMP(6), + deleted_at TIMESTAMP(6), + CONSTRAINT uk_recap_user_date UNIQUE (user_id, recap_date) +); + +CREATE TABLE recap_section +( + id BIGINT PRIMARY KEY, + recap_id BIGINT NOT NULL, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP(6) NOT NULL, + updated_at TIMESTAMP(6), + deleted_at TIMESTAMP(6) +); + +CREATE TABLE recap_timeline +( + id BIGINT PRIMARY KEY, + recap_id BIGINT NOT NULL, + started_at TIME(6) NOT NULL, + ended_at TIME(6) NOT NULL, + title VARCHAR(255) NOT NULL, + duration_minutes INT NOT NULL, + created_at TIMESTAMP(6) NOT NULL, + updated_at TIMESTAMP(6), + deleted_at TIMESTAMP(6) +); + +CREATE TABLE recap_topic +( + id BIGINT PRIMARY KEY, + recap_id BIGINT NOT NULL, + keyword VARCHAR(255) NOT NULL, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6), + deleted_at DATETIME(6) +); diff --git a/core/src/test/kotlin/com/retoday/core/domain/auth/service/AuthServiceTest.kt b/core/src/test/kotlin/com/retoday/core/domain/auth/service/AuthServiceTest.kt index bb5b2aea..472be7ce 100644 --- a/core/src/test/kotlin/com/retoday/core/domain/auth/service/AuthServiceTest.kt +++ b/core/src/test/kotlin/com/retoday/core/domain/auth/service/AuthServiceTest.kt @@ -94,8 +94,8 @@ class AuthServiceTest : BehaviorSpec() { val result = authService.login(command) Then("회원가입과 함께 로그인 처리가 된다.") { - userSlot.captured.id shouldBe null - profileSlot.captured.id shouldBe null + (userSlot.captured.id > 0L) shouldBe true + (profileSlot.captured.id > 0L) shouldBe true result shouldBe createLoginResult() } } diff --git a/core/src/test/kotlin/com/retoday/core/domain/history/repository/HistoryRepositoryTest.kt b/core/src/test/kotlin/com/retoday/core/domain/history/repository/HistoryRepositoryTest.kt index 2fb4de29..54708a74 100644 --- a/core/src/test/kotlin/com/retoday/core/domain/history/repository/HistoryRepositoryTest.kt +++ b/core/src/test/kotlin/com/retoday/core/domain/history/repository/HistoryRepositoryTest.kt @@ -2,6 +2,8 @@ package com.retoday.core.domain.history.repository import com.retoday.core.common.RepositoryTest import com.retoday.core.domain.history.dto.projection.WorkPatternHourlyCountProjection +import com.retoday.core.domain.history.repository.WebsiteCategoryRepository +import com.retoday.core.domain.history.repository.WebsiteRepository import com.retoday.core.fixture.* import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe @@ -12,16 +14,22 @@ class HistoryRepositoryTest : RepositoryTest() { @Autowired private lateinit var historyRepository: HistoryRepository + @Autowired + private lateinit var websiteRepository: WebsiteRepository + + @Autowired + private lateinit var websiteCategoryRepository: WebsiteCategoryRepository + init { "findTopWebsiteStatByUserId()" { val userId = 1L val periodStartedAt = Instant.parse("2026-02-13T00:00:00Z") val periodEndedAt = Instant.parse("2026-02-14T00:00:00Z") - val github = createWebsite(id = null, domain = DOMAIN, faviconUrl = FAVICON_URL).save() - val news = createWebsite(id = null, domain = USER_EX_DOMAIN, faviconUrl = null).save() - val githubId = github.id!! - val newsId = news.id!! + val github = websiteRepository.save(createWebsite(id = null, domain = DOMAIN, faviconUrl = FAVICON_URL)) + val news = websiteRepository.save(createWebsite(id = null, domain = USER_EX_DOMAIN, faviconUrl = null)) + val githubId = github.id + val newsId = news.id createHistory( id = null, @@ -30,7 +38,7 @@ class HistoryRepositoryTest : RepositoryTest() { pageId = 11L, visitedAt = Instant.parse("2026-02-13T01:00:00Z"), closedAt = Instant.parse("2026-02-13T02:00:00Z") - ).save() + ).let(historyRepository::save) createHistory( id = null, userId = userId, @@ -38,7 +46,7 @@ class HistoryRepositoryTest : RepositoryTest() { pageId = 12L, visitedAt = Instant.parse("2026-02-13T10:00:00Z"), closedAt = Instant.parse("2026-02-13T10:30:00Z") - ).save() + ).let(historyRepository::save) createHistory( id = null, userId = userId, @@ -46,10 +54,7 @@ class HistoryRepositoryTest : RepositoryTest() { pageId = 13L, visitedAt = Instant.parse("2026-02-13T23:30:00Z"), closedAt = Instant.parse("2026-02-14T00:30:00Z") - ).save() - - entityManager.flush() - entityManager.clear() + ).let(historyRepository::save) val topWebsite = historyRepository.findTopWebsiteStatByUserId( @@ -74,7 +79,7 @@ class HistoryRepositoryTest : RepositoryTest() { pageId = 201L, visitedAt = Instant.parse("2026-02-13T00:10:00Z"), closedAt = Instant.parse("2026-02-13T00:20:00Z") - ).save() + ).let(historyRepository::save) createHistory( id = null, userId = userId, @@ -82,7 +87,7 @@ class HistoryRepositoryTest : RepositoryTest() { pageId = 202L, visitedAt = Instant.parse("2026-02-13T00:59:00Z"), closedAt = Instant.parse("2026-02-13T01:10:00Z") - ).save() + ).let(historyRepository::save) createHistory( id = null, userId = userId, @@ -90,7 +95,7 @@ class HistoryRepositoryTest : RepositoryTest() { pageId = 203L, visitedAt = Instant.parse("2026-02-13T06:01:00Z"), closedAt = Instant.parse("2026-02-13T06:20:00Z") - ).save() + ).let(historyRepository::save) createHistory( id = null, userId = userId, @@ -98,7 +103,7 @@ class HistoryRepositoryTest : RepositoryTest() { pageId = 204L, visitedAt = Instant.parse("2026-02-13T11:59:00Z"), closedAt = Instant.parse("2026-02-13T12:30:00Z") - ).save() + ).let(historyRepository::save) createHistory( id = null, userId = userId, @@ -106,7 +111,7 @@ class HistoryRepositoryTest : RepositoryTest() { pageId = 205L, visitedAt = Instant.parse("2026-02-13T12:00:00Z"), closedAt = Instant.parse("2026-02-13T12:10:00Z") - ).save() + ).let(historyRepository::save) createHistory( id = null, userId = userId, @@ -114,7 +119,7 @@ class HistoryRepositoryTest : RepositoryTest() { pageId = 206L, visitedAt = Instant.parse("2026-02-13T23:30:00Z"), closedAt = Instant.parse("2026-02-13T23:40:00Z") - ).save() + ).let(historyRepository::save) // 집계 시작 이전 데이터는 제외된다. createHistory( @@ -124,7 +129,7 @@ class HistoryRepositoryTest : RepositoryTest() { pageId = 207L, visitedAt = Instant.parse("2026-02-12T23:59:00Z"), closedAt = Instant.parse("2026-02-13T00:10:00Z") - ).save() + ).let(historyRepository::save) // 집계 종료(다음날 00:00) 시각과 같은 데이터는 제외된다. createHistory( id = null, @@ -133,7 +138,7 @@ class HistoryRepositoryTest : RepositoryTest() { pageId = 208L, visitedAt = Instant.parse("2026-02-14T00:00:00Z"), closedAt = Instant.parse("2026-02-14T00:10:00Z") - ).save() + ).let(historyRepository::save) // 다른 사용자의 데이터는 제외된다. createHistory( id = null, @@ -142,10 +147,7 @@ class HistoryRepositoryTest : RepositoryTest() { pageId = 209L, visitedAt = Instant.parse("2026-02-13T00:30:00Z"), closedAt = Instant.parse("2026-02-13T00:40:00Z") - ).save() - - entityManager.flush() - entityManager.clear() + ).let(historyRepository::save) val counts = historyRepository.findHourlyHistoryCountsByUserId( @@ -168,11 +170,11 @@ class HistoryRepositoryTest : RepositoryTest() { val periodStartedAt = Instant.parse("2026-02-13T00:00:00Z") val periodEndedAt = Instant.parse("2026-02-14T00:00:00Z") - val category = createWebsiteCategory(id = null, name = "개발").save() - val github = createWebsite(id = null, domain = DOMAIN, categoryId = category.id).save() - val news = createWebsite(id = null, domain = USER_EX_DOMAIN, faviconUrl = null).save() - val githubId = github.id!! - val newsId = news.id!! + val category = websiteCategoryRepository.save(createWebsiteCategory(id = null, name = "개발")) + val github = websiteRepository.save(createWebsite(id = null, domain = DOMAIN, categoryId = category.id)) + val news = websiteRepository.save(createWebsite(id = null, domain = USER_EX_DOMAIN, faviconUrl = null)) + val githubId = github.id + val newsId = news.id createHistory( id = null, @@ -181,7 +183,7 @@ class HistoryRepositoryTest : RepositoryTest() { pageId = 11L, visitedAt = Instant.parse("2026-02-12T23:30:00Z"), closedAt = Instant.parse("2026-02-13T00:30:00Z") - ).save() + ).let(historyRepository::save) createHistory( id = null, userId = userId, @@ -189,7 +191,7 @@ class HistoryRepositoryTest : RepositoryTest() { pageId = 12L, visitedAt = Instant.parse("2026-02-13T01:00:00Z"), closedAt = Instant.parse("2026-02-13T01:30:00Z") - ).save() + ).let(historyRepository::save) createHistory( id = null, userId = userId, @@ -197,7 +199,7 @@ class HistoryRepositoryTest : RepositoryTest() { pageId = 13L, visitedAt = Instant.parse("2026-02-13T23:30:00Z"), closedAt = Instant.parse("2026-02-14T00:30:00Z") - ).save() + ).let(historyRepository::save) createHistory( id = null, userId = userId, @@ -205,7 +207,7 @@ class HistoryRepositoryTest : RepositoryTest() { pageId = 14L, visitedAt = Instant.parse("2026-02-14T01:00:00Z"), closedAt = Instant.parse("2026-02-14T02:00:00Z") - ).save() + ).let(historyRepository::save) createHistory( id = null, userId = 2L, @@ -213,10 +215,7 @@ class HistoryRepositoryTest : RepositoryTest() { pageId = 15L, visitedAt = Instant.parse("2026-02-13T02:00:00Z"), closedAt = Instant.parse("2026-02-13T03:00:00Z") - ).save() - - entityManager.flush() - entityManager.clear() + ).let(historyRepository::save) val analyses = historyRepository.findWebsiteStatsWithCategoryByUserId( @@ -244,12 +243,12 @@ class HistoryRepositoryTest : RepositoryTest() { val periodEndedAt = Instant.parse("2026-02-14T00:00:00Z") val limit = 2 - val github = createWebsite(id = null, domain = DOMAIN).save() - val youtube = createWebsite(id = null, domain = "youtube.com").save() - val news = createWebsite(id = null, domain = USER_EX_DOMAIN).save() - val githubId = github.id!! - val youtubeId = youtube.id!! - val newsId = news.id!! + val github = websiteRepository.save(createWebsite(id = null, domain = DOMAIN)) + val youtube = websiteRepository.save(createWebsite(id = null, domain = "youtube.com")) + val news = websiteRepository.save(createWebsite(id = null, domain = USER_EX_DOMAIN)) + val githubId = github.id + val youtubeId = youtube.id + val newsId = news.id createHistory( id = null, @@ -258,7 +257,7 @@ class HistoryRepositoryTest : RepositoryTest() { pageId = 21L, visitedAt = Instant.parse("2026-02-13T00:10:00Z"), closedAt = Instant.parse("2026-02-13T00:40:00Z") - ).save() + ).let(historyRepository::save) createHistory( id = null, userId = userId, @@ -266,7 +265,7 @@ class HistoryRepositoryTest : RepositoryTest() { pageId = 22L, visitedAt = Instant.parse("2026-02-13T01:00:00Z"), closedAt = Instant.parse("2026-02-13T01:20:00Z") - ).save() + ).let(historyRepository::save) createHistory( id = null, userId = userId, @@ -274,7 +273,7 @@ class HistoryRepositoryTest : RepositoryTest() { pageId = 23L, visitedAt = Instant.parse("2026-02-13T02:00:00Z"), closedAt = Instant.parse("2026-02-13T03:00:00Z") - ).save() + ).let(historyRepository::save) createHistory( id = null, userId = userId, @@ -282,7 +281,7 @@ class HistoryRepositoryTest : RepositoryTest() { pageId = 24L, visitedAt = Instant.parse("2026-02-13T23:50:00Z"), closedAt = Instant.parse("2026-02-14T00:10:00Z") - ).save() + ).let(historyRepository::save) createHistory( id = null, userId = userId, @@ -290,7 +289,7 @@ class HistoryRepositoryTest : RepositoryTest() { pageId = 25L, visitedAt = Instant.parse("2026-02-12T23:50:00Z"), closedAt = Instant.parse("2026-02-13T00:20:00Z") - ).save() + ).let(historyRepository::save) createHistory( id = null, userId = userId, @@ -298,7 +297,7 @@ class HistoryRepositoryTest : RepositoryTest() { pageId = 26L, visitedAt = Instant.parse("2026-02-13T05:00:00Z"), closedAt = Instant.parse("2026-02-13T05:10:00Z") - ).save() + ).let(historyRepository::save) createHistory( id = null, userId = 2L, @@ -306,10 +305,7 @@ class HistoryRepositoryTest : RepositoryTest() { pageId = 27L, visitedAt = Instant.parse("2026-02-13T08:00:00Z"), closedAt = Instant.parse("2026-02-13T09:00:00Z") - ).save() - - entityManager.flush() - entityManager.clear() + ).let(historyRepository::save) val analyses = historyRepository.findWebsiteStatsWithVisitCountByUserId( diff --git a/core/src/test/kotlin/com/retoday/core/domain/history/service/HistoryServiceTest.kt b/core/src/test/kotlin/com/retoday/core/domain/history/service/HistoryServiceTest.kt index aa441785..a589a628 100644 --- a/core/src/test/kotlin/com/retoday/core/domain/history/service/HistoryServiceTest.kt +++ b/core/src/test/kotlin/com/retoday/core/domain/history/service/HistoryServiceTest.kt @@ -499,26 +499,44 @@ class HistoryServiceTest : ServiceTest() { Given("웹사이트 도메인 카테고리 분류가 필요할 때") { val domain = "hackers.com" - val studyCategory = WebsiteCategory(id = 10L, code = WebsiteCategoryCode.STUDY, name = "학습") + val studyCategory = + WebsiteCategory(code = WebsiteCategoryCode.STUDY, name = "학습").apply { + id = 10L + } val categoryList = listOf(studyCategory) val categoryCodes = listOf("STUDY") When("카테고리가 할당되지 않은 도메인인 경우") { - val targetWebsite = Website(id = 1L, domain = domain, categoryId = null) + val targetWebsite = + Website(domain = domain, categoryId = null).apply { + id = 1L + } every { categoryRepository.findAll() } returns categoryList every { aiClient.classify(domain, categoryCodes) } returns "STUDY" every { categoryRepository.findByCode(WebsiteCategoryCode.STUDY) } returns studyCategory + every { websiteService.save(any()) } answers { firstArg() } historyService.classifyCategory(targetWebsite, domain) Then("AI 결과에 따라 웹사이트의 카테고리가 업데이트되어야 한다") { - targetWebsite.categoryId shouldBe 10L + verify(exactly = 1) { + websiteService.save( + withArg { + it.id shouldBe targetWebsite.id + it.domain shouldBe targetWebsite.domain + it.categoryId shouldBe 10L + } + ) + } verify(exactly = 1) { aiClient.classify(domain, any()) } } } When("AI가 분류한 카테고리가 DB에 존재하지 않는 이름이라면") { - val freshWebsite = Website(id = 2L, domain = domain, categoryId = null) + val freshWebsite = + Website(domain = domain, categoryId = null).apply { + id = 2L + } every { categoryRepository.findAll() } returns categoryList every { aiClient.classify(domain, categoryCodes) } returns "INVALID_CODE" diff --git a/core/src/test/kotlin/com/retoday/core/domain/recap/service/RecapServiceTest.kt b/core/src/test/kotlin/com/retoday/core/domain/recap/service/RecapServiceTest.kt index a30f30ad..a8e5536f 100644 --- a/core/src/test/kotlin/com/retoday/core/domain/recap/service/RecapServiceTest.kt +++ b/core/src/test/kotlin/com/retoday/core/domain/recap/service/RecapServiceTest.kt @@ -13,9 +13,9 @@ import com.retoday.core.domain.recap.dto.response.GeminiRecapResponse import com.retoday.core.domain.recap.dto.response.GeminiTimelineResponse import com.retoday.core.domain.recap.dto.response.GeminiTopicResponse import com.retoday.core.domain.recap.entity.RecapStatus -import com.retoday.core.domain.recap.entity.Section -import com.retoday.core.domain.recap.entity.Timeline -import com.retoday.core.domain.recap.entity.Topic +import com.retoday.core.domain.recap.entity.RecapSection +import com.retoday.core.domain.recap.entity.RecapTimeline +import com.retoday.core.domain.recap.entity.RecapTopic import com.retoday.core.domain.recap.repository.RecapRepository import com.retoday.core.domain.recap.repository.SectionRepository import com.retoday.core.domain.recap.repository.TimelineRepository @@ -136,9 +136,9 @@ class RecapServiceTest : ServiceTest() { // Entity 저장 Mocking val savedRecap = createRecap(id = 100L) every { recapRepository.save(any()) } returns savedRecap - every { sectionRepository.saveAll(any>()) } returns emptyList() - every { topicRepository.saveAll(any>()) } returns emptyList() - every { timelineRepository.saveAll(any>()) } returns emptyList() + every { sectionRepository.saveAll(any>()) } returns emptyList() + every { topicRepository.saveAll(any>()) } returns emptyList() + every { timelineRepository.saveAll(any>()) } returns emptyList() When("createDailyRecap을 호출하여 리캡 생성을 수행하면") { recapService.createDailyRecap(userId, date) @@ -154,7 +154,7 @@ class RecapServiceTest : ServiceTest() { // 2. 섹션 저장 확인 verify(exactly = 1) { sectionRepository.saveAll( - match> { sections -> + match> { sections -> // 타입을 명시적으로 지정 sections.all { it.recapId == 100L } } @@ -164,7 +164,7 @@ class RecapServiceTest : ServiceTest() { // 3. 토픽 저장 확인 verify(exactly = 1) { topicRepository.saveAll( - match> { topics -> + match> { topics -> // 타입을 명시적으로 지정 topics.all { it.recapId == 100L } } @@ -174,7 +174,7 @@ class RecapServiceTest : ServiceTest() { // 4. 타임라인 저장 확인 verify(exactly = 1) { timelineRepository.saveAll( - match> { timelines -> + match> { timelines -> // 타입을 명시적으로 지정 timelines.all { it.recapId == 100L } } diff --git a/core/src/test/kotlin/com/retoday/core/domain/user/entity/ProfileTest.kt b/core/src/test/kotlin/com/retoday/core/domain/user/entity/ProfileTest.kt index 34ffc0cb..80c3f3c9 100644 --- a/core/src/test/kotlin/com/retoday/core/domain/user/entity/ProfileTest.kt +++ b/core/src/test/kotlin/com/retoday/core/domain/user/entity/ProfileTest.kt @@ -28,6 +28,7 @@ class ProfileTest : BehaviorSpec() { with(profile) { firstName shouldBe changedFirstName lastName shouldBe changedLastName + imageUrl shouldBe changedImageUrl } } } diff --git a/core/src/test/kotlin/com/retoday/core/domain/user/repository/CustomProfileRepositoryTest.kt b/core/src/test/kotlin/com/retoday/core/domain/user/repository/CustomProfileRepositoryTest.kt index f8b2649d..0d5ee277 100644 --- a/core/src/test/kotlin/com/retoday/core/domain/user/repository/CustomProfileRepositoryTest.kt +++ b/core/src/test/kotlin/com/retoday/core/domain/user/repository/CustomProfileRepositoryTest.kt @@ -3,36 +3,46 @@ package com.retoday.core.domain.user.repository import com.retoday.core.common.RepositoryTest import com.retoday.core.domain.user.entity.Profile import com.retoday.core.domain.user.entity.User +import com.retoday.core.domain.user.repository.UserRepository import com.retoday.core.fixture.createProfile -import com.retoday.core.fixture.createProfileWithEmailProjection import com.retoday.core.fixture.createUser import io.kotest.core.test.TestCase -import io.kotest.matchers.equality.shouldBeEqualToComparingFields import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe import org.springframework.beans.factory.annotation.Autowired class CustomProfileRepositoryTest : RepositoryTest() { @Autowired private lateinit var profileRepository: ProfileRepository + + @Autowired + private lateinit var userRepository: UserRepository + private lateinit var user: User private lateinit var profile: Profile override suspend fun beforeEach(testCase: TestCase) { + super.beforeEach(testCase) user = - createUser(id = null) - .save() + userRepository.save(createUser(id = null)) profile = - createProfile(id = null, userId = user.id!!) - .save() + profileRepository.save(createProfile(id = null, userId = user.id)) } init { "findByUserIdWithEmail()" { - val profileWithEmail = profileRepository.findByUserIdWithEmail(user.id!!) + val profileWithEmail = profileRepository.findByUserIdWithEmail(user.id) - profileWithEmail - .shouldNotBeNull() - .shouldBeEqualToComparingFields(createProfileWithEmailProjection(profile = profile)) + profileWithEmail.shouldNotBeNull() + profileWithEmail.id shouldBe profile.id + profileWithEmail.userId shouldBe profile.userId + profileWithEmail.firstName shouldBe profile.firstName + profileWithEmail.lastName shouldBe profile.lastName + profileWithEmail.imageUrl shouldBe profile.imageUrl + profileWithEmail.timeZone shouldBe profile.timeZone + profileWithEmail.recapPeriod shouldBe profile.recapPeriod + profileWithEmail.email shouldBe user.email + profileWithEmail.deletedAt shouldBe null } } } diff --git a/core/src/testFixtures/kotlin/com/retoday/core/common/RepositoryTest.kt b/core/src/testFixtures/kotlin/com/retoday/core/common/RepositoryTest.kt index f86a86ca..471ddc3f 100644 --- a/core/src/testFixtures/kotlin/com/retoday/core/common/RepositoryTest.kt +++ b/core/src/testFixtures/kotlin/com/retoday/core/common/RepositoryTest.kt @@ -1,26 +1,26 @@ package com.retoday.core.common -import com.linecorp.kotlinjdsl.support.spring.data.jpa.autoconfigure.KotlinJdslAutoConfiguration -import com.retoday.core.global.config.JpaConfiguration -import com.retoday.core.global.entity.BaseEntity +import com.retoday.core.global.config.JdbcConfiguration import io.kotest.core.spec.style.StringSpec -import jakarta.persistence.EntityManager +import io.kotest.core.test.TestCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.data.jdbc.DataJdbcTest import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.boot.testcontainers.service.connection.ServiceConnection +import org.springframework.jdbc.core.JdbcTemplate import org.springframework.test.context.ContextConfiguration import org.testcontainers.containers.MySQLContainer import org.testcontainers.junit.jupiter.Container import org.testcontainers.junit.jupiter.Testcontainers as EnableTestContainers -@DataJpaTest +@DataJdbcTest @EnableTestContainers @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @ContextConfiguration( classes = [ - JpaConfiguration::class, - KotlinJdslAutoConfiguration::class + JdbcConfiguration::class ] ) abstract class RepositoryTest : StringSpec() { @@ -32,7 +32,23 @@ abstract class RepositoryTest : StringSpec() { } @Autowired - protected lateinit var entityManager: EntityManager + protected lateinit var jdbcTemplate: JdbcTemplate - protected fun T.save(): T = also { entityManager.persist(it) } + override suspend fun beforeEach(testCase: TestCase) { + withContext(Dispatchers.IO) { + jdbcTemplate.batchUpdate( + "DELETE FROM recap_topic", + "DELETE FROM recap_timeline", + "DELETE FROM recap_section", + "DELETE FROM recap", + "DELETE FROM user_excluded_website_domain", + "DELETE FROM history", + "DELETE FROM page", + "DELETE FROM website", + "DELETE FROM website_category", + "DELETE FROM profile", + "DELETE FROM `user`" + ) + } + } } diff --git a/core/src/testFixtures/kotlin/com/retoday/core/fixture/AuthFixtures.kt b/core/src/testFixtures/kotlin/com/retoday/core/fixture/AuthFixtures.kt index a508ce4a..006aead8 100644 --- a/core/src/testFixtures/kotlin/com/retoday/core/fixture/AuthFixtures.kt +++ b/core/src/testFixtures/kotlin/com/retoday/core/fixture/AuthFixtures.kt @@ -6,7 +6,7 @@ import com.retoday.core.domain.auth.dto.response.GetOAuthUserResponse import com.retoday.core.domain.auth.dto.result.LoginResult import com.retoday.core.domain.auth.dto.result.RefreshResult import com.retoday.core.domain.auth.entity.RefreshToken -import com.retoday.core.domain.user.entity.Provider +import com.retoday.core.domain.user.entity.SocialProvider import java.time.Duration const val TOKEN = "eyJhbGciOiJub25lIn0.eyJpZCI6MSwiaWF0IjoxNTE2MjM5MDIyfQ." @@ -25,7 +25,7 @@ fun createRefreshToken( fun createGetOAuthUserResponse( id: String = SOCIAL_ID, - provider: Provider = PROVIDER, + provider: SocialProvider = PROVIDER, email: String = EMAIL, firstName: String = FIRST_NAME, lastName: String = LAST_NAME, @@ -42,7 +42,7 @@ fun createGetOAuthUserResponse( fun createLoginCommand( oAuthToken: String = TOKEN, - provider: Provider = PROVIDER + provider: SocialProvider = PROVIDER ): LoginCommand = LoginCommand( oAuthToken = oAuthToken, diff --git a/core/src/testFixtures/kotlin/com/retoday/core/fixture/HistoryFixtures.kt b/core/src/testFixtures/kotlin/com/retoday/core/fixture/HistoryFixtures.kt index 957aecd9..3bd6f88f 100644 --- a/core/src/testFixtures/kotlin/com/retoday/core/fixture/HistoryFixtures.kt +++ b/core/src/testFixtures/kotlin/com/retoday/core/fixture/HistoryFixtures.kt @@ -16,6 +16,7 @@ import com.retoday.core.domain.history.entity.Page import com.retoday.core.domain.history.entity.Website import com.retoday.core.domain.history.entity.WebsiteCategory import com.retoday.core.domain.history.entity.WebsiteCategoryCode +import com.retoday.core.global.extension.createTsid import java.time.Instant import java.time.LocalDate @@ -217,40 +218,49 @@ fun createWebsite( id: Long? = WEBSITE_ID, domain: String = DOMAIN, categoryId: Long? = null, - faviconUrl: String? = FAVICON_URL + faviconUrl: String? = FAVICON_URL, + createdAt: Instant? = if (id == null) null else Instant.now() ): Website = Website( - id = id, domain = domain, categoryId = categoryId, faviconUrl = faviconUrl - ) + ).apply { + this.id = id ?: createTsid() + this.createdAt = createdAt + } fun createWebsiteCategory( id: Long? = ID, code: WebsiteCategoryCode = WebsiteCategoryCode.DEVELOPMENT, - name: String = "개발" + name: String = "개발", + createdAt: Instant? = if (id == null) null else Instant.now() ): WebsiteCategory = WebsiteCategory( - id = id, code = code, name = name - ) + ).apply { + this.id = id ?: createTsid() + this.createdAt = createdAt + } fun createPage( id: Long? = PAGE_ID, websiteId: Long = WEBSITE_ID, url: String = PAGE_URL, title: String? = TITLE, - description: String? = DESCRIPTION + description: String? = DESCRIPTION, + createdAt: Instant? = if (id == null) null else Instant.now() ): Page = Page( - id = id, websiteId = websiteId, url = url, title = title, description = description - ) + ).apply { + this.id = id ?: createTsid() + this.createdAt = createdAt + } fun createHistory( id: Long? = ID, @@ -260,10 +270,10 @@ fun createHistory( visitedAt: Instant = Instant.now().minusSeconds(10), closedAt: Instant = Instant.now(), isClosed: Boolean = true, - scrollDepth: Int? = SCROLL_DEPTH + scrollDepth: Int? = SCROLL_DEPTH, + createdAt: Instant? = if (id == null) null else Instant.now() ): History = History( - id = id, userId = userId, websiteId = websiteId, pageId = pageId, @@ -271,7 +281,10 @@ fun createHistory( closedAt = closedAt, isClosed = isClosed, scrollDepth = scrollDepth - ) + ).apply { + this.id = id ?: createTsid() + this.createdAt = createdAt + } fun createHistoryRecordCommand( tabId: Int = TAB_ID, diff --git a/core/src/testFixtures/kotlin/com/retoday/core/fixture/RecapFixtures.kt b/core/src/testFixtures/kotlin/com/retoday/core/fixture/RecapFixtures.kt index 9a42d8e2..b72dd18b 100644 --- a/core/src/testFixtures/kotlin/com/retoday/core/fixture/RecapFixtures.kt +++ b/core/src/testFixtures/kotlin/com/retoday/core/fixture/RecapFixtures.kt @@ -8,6 +8,7 @@ import com.retoday.core.domain.recap.dto.response.GeminiRecapResponse import com.retoday.core.domain.recap.dto.response.GeminiTimelineResponse import com.retoday.core.domain.recap.dto.response.GeminiTopicResponse import com.retoday.core.domain.recap.entity.Recap +import com.retoday.core.global.extension.createTsid import java.time.Instant import java.time.LocalDate import java.time.temporal.ChronoUnit @@ -84,10 +85,10 @@ fun createRecap( summary: String = "오늘은 주로 개발 업무와 기술 블로그 탐독을 하며 시간을 보냈습니다.", startedAt: Instant = Instant.now().minus(9, ChronoUnit.HOURS), closedAt: Instant = Instant.now(), - model: String = "gemini-2.5-flash" + model: String = "gemini-2.5-flash", + createdAt: Instant? = if (id == null) null else Instant.now() ): Recap = Recap( - id = id, userId = userId, recapDate = recapDate, title = title, @@ -95,7 +96,10 @@ fun createRecap( startedAt = startedAt, closedAt = closedAt, model = model - ) + ).apply { + this.id = id ?: createTsid() + this.createdAt = createdAt + } fun createUserTimelineActivities(): List { // 기준 시간을 UTC Instant로 설정 diff --git a/core/src/testFixtures/kotlin/com/retoday/core/fixture/UserFixtures.kt b/core/src/testFixtures/kotlin/com/retoday/core/fixture/UserFixtures.kt index 57abe986..2722178b 100644 --- a/core/src/testFixtures/kotlin/com/retoday/core/fixture/UserFixtures.kt +++ b/core/src/testFixtures/kotlin/com/retoday/core/fixture/UserFixtures.kt @@ -3,35 +3,41 @@ package com.retoday.core.fixture import com.retoday.core.domain.user.dto.projection.ProfileWithEmailProjection import com.retoday.core.domain.user.dto.result.GetMyProfileResult import com.retoday.core.domain.user.entity.* +import com.retoday.core.global.extension.createTsid +import java.time.Instant import java.time.LocalTime +import java.time.temporal.ChronoUnit const val SOCIAL_ID = "1232342423" const val EMAIL = "earlgrey02@re-today.com" -val PROVIDER = Provider.GOOGLE +val PROVIDER = SocialProvider.GOOGLE val ROLES = setOf(Role.MEMBER) const val IS_ACTIVE = true const val FIRST_NAME = "Sangyoon" const val LAST_NAME = "Jeong" const val IMAGE_URL = "https://re-today.com/profile.png" val TIME_ZONE = TimeZone.SEOUL -val RECAP_PERIOD: LocalTime = LocalTime.now() +val RECAP_PERIOD: LocalTime = LocalTime.now().truncatedTo(ChronoUnit.SECONDS) fun createUser( id: Long? = ID, socialId: String = SOCIAL_ID, email: String = EMAIL, - provider: Provider = PROVIDER, + provider: SocialProvider = PROVIDER, roles: Set = ROLES, - isActive: Boolean = IS_ACTIVE + isActive: Boolean = IS_ACTIVE, + createdAt: Instant? = if (id == null) null else Instant.now() ): User = User( - id = id, socialId = socialId, email = email, provider = provider, - roles = roles, + roles = roles.joinToString(","), isActive = isActive - ) + ).apply { + this.id = id ?: createTsid() + this.createdAt = createdAt + } fun createProfile( id: Long? = ID, @@ -40,35 +46,50 @@ fun createProfile( lastName: String = LAST_NAME, imageUrl: String = IMAGE_URL, timeZone: TimeZone = TIME_ZONE, - recapPeriod: LocalTime = RECAP_PERIOD + recapPeriod: LocalTime = RECAP_PERIOD, + createdAt: Instant? = if (id == null) null else Instant.now() ): Profile = Profile( - id = id, userId = userId, firstName = firstName, lastName = lastName, imageUrl = imageUrl, timeZone = timeZone, recapPeriod = recapPeriod - ) + ).apply { + this.id = id ?: createTsid() + this.createdAt = createdAt + } fun createUserExcludedWebsite( id: Long? = ID, userId: Long = ID, - domain: String = USER_EX_DOMAIN + domain: String = USER_EX_DOMAIN, + createdAt: Instant? = if (id == null) null else Instant.now() ): UserExcludedWebsiteDomain = UserExcludedWebsiteDomain( - id = id, userId = userId, domain = domain - ) + ).apply { + this.id = id ?: createTsid() + this.createdAt = createdAt + } fun createProfileWithEmailProjection( profile: Profile = createProfile(), email: String = EMAIL ): ProfileWithEmailProjection = ProfileWithEmailProjection( - profile = profile, + id = profile.id, + userId = profile.userId, + firstName = profile.firstName, + lastName = profile.lastName, + imageUrl = profile.imageUrl, + timeZone = profile.timeZone, + recapPeriod = profile.recapPeriod, + createdAt = profile.createdAt ?: Instant.now(), + updatedAt = profile.updatedAt, + deletedAt = profile.deletedAt, email = email ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b327dfb7..f85db1b1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,20 +6,17 @@ kotlin = "2.1.0" # Spring spring-boot = "3.5.3" -# Kotest -kotest = "5.9.1" - -# Mockk -mockk = "1.14.4" +# Flyway +flyway = "11.7.2" # JWT jwt = "0.12.6" -# Database -mysql = "8.2.0" +# Kotest +kotest = "5.9.1" -# JDSL -jdsl = "3.7.2" +# Mockk +mockk = "1.14.4" # OpenAPI restdocs-api-spec = "0.19.4" @@ -33,74 +30,69 @@ java-test-fixtures = { id = "java-test-fixtures" } # Kotlin kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-spring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin" } -kotlin-jpa = { id = "org.jetbrains.kotlin.plugin.jpa", version.ref = "kotlin" } kotlin-lint = { id = "org.jlleitschuh.gradle.ktlint", version = "13.0.0" } # Spring spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" } spring-dependency-management = { id = "io.spring.dependency-management", version = "1.1.7" } +# Flyway +flyway = { id = "net.ltgt.flyway", version = "1.0.0" } + # OpenAPI restdocs-api-spec = { id = "com.epages.restdocs-api-spec", version.ref = "restdocs-api-spec" } [libraries] # Kotlin -kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlin" } kotlin-logging = { group = "io.github.oshai", name = "kotlin-logging-jvm", version = "7.0.3" } # Spring spring-web = { group = "org.springframework.boot", name = "spring-boot-starter-web" } -spring-data-jpa = { group = "org.springframework.boot", name = "spring-boot-starter-data-jpa" } -spring-data-redis = { group = "org.springframework.boot", name = "spring-boot-starter-data-redis" } spring-validation = { group = "org.springframework.boot", name = "spring-boot-starter-validation" } spring-security = { group = "org.springframework.boot", name = "spring-boot-starter-security" } -spring-security-test = { group = "org.springframework.security", name = "spring-security-test" } spring-log4j2 = { group = "org.springframework.boot", name = "spring-boot-starter-log4j2" } -spring-test = { group = "org.springframework.boot", name = "spring-boot-starter-test" } -spring-restdocs-webtestclient = { group = "org.springframework.restdocs", name = "spring-restdocs-webtestclient" } spring-batch = { group = "org.springframework.boot", name = "spring-boot-starter-batch" } spring-actuator = { group = "org.springframework.boot", name = "spring-boot-starter-actuator" } +spring-data-jdbc = { group = "org.springframework.boot", name = "spring-boot-starter-data-jdbc" } +spring-data-redis = { group = "org.springframework.boot", name = "spring-boot-starter-data-redis" } +spring-test = { group = "org.springframework.boot", name = "spring-boot-starter-test" } spring-testcontainers = { group = "org.springframework.boot", name = "spring-boot-testcontainers" } +spring-restdocs-webtestclient = { group = "org.springframework.restdocs", name = "spring-restdocs-webtestclient" } + +# Flyway +flyway-core = { group = "org.flywaydb", name = "flyway-core", version.ref = "flyway" } +flyway-mysql = { group = "org.flywaydb", name = "flyway-mysql", version.ref = "flyway" } + +# Database +mysql-driver = { group = "com.mysql", name = "mysql-connector-j", version = "8.2.0" } # JWT jjwt-api = { group = "io.jsonwebtoken", name = "jjwt-api", version.ref = "jwt" } -jjwt-impl = { group = "io.jsonwebtoken", name = "jjwt-impl", version.ref = "jwt" } +jjwt-implementation = { group = "io.jsonwebtoken", name = "jjwt-impl", version.ref = "jwt" } jjwt-jackson = { group = "io.jsonwebtoken", name = "jjwt-jackson", version.ref = "jwt" } +# Jackson +jackson-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin" } +jackson-yaml = { group = "com.fasterxml.jackson.dataformat", name = "jackson-dataformat-yaml" } + # Kotest kotest = { group = "io.kotest", name = "kotest-assertions-core", version.ref = "kotest" } kotest-junit = { group = "io.kotest", name = "kotest-runner-junit5", version.ref = "kotest" } kotest-spring = { group = "io.kotest.extensions", name = "kotest-extensions-spring", version = "1.1.3" } -# Jackson -jackson-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin" } -jackson-yaml = { group = "com.fasterxml.jackson.dataformat", name = "jackson-dataformat-yaml" } - # Mockk mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } mockk-spring = { group = "com.ninja-squad", name = "springmockk", version = "4.0.2" } -# Database -mysql-connector = { group = "com.mysql", name = "mysql-connector-j", version.ref = "mysql" } -h2 = { group = "com.h2database", name = "h2" } - -# Hibernate -hypersistence-utils = { group = "io.hypersistence", name = "hypersistence-utils-hibernate-63", version = "3.14.1" } - -# JDSL -jdsl-dsl = { group = "com.linecorp.kotlin-jdsl", name = "jpql-dsl", version.ref = "jdsl" } -jdsl-render = { group = "com.linecorp.kotlin-jdsl", name = "jpql-render", version.ref = "jdsl" } -jdsl-jpa = { group = "com.linecorp.kotlin-jdsl", name = "spring-data-jpa-support", version.ref = "jdsl" } - # OpenAPI restdocs-api-spec = { group = "com.epages", name = "restdocs-api-spec", version.ref = "restdocs-api-spec" } restdocs-api-spec-webtestclient = { group = "com.epages", name = "restdocs-api-spec-webtestclient", version.ref = "restdocs-api-spec" } springdoc-openapi = { group = "org.springdoc", name = "springdoc-openapi-starter-webmvc-ui", version = "2.8.9" } -# Test Containers -test-containers-junit = { group = "org.testcontainers", name = "junit-jupiter" } -test-containers-mysql = { group = "org.testcontainers", name = "mysql" } +# Testcontainers +testcontainers-junit = { group = "org.testcontainers", name = "junit-jupiter" } +testcontainers-mysql = { group = "org.testcontainers", name = "mysql" } # Google google-gemini = { group = "com.google.genai", name = "google-genai", version = "1.38.0" } @@ -113,7 +105,6 @@ test = [ ] spring-test = [ "spring-test", - "spring-security-test", "kotest-spring", "mockk-spring" ] @@ -124,20 +115,15 @@ spring-restdocs = [ ] jwt = [ "jjwt-api", - "jjwt-impl", + "jjwt-implementation", "jjwt-jackson" ] jackson = [ "jackson-kotlin", "jackson-yaml" ] -jdsl = [ - "jdsl-dsl", - "jdsl-render", - "jdsl-jpa" -] -test-containers = [ +testcontainers = [ "spring-testcontainers", - "test-containers-junit", - "test-containers-mysql" + "testcontainers-junit", + "testcontainers-mysql" ]