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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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<Long>): Set<Long> {
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),
)
}
}
26 changes: 26 additions & 0 deletions src/main/kotlin/com/learner/language/domain/shorts/Shorts.kt
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.learner.language.infrastructure.shorts

interface ShortsFeedQueryRepository {
fun findFeedRows(cursor: String?, size: Int): List<ShortsFeedRow>
}

data class ShortsFeedRow(
val shortId: Long,
val youtubeVideoId: String,
val sourceUrl: String,
)
Original file line number Diff line number Diff line change
@@ -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<ShortsFeedRow> {
val query = jpql {
val short = entity(Shorts::class, "short")

selectNew<ShortsFeedRow>(
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<Shorts>,
cursor: String?,
): Array<com.linecorp.kotlinjdsl.querymodel.jpql.predicate.Predicate> {
val predicates = mutableListOf<com.linecorp.kotlinjdsl.querymodel.jpql.predicate.Predicate>()

predicates += short(Shorts::isActive).equal(true)

val cursorId = cursor?.toLongOrNull()
if (cursorId != null) {
predicates += short(Shorts::id).lessThan(cursorId)
}

return predicates.toTypedArray()
}
}
Original file line number Diff line number Diff line change
@@ -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<Shorts, Long>
Original file line number Diff line number Diff line change
@@ -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<UserSavedShort, Long> {
fun findByUserIdAndShortId(userId: Long, shortId: Long): Optional<UserSavedShort>
fun findAllByUserIdAndShortIdIn(userId: Long, shortIds: List<Long>): List<UserSavedShort>
fun existsByUserIdAndShortId(userId: Long, shortId: Long): Boolean
fun deleteByUserIdAndShortId(userId: Long, shortId: Long)
}
Original file line number Diff line number Diff line change
@@ -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<ShortsFeedDto.FeedResponse> {
val response = shortsService.retrieveFeed(userId, request)
return ResponseEntity.status(HttpStatus.OK).body(response)
}
}
Original file line number Diff line number Diff line change
@@ -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<ShortsFeedItem>,
val paging: ShortsPagingPayload,
)
}
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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);