Skip to content

Commit

Permalink
[Feat/#228] 아티클 목록 조회(무한스크롤) API 추가 (#259)
Browse files Browse the repository at this point in the history
* docs: flyway V1.00.0.15 배포

* refactor: article_main_card 테이블에서 content 삭제

* refactor: LocalDateTime에 대한 Json serialize 설정 추가

* feat: 아티클 목록 조회에서 카테고리 코드 파람 추가

* test: 아티클 목록 조회 API 카테고리 코드 추가  반영

* feat: 아티클 카테고리 조회 API 추가

* fix: PK 수정

* fix: 아티클 카테고리 조회시 displayname을 보내도록 수정

* feat: 아티클 목록 조회 API 1차 구현(step1: 조회수 및 카테고리 기반 정렬 상위 10개 조회)

* feat: 아티클 최초 생성시 조회수 테이블 0으로 초기화하도록 변경

* feat: isLast에 대한 로직 처리를 UC로 이동

* test: article 카테고리 조회 API 테스트 구현

* fix: ARTICLE_MAIN_CARD 컬럼 수정

* fix: ARTICLE_MAIN_CARD 테이블 컬럼 타입 변경

* fix: ARTICLE_MAIN_CARD 테이블 컬럼 타입 변경 (description 컬럼 json으로 유지)

* feat: 아티클 목록 조회, 저장 SQL 추가

* feat: 아티클 컨텐츠 조회 SQL 생성

* feat: 아티클 Main card DB 접근의 경우 한정 구현

* chore: TODO 주석 추가

* fix: selectArticlesOrderByViewsQuery 문법 수정

* fix: article main card 뷰 조합 SQL 수정(테이블명 수정)

* fix: workbooks 삭제

* fix: json query

* fix: ARTICLE_MAIN_CARD 테이블에서 workbooks 컬럼 삭제

* fix: 트랜잭션 readOnly 옵션 일시 삭제(insert 쿼리)

* fix: 워크북 응답 삭제

* feat: 조회수가 같을 경우 최신 아티클이 우선순위가 높도록 수정

* test: [GET] /api/v1/articles/categories 테스트 수정

* fix: 아티클이 이미 테이블에 있느 경우에도 컨텐츠 조회되도록 변경

* refactor: 쿼리 수정

* refactor: 아티클 카테고리 조회 API 수정 (DTO 삭제)

* fix: row rank 쿼리 문법 수정

* feat: record class <-> 쿼리 수행 결과 변환 매퍼 ArticleMainCardMapper 구현

* feat: ARTICLE_MAIN_CARD 테이블 workbooks 컬럼 추가(아티클이 포함된 워크북 정보 json)

* feat:JDBC URL 옵션 추가 - allowMultiQueries=true

* feat: record 필드(workbooks) 추가 및 워크북 json mapper 적용

* feat: 아티클 목록 조회 응답 바디 수정 (workbooks[] 추가)

* test: data.articles[].workbooks 응답 바디 추가 테스트 코드 수정

* fix: selectArticlesOrderByViewsQuery 문법 일부 수정

* test: Duplicate entry '[email protected]' for key 'member.email' 에러 해결

* refacotr: kotlin logger로 변경

* test: insertMember 메소드로 트랜잭션 분리

* test: #e3dd2b9 원복
  • Loading branch information
hun-ca authored Jul 31, 2024
1 parent dd2dbbd commit fe7e666
Show file tree
Hide file tree
Showing 31 changed files with 584 additions and 76 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.few.api.repo.dao.article.command.InsertFullArticleRecordCommand
import com.few.api.repo.dao.article.query.SelectArticleRecordQuery
import com.few.api.repo.dao.article.query.SelectWorkBookArticleRecordQuery
import com.few.api.repo.dao.article.query.SelectWorkbookMappedArticleRecordsQuery
import com.few.api.repo.dao.article.record.SelectArticleContentsRecord
import com.few.api.repo.dao.article.record.SelectArticleRecord
import com.few.api.repo.dao.article.record.SelectWorkBookArticleRecord
import com.few.api.repo.dao.article.record.SelectWorkBookMappedArticleRecord
Expand Down Expand Up @@ -99,17 +100,28 @@ class ArticleDao(
return mstId.getValue(ArticleMst.ARTICLE_MST.ID)
}

fun insertArticleIfoCommand(
mstId: Long,
command: InsertFullArticleRecordCommand,
) = dslContext.insertInto(ArticleIfo.ARTICLE_IFO)
.set(ArticleIfo.ARTICLE_IFO.ARTICLE_MST_ID, mstId)
.set(ArticleIfo.ARTICLE_IFO.CONTENT, command.content)

fun insertArticleMstCommand(command: InsertFullArticleRecordCommand) =
dslContext.insertInto(ArticleMst.ARTICLE_MST)
.set(ArticleMst.ARTICLE_MST.MEMBER_ID, command.writerId)
.set(ArticleMst.ARTICLE_MST.MAIN_IMAGE_URL, command.mainImageURL.toString())
.set(ArticleMst.ARTICLE_MST.TITLE, command.title)
.set(ArticleMst.ARTICLE_MST.CATEGORY_CD, command.category)

fun insertArticleIfoCommand(
mstId: Long,
command: InsertFullArticleRecordCommand,
) = dslContext.insertInto(ArticleIfo.ARTICLE_IFO)
.set(ArticleIfo.ARTICLE_IFO.ARTICLE_MST_ID, mstId)
.set(ArticleIfo.ARTICLE_IFO.CONTENT, command.content)
fun selectArticleContents(articleIds: Set<Long>): List<SelectArticleContentsRecord> =
selectArticleContentsQuery(articleIds)
.fetchInto(SelectArticleContentsRecord::class.java)

fun selectArticleContentsQuery(articleIds: Set<Long>) = dslContext.select(
ArticleIfo.ARTICLE_IFO.ARTICLE_MST_ID.`as`(SelectArticleContentsRecord::articleId.name),
ArticleIfo.ARTICLE_IFO.CONTENT.`as`(SelectArticleContentsRecord::content.name)
).from(ArticleIfo.ARTICLE_IFO)
.where(ArticleIfo.ARTICLE_IFO.ARTICLE_MST_ID.`in`(articleIds))
.and(ArticleIfo.ARTICLE_IFO.DELETED_AT.isNull)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package com.few.api.repo.dao.article

import jooq.jooq_dsl.tables.ArticleMst.ARTICLE_MST
import jooq.jooq_dsl.tables.Member.MEMBER
import com.few.api.repo.dao.article.record.ArticleMainCardRecord
import com.few.api.repo.dao.article.support.CommonJsonMapper
import com.few.api.repo.dao.article.support.ArticleMainCardMapper
import jooq.jooq_dsl.tables.ArticleMainCard.ARTICLE_MAIN_CARD
import jooq.jooq_dsl.tables.MappingWorkbookArticle.MAPPING_WORKBOOK_ARTICLE
import jooq.jooq_dsl.tables.Workbook.WORKBOOK
import org.jooq.*
import org.jooq.impl.DSL.*
import org.springframework.stereotype.Repository

@Repository
class ArticleMainCardDao(
private val dslContext: DSLContext,
private val commonJsonMapper: CommonJsonMapper,
private val articleMainCardMapper: ArticleMainCardMapper,
) {

fun selectArticleMainCardsRecord(articleIds: Set<Long>): Set<ArticleMainCardRecord> {
return selectArticleMainCardsRecordQuery(articleIds)
.fetch(articleMainCardMapper)
.toSet()
}

private fun selectArticleMainCardsRecordQuery(articleIds: Set<Long>) = dslContext.select(
ARTICLE_MAIN_CARD.ID.`as`(ArticleMainCardRecord::articleId.name),
ARTICLE_MAIN_CARD.TITLE.`as`(ArticleMainCardRecord::articleTitle.name),
ARTICLE_MAIN_CARD.MAIN_IMAGE_URL.`as`(ArticleMainCardRecord::mainImageUrl.name),
ARTICLE_MAIN_CARD.CATEGORY_CD.`as`(ArticleMainCardRecord::categoryCd.name),
ARTICLE_MAIN_CARD.CREATED_AT.`as`(ArticleMainCardRecord::createdAt.name),
ARTICLE_MAIN_CARD.WRITER_ID.`as`(ArticleMainCardRecord::writerId.name),
ARTICLE_MAIN_CARD.WRITER_EMAIL.`as`(ArticleMainCardRecord::writerEmail.name),
jsonGetAttributeAsText(
ARTICLE_MAIN_CARD.WRITER_DESCRIPTION,
"name"
).`as`(ArticleMainCardRecord::writerName.name),
jsonGetAttribute(ARTICLE_MAIN_CARD.WRITER_DESCRIPTION, "url").`as`(ArticleMainCardRecord::writerImgUrl.name),
ARTICLE_MAIN_CARD.WORKBOOKS.`as`(ArticleMainCardRecord::workbooks.name)
).from(ARTICLE_MAIN_CARD)
.where(ARTICLE_MAIN_CARD.ID.`in`(articleIds))
.query

fun selectByArticleMstAndMemberAndMappingWorkbookArticleAndWorkbook(articleIds: Set<Long>): Set<ArticleMainCardRecord> {
return selectByArticleMstAndMemberAndMappingWorkbookArticleAndWorkbookQuery(articleIds)
.fetch(articleMainCardMapper)
.toSet()
}

private fun selectByArticleMstAndMemberAndMappingWorkbookArticleAndWorkbookQuery(articleIds: Set<Long>) =
dslContext.select(
ARTICLE_MST.ID.`as`(ArticleMainCardRecord::articleId.name),
ARTICLE_MST.TITLE.`as`(ArticleMainCardRecord::articleTitle.name),
ARTICLE_MST.MAIN_IMAGE_URL.`as`(ArticleMainCardRecord::mainImageUrl.name),
ARTICLE_MST.CATEGORY_CD.`as`(ArticleMainCardRecord::categoryCd.name),
ARTICLE_MST.CREATED_AT.`as`(ArticleMainCardRecord::createdAt.name),
MEMBER.ID.`as`(ArticleMainCardRecord::writerId.name),
MEMBER.EMAIL.`as`(ArticleMainCardRecord::writerEmail.name),
jsonGetAttributeAsText(MEMBER.DESCRIPTION, "name").`as`(ArticleMainCardRecord::writerName.name),
jsonGetAttribute(MEMBER.DESCRIPTION, "url").`as`(ArticleMainCardRecord::writerImgUrl.name),
jsonArrayAgg(
jsonObject(
key("id").value(WORKBOOK.ID),
key("title").value(WORKBOOK.TITLE)
)
).`as`(ArticleMainCardRecord::workbooks.name)
)
.from(ARTICLE_MST)
.join(MEMBER).on(ARTICLE_MST.MEMBER_ID.eq(MEMBER.ID)).and(ARTICLE_MST.DELETED_AT.isNull)
.and(MEMBER.DELETED_AT.isNull)
.leftJoin(MAPPING_WORKBOOK_ARTICLE).on(ARTICLE_MST.ID.eq(MAPPING_WORKBOOK_ARTICLE.ARTICLE_ID)).and(MAPPING_WORKBOOK_ARTICLE.DELETED_AT.isNull)
.leftJoin(WORKBOOK).on(MAPPING_WORKBOOK_ARTICLE.WORKBOOK_ID.eq(WORKBOOK.ID)).and(WORKBOOK.DELETED_AT.isNull)
.where(ARTICLE_MST.ID.`in`(articleIds))
.groupBy(ARTICLE_MST.ID)
.query

fun insertArticleMainCardsBulk(commands: Set<ArticleMainCardRecord>) {
dslContext.batch(
commands.map { command -> insertArticleMainCardsBulkQuery(command) }
).execute()
}

fun insertArticleMainCardsBulkQuery(command: ArticleMainCardRecord) =
dslContext.insertInto(
ARTICLE_MAIN_CARD,
ARTICLE_MAIN_CARD.ID,
ARTICLE_MAIN_CARD.TITLE,
ARTICLE_MAIN_CARD.MAIN_IMAGE_URL,
ARTICLE_MAIN_CARD.CATEGORY_CD,
ARTICLE_MAIN_CARD.CREATED_AT,
ARTICLE_MAIN_CARD.WRITER_ID,
ARTICLE_MAIN_CARD.WRITER_EMAIL,
ARTICLE_MAIN_CARD.WRITER_DESCRIPTION,
ARTICLE_MAIN_CARD.WORKBOOKS
).values(
command.articleId,
command.articleTitle,
command.mainImageUrl.toString(),
command.categoryCd,
command.createdAt,
command.writerId,
command.writerEmail,
JSON.valueOf(
commonJsonMapper.toJsonStr(
mapOf(
"name" to command.writerName,
"url" to command.writerImgUrl
)
)
),
JSON.valueOf(articleMainCardMapper.toJsonStr(command.workbooks))
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@ package com.few.api.repo.dao.article

import com.few.api.repo.dao.article.command.ArticleViewCountCommand
import com.few.api.repo.dao.article.query.ArticleViewCountQuery
import com.few.api.repo.dao.article.query.SelectArticlesOrderByViewsQuery
import com.few.api.repo.dao.article.query.SelectRankByViewsQuery
import com.few.api.repo.dao.article.record.SelectArticleViewsRecord
import jooq.jooq_dsl.tables.ArticleViewCount.ARTICLE_VIEW_COUNT
import org.jooq.DSLContext
import org.jooq.Record2
import org.jooq.SelectLimitPercentStep
import org.jooq.impl.DSL.*
import org.springframework.stereotype.Repository

@Repository
Expand All @@ -24,6 +30,13 @@ class ArticleViewCountDao(
.onDuplicateKeyUpdate()
.set(ARTICLE_VIEW_COUNT.VIEW_COUNT, ARTICLE_VIEW_COUNT.VIEW_COUNT.plus(1))

fun insertArticleViewCountToZero(query: ArticleViewCountQuery) = insertArticleViewCountToZeroQuery(query).execute()

fun insertArticleViewCountToZeroQuery(query: ArticleViewCountQuery) = dslContext.insertInto(ARTICLE_VIEW_COUNT)
.set(ARTICLE_VIEW_COUNT.ARTICLE_ID, query.articleId)
.set(ARTICLE_VIEW_COUNT.VIEW_COUNT, 0)
.set(ARTICLE_VIEW_COUNT.CATEGORY_CD, query.categoryType.code)

fun selectArticleViewCount(command: ArticleViewCountCommand): Long? {
return selectArticleViewCountQuery(command).fetchOneInto(Long::class.java)
}
Expand All @@ -34,4 +47,52 @@ class ArticleViewCountDao(
.where(ARTICLE_VIEW_COUNT.ARTICLE_ID.eq(command.articleId))
.and(ARTICLE_VIEW_COUNT.DELETED_AT.isNull)
.query

fun selectRankByViews(query: SelectRankByViewsQuery): Long? {
return selectRankByViewsQuery(query)
.fetchOneInto(Long::class.java)
}

fun selectRankByViewsQuery(query: SelectRankByViewsQuery) = dslContext
.select(field("row_rank_tb.offset", Long::class.java))
.from(
dslContext
.select(
ARTICLE_VIEW_COUNT.ARTICLE_ID,
rowNumber().over(orderBy(ARTICLE_VIEW_COUNT.VIEW_COUNT.desc())).`as`("offset")
)
.from(ARTICLE_VIEW_COUNT)
.asTable("row_rank_tb")
)
.where(field("row_rank_tb.${ARTICLE_VIEW_COUNT.ARTICLE_ID.name}")!!.eq(query.articleId))
.query

fun selectArticlesOrderByViews(query: SelectArticlesOrderByViewsQuery): Set<SelectArticleViewsRecord> {
return selectArticlesOrderByViewsQuery(query)
.fetchInto(SelectArticleViewsRecord::class.java)
.toSet()
}

fun selectArticlesOrderByViewsQuery(query: SelectArticlesOrderByViewsQuery): SelectLimitPercentStep<Record2<Any, Any>> {
val articleViewCountOffsetTb = select()
.from(ARTICLE_VIEW_COUNT)
.where(ARTICLE_VIEW_COUNT.DELETED_AT.isNull)
.orderBy(ARTICLE_VIEW_COUNT.VIEW_COUNT.desc())
.limit(query.offset, Long.MAX_VALUE)
.asTable("article_view_count_offset_tb")

val baseQuery = dslContext.select(
field("article_view_count_offset_tb.article_id").`as`(SelectArticleViewsRecord::articleId.name),
field("article_view_count_offset_tb.view_count").`as`(SelectArticleViewsRecord::views.name)
)
.from(articleViewCountOffsetTb)

return if (query.category != null) {
baseQuery.where(field("article_view_count_offset_tb.category_cd").eq(query.category.code))
.limit(10)
} else {
baseQuery
.limit(10)
} // TODO: .query로 리턴하면 리턴 타입이 달라져 못받는 문제
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.few.api.repo.dao.article.query

import com.few.data.common.code.CategoryType

data class SelectArticlesOrderByViewsQuery(
val offset: Long,
val category: CategoryType?,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.few.api.repo.dao.article.query

data class SelectRankByViewsQuery(
val articleId: Long,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.few.api.repo.dao.article.record

import java.net.URL
import java.time.LocalDateTime

data class ArticleMainCardRecord(
val articleId: Long,
val articleTitle: String,
val mainImageUrl: URL,
val categoryCd: Byte,
val createdAt: LocalDateTime,
val writerId: Long,
val writerEmail: String,
val writerName: String,
val writerImgUrl: URL,
val workbooks: List<WorkbookRecord> = emptyList(),
) {
var content: String = ""
set(value) {
field = value
}

var views: Long = 0L
set(value) {
field = value
}
}

data class WorkbookRecord(
val id: Long?,
val title: String?,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.few.api.repo.dao.article.record

data class SelectArticleContentsRecord(
val articleId: Long,
val content: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.few.api.repo.dao.article.record

data class SelectArticleViewsRecord(
val articleId: Long,
val views: Long,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.few.api.repo.dao.article.support

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.few.api.repo.dao.article.record.ArticleMainCardRecord
import com.few.api.repo.dao.article.record.WorkbookRecord
import org.jooq.JSON
import org.jooq.RecordMapper
import org.jooq.Record
import org.springframework.stereotype.Component
import java.net.URL
import java.time.LocalDateTime

@Component
class ArticleMainCardMapper(
private val objectMapper: ObjectMapper,
) : RecordMapper<Record, ArticleMainCardRecord> {
override fun map(record: Record): ArticleMainCardRecord {
val workbooksJsonArrayStr: String = record.get(ArticleMainCardRecord::workbooks.name, JSON::class.java).data()

return ArticleMainCardRecord(
articleId = record.get(ArticleMainCardRecord::articleId.name, Long::class.java),
articleTitle = record.get(ArticleMainCardRecord::articleTitle.name, String::class.java),
mainImageUrl = record.get(ArticleMainCardRecord::mainImageUrl.name, URL::class.java),
categoryCd = record.get(ArticleMainCardRecord::categoryCd.name, Byte::class.java),
createdAt = record.get(ArticleMainCardRecord::createdAt.name, LocalDateTime::class.java),
writerId = record.get(ArticleMainCardRecord::writerId.name, Long::class.java),
writerEmail = record.get(ArticleMainCardRecord::writerEmail.name, String::class.java),
writerName = record.get(ArticleMainCardRecord::writerName.name, String::class.java),
writerImgUrl = record.get(ArticleMainCardRecord::writerImgUrl.name, URL::class.java),
workbooks = objectMapper.readValue<List<WorkbookRecord>>(workbooksJsonArrayStr)
)
}

fun toJsonStr(workbooks: List<WorkbookRecord>) = objectMapper.writeValueAsString(workbooks)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.few.api.repo.dao.article.support

import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.stereotype.Component

@Component
class CommonJsonMapper( // TODO: common 성 패키지 위치로 이동
private val objectMapper: ObjectMapper,
) {
fun toJsonStr(map: Map<String, Any>): String {
return objectMapper.writeValueAsString(map)
}
}
2 changes: 1 addition & 1 deletion api-repo/src/main/resources/application-api-repo-local.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
spring:
datasource:
hikari:
jdbcUrl: jdbc:mysql://localhost:13306/api?allowPublicKeyRetrieval=true&rewriteBatchedStatements=true
jdbcUrl: jdbc:mysql://localhost:13306/api?allowPublicKeyRetrieval=true&rewriteBatchedStatements=true&allowMultiQueries=true
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
Expand Down
2 changes: 1 addition & 1 deletion api-repo/src/main/resources/application-api-repo-prd.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
spring:
datasource:
hikari:
jdbcUrl: ${DB_HOSTNAME}/api?allowPublicKeyRetrieval=true&rewriteBatchedStatements=true
jdbcUrl: ${DB_HOSTNAME}/api?allowPublicKeyRetrieval=true&rewriteBatchedStatements=true&allowMultiQueries=true
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
Expand Down
Loading

0 comments on commit fe7e666

Please sign in to comment.