diff --git a/src/main/kotlin/com/learner/language/application/shorts/ShortsService.kt b/src/main/kotlin/com/learner/language/application/shorts/ShortsService.kt new file mode 100644 index 0000000..e6c49b8 --- /dev/null +++ b/src/main/kotlin/com/learner/language/application/shorts/ShortsService.kt @@ -0,0 +1,7 @@ +package com.learner.language.application.shorts + +import com.learner.language.interfaces.shorts.ShortsFeedDto + +interface ShortsService { + fun retrieveFeed(userId: Long, request: ShortsFeedDto.FeedRequest): ShortsFeedDto.FeedResponse +} diff --git a/src/main/kotlin/com/learner/language/application/shorts/ShortsServiceImpl.kt b/src/main/kotlin/com/learner/language/application/shorts/ShortsServiceImpl.kt new file mode 100644 index 0000000..c5eb23f --- /dev/null +++ b/src/main/kotlin/com/learner/language/application/shorts/ShortsServiceImpl.kt @@ -0,0 +1,59 @@ +package com.learner.language.application.shorts + +import com.learner.language.infrastructure.shorts.ShortsFeedQueryRepository +import com.learner.language.infrastructure.shorts.ShortsFeedRow +import com.learner.language.infrastructure.shorts.UserSavedShortRepository +import com.learner.language.interfaces.shorts.ShortsFeedDto +import com.learner.language.interfaces.shorts.ShortsFeedItem +import com.learner.language.interfaces.shorts.ShortsPagingPayload +import com.learner.language.interfaces.shorts.ShortsUserStatePayload +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class ShortsServiceImpl( + private val shortsFeedQueryRepository: ShortsFeedQueryRepository, + private val userSavedShortRepository: UserSavedShortRepository, +) : ShortsService { + + override fun retrieveFeed(userId: Long, request: ShortsFeedDto.FeedRequest): ShortsFeedDto.FeedResponse { + val rows = shortsFeedQueryRepository.findFeedRows( + cursor = request.cursor, + size = request.size + 1, + ) + val hasNext = rows.size > request.size + val pageRows = if (hasNext) rows.take(request.size) else rows + val shortIds = pageRows.map { it.shortId } + + val savedShortIds = findSavedShortIds(userId, shortIds) + + return ShortsFeedDto.FeedResponse( + items = pageRows.map { row -> + row.toFeedItem(saved = savedShortIds.contains(row.shortId)) + }, + paging = ShortsPagingPayload( + nextCursor = if (hasNext && pageRows.isNotEmpty()) pageRows.last().shortId.toString() else null, + hasNext = hasNext, + ) + ) + } + + private fun findSavedShortIds(userId: Long, shortIds: List): Set { + if (shortIds.isEmpty()) { + return emptySet() + } + return userSavedShortRepository.findAllByUserIdAndShortIdIn(userId, shortIds) + .map { it.short.id } + .toSet() + } + + private fun ShortsFeedRow.toFeedItem(saved: Boolean): ShortsFeedItem { + return ShortsFeedItem( + shortId = shortId, + youtubeVideoId = youtubeVideoId, + sourceUrl = sourceUrl, + userState = ShortsUserStatePayload(saved = saved), + ) + } +} diff --git a/src/main/kotlin/com/learner/language/domain/shorts/Shorts.kt b/src/main/kotlin/com/learner/language/domain/shorts/Shorts.kt new file mode 100644 index 0000000..d86730c --- /dev/null +++ b/src/main/kotlin/com/learner/language/domain/shorts/Shorts.kt @@ -0,0 +1,26 @@ +package com.learner.language.domain.shorts + +import com.learner.language.common.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Index +import jakarta.persistence.Table + +@Entity +@Table( + name = "shorts", + indexes = [ + Index(name = "uk_shorts_youtube_video_id", columnList = "youtube_video_id", unique = true), + Index(name = "idx_shorts_active_created_at", columnList = "is_active, created_at, id"), + ] +) +class Shorts( + @Column(name = "youtube_video_id", nullable = false, length = 11) + var youtubeVideoId: String, + + @Column(name = "source_url", nullable = false, length = 255) + var sourceUrl: String, + + @Column(name = "is_active", nullable = false) + var isActive: Boolean = true, +) : BaseEntity() diff --git a/src/main/kotlin/com/learner/language/domain/shorts/UserSavedShort.kt b/src/main/kotlin/com/learner/language/domain/shorts/UserSavedShort.kt new file mode 100644 index 0000000..70620c7 --- /dev/null +++ b/src/main/kotlin/com/learner/language/domain/shorts/UserSavedShort.kt @@ -0,0 +1,29 @@ +package com.learner.language.domain.shorts + +import com.learner.language.common.BaseEntity +import com.learner.language.domain.user.User +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.Index +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table + +@Entity +@Table( + name = "user_saved_short", + indexes = [ + Index(name = "uk_user_saved_short_user_short", columnList = "user_id, short_id", unique = true), + Index(name = "idx_user_saved_short_user_id_created_at", columnList = "user_id, created_at"), + Index(name = "idx_user_saved_short_short_id", columnList = "short_id"), + ] +) +class UserSavedShort( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + var user: User, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "short_id", nullable = false) + var short: Shorts, +) : BaseEntity() diff --git a/src/main/kotlin/com/learner/language/infrastructure/shorts/ShortsFeedQueryRepository.kt b/src/main/kotlin/com/learner/language/infrastructure/shorts/ShortsFeedQueryRepository.kt new file mode 100644 index 0000000..f37fd63 --- /dev/null +++ b/src/main/kotlin/com/learner/language/infrastructure/shorts/ShortsFeedQueryRepository.kt @@ -0,0 +1,11 @@ +package com.learner.language.infrastructure.shorts + +interface ShortsFeedQueryRepository { + fun findFeedRows(cursor: String?, size: Int): List +} + +data class ShortsFeedRow( + val shortId: Long, + val youtubeVideoId: String, + val sourceUrl: String, +) diff --git a/src/main/kotlin/com/learner/language/infrastructure/shorts/ShortsFeedQueryRepositoryImpl.kt b/src/main/kotlin/com/learner/language/infrastructure/shorts/ShortsFeedQueryRepositoryImpl.kt new file mode 100644 index 0000000..49642b6 --- /dev/null +++ b/src/main/kotlin/com/learner/language/infrastructure/shorts/ShortsFeedQueryRepositoryImpl.kt @@ -0,0 +1,53 @@ +package com.learner.language.infrastructure.shorts + +import com.learner.language.domain.shorts.Shorts +import com.linecorp.kotlinjdsl.dsl.jpql.jpql +import com.linecorp.kotlinjdsl.render.jpql.JpqlRenderContext +import com.linecorp.kotlinjdsl.support.spring.data.jpa.extension.createQuery as createJdslQuery +import jakarta.persistence.EntityManager +import org.springframework.stereotype.Repository + +@Repository +class ShortsFeedQueryRepositoryImpl( + private val entityManager: EntityManager +) : ShortsFeedQueryRepository { + private val renderContext = JpqlRenderContext() + + override fun findFeedRows(cursor: String?, size: Int): List { + val query = jpql { + val short = entity(Shorts::class, "short") + + selectNew( + short(Shorts::id), + short(Shorts::youtubeVideoId), + short(Shorts::sourceUrl), + ).from( + short, + ).whereAnd( + *buildPredicates(short, cursor) + ).orderBy( + short(Shorts::id).desc() + ) + } + + return entityManager.createJdslQuery(query, renderContext) + .setMaxResults(size) + .resultList + } + + private fun com.linecorp.kotlinjdsl.dsl.jpql.Jpql.buildPredicates( + short: com.linecorp.kotlinjdsl.querymodel.jpql.entity.Entity, + cursor: String?, + ): Array { + val predicates = mutableListOf() + + predicates += short(Shorts::isActive).equal(true) + + val cursorId = cursor?.toLongOrNull() + if (cursorId != null) { + predicates += short(Shorts::id).lessThan(cursorId) + } + + return predicates.toTypedArray() + } +} diff --git a/src/main/kotlin/com/learner/language/infrastructure/shorts/ShortsRepository.kt b/src/main/kotlin/com/learner/language/infrastructure/shorts/ShortsRepository.kt new file mode 100644 index 0000000..b3217fc --- /dev/null +++ b/src/main/kotlin/com/learner/language/infrastructure/shorts/ShortsRepository.kt @@ -0,0 +1,6 @@ +package com.learner.language.infrastructure.shorts + +import com.learner.language.domain.shorts.Shorts +import org.springframework.data.jpa.repository.JpaRepository + +interface ShortsRepository : JpaRepository diff --git a/src/main/kotlin/com/learner/language/infrastructure/shorts/UserSavedShortRepository.kt b/src/main/kotlin/com/learner/language/infrastructure/shorts/UserSavedShortRepository.kt new file mode 100644 index 0000000..445ea17 --- /dev/null +++ b/src/main/kotlin/com/learner/language/infrastructure/shorts/UserSavedShortRepository.kt @@ -0,0 +1,12 @@ +package com.learner.language.infrastructure.shorts + +import com.learner.language.domain.shorts.UserSavedShort +import org.springframework.data.jpa.repository.JpaRepository +import java.util.Optional + +interface UserSavedShortRepository : JpaRepository { + fun findByUserIdAndShortId(userId: Long, shortId: Long): Optional + fun findAllByUserIdAndShortIdIn(userId: Long, shortIds: List): List + fun existsByUserIdAndShortId(userId: Long, shortId: Long): Boolean + fun deleteByUserIdAndShortId(userId: Long, shortId: Long) +} diff --git a/src/main/kotlin/com/learner/language/interfaces/shorts/ShortsApiController.kt b/src/main/kotlin/com/learner/language/interfaces/shorts/ShortsApiController.kt new file mode 100644 index 0000000..243e820 --- /dev/null +++ b/src/main/kotlin/com/learner/language/interfaces/shorts/ShortsApiController.kt @@ -0,0 +1,28 @@ +package com.learner.language.interfaces.shorts + +import com.learner.language.application.shorts.ShortsService +import com.learner.language.system.login.LoginUser +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.ModelAttribute +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@Validated +@RequestMapping("/api/v1/shorts") +class ShortsApiController( + private val shortsService: ShortsService, +) { + @GetMapping("/feed") + fun retrieveFeed( + @LoginUser userId: Long, + @Valid @ModelAttribute request: ShortsFeedDto.FeedRequest, + ): ResponseEntity { + val response = shortsService.retrieveFeed(userId, request) + return ResponseEntity.status(HttpStatus.OK).body(response) + } +} diff --git a/src/main/kotlin/com/learner/language/interfaces/shorts/ShortsFeedDto.kt b/src/main/kotlin/com/learner/language/interfaces/shorts/ShortsFeedDto.kt new file mode 100644 index 0000000..59e4f4f --- /dev/null +++ b/src/main/kotlin/com/learner/language/interfaces/shorts/ShortsFeedDto.kt @@ -0,0 +1,18 @@ +package com.learner.language.interfaces.shorts + +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min + +class ShortsFeedDto { + data class FeedRequest( + val cursor: String?, + @field:Min(1, message = "size must be at least 1") + @field:Max(50, message = "size must be at most 50") + val size: Int = 10, + ) + + data class FeedResponse( + val items: List, + val paging: ShortsPagingPayload, + ) +} diff --git a/src/main/kotlin/com/learner/language/interfaces/shorts/ShortsPayload.kt b/src/main/kotlin/com/learner/language/interfaces/shorts/ShortsPayload.kt new file mode 100644 index 0000000..0c085fd --- /dev/null +++ b/src/main/kotlin/com/learner/language/interfaces/shorts/ShortsPayload.kt @@ -0,0 +1,17 @@ +package com.learner.language.interfaces.shorts + +data class ShortsFeedItem( + val shortId: Long, + val youtubeVideoId: String, + val sourceUrl: String, + val userState: ShortsUserStatePayload, +) + +data class ShortsUserStatePayload( + val saved: Boolean, +) + +data class ShortsPagingPayload( + val nextCursor: String?, + val hasNext: Boolean, +) diff --git a/src/main/kotlin/com/learner/language/system/db/migration/202604200001_create_shorts_and_seed_feed.sql b/src/main/kotlin/com/learner/language/system/db/migration/202604200001_create_shorts_and_seed_feed.sql new file mode 100644 index 0000000..981d004 --- /dev/null +++ b/src/main/kotlin/com/learner/language/system/db/migration/202604200001_create_shorts_and_seed_feed.sql @@ -0,0 +1,53 @@ +CREATE TABLE shorts ( + id BIGINT NOT NULL AUTO_INCREMENT, + youtube_video_id VARCHAR(11) NOT NULL, + source_url VARCHAR(255) NOT NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_shorts_youtube_video_id (youtube_video_id), + KEY idx_shorts_active_created_at (is_active, created_at, id), + CONSTRAINT ck_shorts_youtube_video_id_len CHECK (CHAR_LENGTH(youtube_video_id) = 11) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE user_saved_short ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + short_id BIGINT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + CONSTRAINT fk_user_saved_short_user FOREIGN KEY (user_id) REFERENCES `user`(id), + CONSTRAINT fk_user_saved_short_short FOREIGN KEY (short_id) REFERENCES shorts(id) ON DELETE CASCADE, + UNIQUE KEY uk_user_saved_short_user_short (user_id, short_id), + KEY idx_user_saved_short_user_id_created_at (user_id, created_at), + KEY idx_user_saved_short_short_id (short_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO shorts (youtube_video_id, source_url) +VALUES + ('fxqR_mh03YI', 'https://www.youtube.com/shorts/fxqR_mh03YI'), + ('fo49QHjmk74', 'https://www.youtube.com/shorts/fo49QHjmk74'), + ('mDCduZ3JwZQ', 'https://www.youtube.com/shorts/mDCduZ3JwZQ'), + ('ql7Xr1YVgcI', 'https://www.youtube.com/shorts/ql7Xr1YVgcI'), + ('ezKHBhWiE84', 'https://www.youtube.com/shorts/ezKHBhWiE84'), + ('w0jePoXgL0k', 'https://www.youtube.com/shorts/w0jePoXgL0k'); + +INSERT INTO clip_source_video (id, youtube_video_id, source_url, source_title, channel_name, thumbnail_url) +VALUES + (802, 'TmkcVc60x8Y', 'https://www.youtube.com/watch?v=TmkcVc60x8Y', 'Clip 2 source', + 'Clip Source Channel', 'https://img.youtube.com/vi/TmkcVc60x8Y/hqdefault.jpg'), + (803, 'QrGbJz5H1PU', 'https://www.youtube.com/watch?v=QrGbJz5H1PU', 'Clip 3 source', + 'Clip Source Channel', 'https://img.youtube.com/vi/QrGbJz5H1PU/hqdefault.jpg'), + (804, 'opqq01bu764', 'https://www.youtube.com/watch?v=opqq01bu764', 'Clip 4 source', + 'Clip Source Channel', 'https://img.youtube.com/vi/opqq01bu764/hqdefault.jpg'), + (805, 'd8v0hgMJZDU', 'https://www.youtube.com/watch?v=d8v0hgMJZDU', 'Clip 5 source', + 'Clip Source Channel', 'https://img.youtube.com/vi/d8v0hgMJZDU/hqdefault.jpg'); + +INSERT INTO clip_learning_clip (id, source_video_id, title, category, clip_start_ms, clip_end_ms, clip_duration_ms) +VALUES + (2002, 802, 'Clip 2', 'general', 0, 0, 0), + (2003, 803, 'Clip 3', 'general', 0, 0, 0), + (2004, 804, 'Clip 4', 'general', 0, 0, 0), + (2005, 805, 'Clip 5', 'general', 0, 0, 0);