Skip to content
Merged
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
Expand Up @@ -61,7 +61,7 @@ class ArticleController(
"제목·본문·대표 이미지(선택)로 새 문서를 만든다. `multipart/form-data`로 전송하며 이미지는 최대 10MB. " +
"생성된 문서는 리비전 1로 히스토리에 기록되고, 제목은 이후 수정할 수 없다. " +
"로그인한 상태로 만들면 생성자가 기록되어 내 기여 통계의 '새로 만든 문서 수'에 반영된다. " +
"제목은 앞뒤 공백 제거·유니코드(NFC) 정규화·연속 공백 축소 후 저장되며, 대소문자 구분 없이 유일해야 한다.",
"`categoryId`로 분류를 지정할 수 있다(선택). 제목은 앞뒤 공백 제거·유니코드(NFC) 정규화·연속 공백 축소 후 저장되며, 대소문자 구분 없이 유일해야 한다.",
)
@ApiResponses(
value = [
Expand Down Expand Up @@ -182,7 +182,8 @@ class ArticleController(
description =
"문서 본문을 교체하고, 이미지를 함께 보내면 대표 이미지도 교체한다(안 보내면 기존 이미지 유지). " +
"로그인한 학생 계정만 가능하며 수정할 때마다 새 리비전이 생성된다. 제목은 수정할 수 없다. " +
"편집 요약(`summary`, 최대 500자)과 가벼운 수정 여부(`minorEdit`)를 선택적으로 함께 보낼 수 있다. " +
"편집 요약(`summary`, 최대 500자)과 가벼운 수정 여부(`minorEdit`)를 선택적으로 함께 보낼 수 있고, " +
"`categoryId`를 보내면 분류가 바뀐다(생략하면 유지). " +
"`multipart/form-data`로 전송하며 이미지는 최대 10MB.",
)
@ApiResponses(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@ data class CreateArticleReqDto(
val content: String,
@field:Schema(description = "대표 이미지 파일 (선택, 최대 10MB)")
val image: MultipartFile? = null,
@field:Schema(description = "문서 분류 ID (선택). 분류 목록 API로 확인한다.", example = "1")
val categoryId: Long? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,6 @@ data class UpdateArticleReqDto(
val summary: String? = null,
@field:Schema(description = "가벼운 수정 여부 (선택, 기본 false)", example = "false")
val minorEdit: Boolean = false,
@field:Schema(description = "바꿀 문서 분류 ID (선택, 생략하면 기존 분류 유지). 분류를 비우는 기능은 지원하지 않는다.", example = "1")
val categoryId: Long? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package io.github.wntopia.gikipedia.server.domain.article.dto.response

import io.github.wntopia.gikipedia.server.domain.article.entity.ArticleJpaEntity
import io.github.wntopia.gikipedia.server.domain.article.entity.ArticleMongoEntity
import io.github.wntopia.gikipedia.server.domain.category.dto.response.CategoryResDto
import io.swagger.v3.oas.annotations.media.Schema
import java.time.Instant

Expand All @@ -18,6 +19,8 @@ data class ArticleResDto(
val createdAt: Instant?,
@field:Schema(description = "마지막 수정 시각 (ISO-8601, UTC)")
val updatedAt: Instant?,
@field:Schema(description = "문서 분류 (분류가 없으면 null)")
val category: CategoryResDto? = null,
) {
companion object {
fun from(entity: ArticleJpaEntity): ArticleResDto =
Expand All @@ -28,6 +31,7 @@ data class ArticleResDto(
imageUrl = entity.imageUrl,
createdAt = entity.createdAt,
updatedAt = entity.updatedAt,
category = entity.category?.let { CategoryResDto(requireNotNull(it.id), it.name) },
)

fun from(entity: ArticleMongoEntity): ArticleResDto =
Expand All @@ -38,6 +42,7 @@ data class ArticleResDto(
imageUrl = entity.imageUrl,
createdAt = entity.articleCreatedAt,
updatedAt = entity.articleUpdatedAt,
category = entity.categoryId?.let { id -> entity.categoryName?.let { CategoryResDto(id, it) } },
)
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.github.wntopia.gikipedia.server.domain.article.entity

import io.github.wntopia.gikipedia.server.domain.category.entity.CategoryJpaEntity
import io.github.wntopia.gikipedia.server.global.entity.BaseJpaEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
Expand Down Expand Up @@ -42,6 +43,15 @@ class ArticleJpaEntity(
var imageUrl: String? = imageUrl
protected set

@field:ManyToOne(fetch = FetchType.LAZY)
@field:JoinColumn(name = "category_id")
var category: CategoryJpaEntity? = null
protected set

fun changeCategory(category: CategoryJpaEntity?) {
this.category = category
}

/** title은 생성 시점에만 정해지고 이후 수정 불가능하다 — 여기서 받지 않는다. */
fun update(
content: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,6 @@ class ArticleMongoEntity(
@field:Field("article_updated_at") val articleUpdatedAt: Instant,
@field:Field("revision") val revision: Int,
@field:Field("search_text") val searchText: String,
@field:Field("category_id") val categoryId: Long? = null,
@field:Field("category_name") val categoryName: String? = null,
) : BaseMongoEntity()
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,27 @@ import org.springframework.data.repository.query.Param
interface ArticleRepository : JpaRepository<ArticleJpaEntity, Long> {
fun existsByTitle(title: String): Boolean

/**
* 분류를 함께 가져오는 단건 조회. 분류는 지연 로딩이라, 트랜잭션 밖(정합성 배치 등)에서 `findById` 결과의
* `category.name`을 읽으면 `LazyInitializationException`이 난다.
*/
@Query("SELECT a FROM ArticleJpaEntity a LEFT JOIN FETCH a.category WHERE a.id = :id")
fun findByIdWithCategory(
@Param("id") id: Long,
): ArticleJpaEntity?

/** 분류에 속한 문서를 최근 수정 순으로. 본문은 읽지 않는다. */
@Query(
value =
"SELECT a.id AS id, a.title AS title, a.createdAt AS createdAt, a.updatedAt AS updatedAt " +
"FROM ArticleJpaEntity a WHERE a.category.id = :categoryId ORDER BY a.updatedAt DESC, a.id DESC",
countQuery = "SELECT COUNT(a) FROM ArticleJpaEntity a WHERE a.category.id = :categoryId",
)
fun findByCategory(
@Param("categoryId") categoryId: Long,
pageable: Pageable,
): Page<ArticleRecentView>

/** 최근에 수정된 문서 순(수정 시각 내림차순). 본문은 읽지 않는다. */
@Query(
value =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ class ArticleConsistencyCheckScheduler(
trueRevision: Int,
) {
try {
val article = articleRepository.findById(articleId).orElse(null)
val article = articleRepository.findByIdWithCategory(articleId)
if (article == null) {
log.warn("복구 대상 article을 MySQL에서 찾을 수 없음, 건너뜀 (articleId={})", articleId)
return
Expand All @@ -115,6 +115,8 @@ class ArticleConsistencyCheckScheduler(
imageUrl = article.imageUrl,
articleCreatedAt = createdAt,
articleUpdatedAt = updatedAt,
categoryId = article.category?.id,
categoryName = article.category?.name,
)
} catch (e: Exception) {
log.error("article {} 복구 중 실패, 다음 article은 계속 진행", articleId, e)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ class ArticleMongoSynchronizer(
imageUrl: String?,
articleCreatedAt: Instant,
articleUpdatedAt: Instant,
categoryId: Long? = null,
categoryName: String? = null,
) {
val searchText = ArticleSearchTextConverter.convert(content)
val byDocumentId = Query(Criteria.where("document_id").`is`(articleId))
Expand All @@ -48,6 +50,8 @@ class ArticleMongoSynchronizer(
.setOnInsert("image_url", imageUrl)
.setOnInsert("article_created_at", articleCreatedAt)
.setOnInsert("article_updated_at", articleUpdatedAt)
.setOnInsert("category_id", categoryId)
.setOnInsert("category_name", categoryName)

val result =
try {
Expand Down Expand Up @@ -81,7 +85,9 @@ class ArticleMongoSynchronizer(
.set("search_text", searchText)
.set("image_url", imageUrl)
.set("article_created_at", articleCreatedAt)
.set("article_updated_at", articleUpdatedAt),
.set("article_updated_at", articleUpdatedAt)
.set("category_id", categoryId)
.set("category_name", categoryName),
ArticleMongoEntity::class.java,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import io.github.wntopia.gikipedia.server.domain.article.repository.ArticleRepos
import io.github.wntopia.gikipedia.server.domain.article.service.ARTICLE_IMAGE_KEY_PREFIX
import io.github.wntopia.gikipedia.server.domain.article.service.ArticleCacheStore
import io.github.wntopia.gikipedia.server.domain.article.service.CreateArticleService
import io.github.wntopia.gikipedia.server.domain.category.repository.CategoryRepository
import io.github.wntopia.gikipedia.server.domain.history.event.ArticleUpdatedEvent
import io.github.wntopia.gikipedia.server.domain.link.service.ArticleLinkIndexer
import io.github.wntopia.gikipedia.server.domain.user.service.ContributionRecorder
Expand All @@ -30,6 +31,7 @@ class CreateArticleServiceImpl(
private val authenticationReader: AuthenticationReader,
private val contributionRecorder: ContributionRecorder,
private val articleLinkIndexer: ArticleLinkIndexer,
private val categoryRepository: CategoryRepository,
) : CreateArticleService {
@Transactional
override fun execute(
Expand All @@ -41,6 +43,12 @@ class CreateArticleServiceImpl(

// 비로그인 생성도 허용하므로 로그인한 경우에만 생성자를 기록한다.
val createdByUserId = authenticationReader.getCurrentUser(session)?.id
val category =
reqDto.categoryId?.let {
categoryRepository.findById(it).orElseThrow {
ExpectedException("존재하지 않는 분류입니다.", HttpStatus.NOT_FOUND)
}
}
val imageUrl =
reqDto.image?.takeIf { !it.isEmpty }?.let {
seaweedUploader.upload(
Expand All @@ -52,7 +60,9 @@ class CreateArticleServiceImpl(
val saved =
try {
articleRepository.saveAndFlush(
ArticleJpaEntity(title, reqDto.content, imageUrl, createdByUserId = createdByUserId),
ArticleJpaEntity(title, reqDto.content, imageUrl, createdByUserId = createdByUserId).also {
it.changeCategory(category)
},
)
} catch (e: DataIntegrityViolationException) {
throw duplicateTitle()
Expand All @@ -72,6 +82,8 @@ class CreateArticleServiceImpl(
imageUrl = saved.imageUrl,
createdAt = requireNotNull(saved.createdAt) { "saved.createdAt이 null입니다 (articleId=${saved.id})" },
updatedAt = requireNotNull(saved.updatedAt) { "saved.updatedAt이 null입니다 (articleId=${saved.id})" },
categoryId = category?.id,
categoryName = category?.name,
),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ class QueryArticleServiceImpl(
// 하나만 실행될 때 그 호출에 한해서만 짧게 트랜잭션이 열리고 닫힌다.
private fun queryFromMysql(articleId: Long): ArticleResDto =
articleRepository
.findById(articleId)
.orElseThrow { notFound() }
.let(ArticleResDto::from)
.findByIdWithCategory(articleId)
?.let(ArticleResDto::from)
?: throw notFound()

/**
* Redis miss 시 Mongo를 조회해 backfill한다. Mongo까지 miss면(정상적으로는 생성 이벤트가 이미
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,23 @@ import io.github.wntopia.gikipedia.server.domain.article.dto.response.ArticleRes
import io.github.wntopia.gikipedia.server.domain.article.service.ARTICLE_IMAGE_KEY_PREFIX
import io.github.wntopia.gikipedia.server.domain.article.service.ArticleUpdateTransactionHelper
import io.github.wntopia.gikipedia.server.domain.article.service.UpdateArticleService
import io.github.wntopia.gikipedia.server.domain.category.repository.CategoryRepository
import io.github.wntopia.gikipedia.server.domain.history.dto.EditMetadata
import io.github.wntopia.gikipedia.server.global.security.session.AuthenticationReader
import io.github.wntopia.gikipedia.server.global.storage.SeaweedUploader
import jakarta.servlet.http.HttpSession
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.support.TransactionTemplate
import team.themoment.sdk.exception.ExpectedException

@Service
class UpdateArticleServiceImpl(
private val seaweedUploader: SeaweedUploader,
private val authenticationReader: AuthenticationReader,
private val transactionTemplate: TransactionTemplate,
private val articleUpdateTransactionHelper: ArticleUpdateTransactionHelper,
private val categoryRepository: CategoryRepository,
) : UpdateArticleService {
/**
* SeaweedFS 업로드(블로킹 네트워크 호출)는 DB와 무관하므로 트랜잭션 밖에서 끝내둔다. `@Transactional`로 메서드
Expand Down Expand Up @@ -52,6 +56,13 @@ class UpdateArticleServiceImpl(
val previousContent = article.content
val imageUrl = uploadedImageUrl ?: article.imageUrl
article.update(reqDto.content, imageUrl)
reqDto.categoryId?.let { categoryId ->
article.changeCategory(
categoryRepository.findById(categoryId).orElseThrow {
ExpectedException("존재하지 않는 분류입니다.", HttpStatus.NOT_FOUND)
},
)
}
previousContent to reqDto.content
}
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package io.github.wntopia.gikipedia.server.domain.category.controller

import io.github.wntopia.gikipedia.server.domain.category.dto.request.CreateCategoryReqDto
import io.github.wntopia.gikipedia.server.domain.category.dto.response.CategoryArticleResDto
import io.github.wntopia.gikipedia.server.domain.category.dto.response.CategoryListItemResDto
import io.github.wntopia.gikipedia.server.domain.category.dto.response.CategoryResDto
import io.github.wntopia.gikipedia.server.domain.category.service.CreateCategoryService
import io.github.wntopia.gikipedia.server.domain.category.service.QueryCategoriesService
import io.github.wntopia.gikipedia.server.domain.category.service.QueryCategoryArticlesService
import io.github.wntopia.gikipedia.server.global.dto.PageResDto
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses
import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.servlet.http.HttpSession
import jakarta.validation.Valid
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import team.themoment.sdk.response.CommonApiResponse

@Tag(name = "Category", description = "문서 분류 API. 문서는 생성·수정 시 `categoryId`로 분류를 지정한다.")
@RestController
@RequestMapping("/api/v1/categories")
class CategoryController(
private val createCategoryService: CreateCategoryService,
private val queryCategoriesService: QueryCategoriesService,
private val queryCategoryArticlesService: QueryCategoryArticlesService,
) {
@Operation(
summary = "분류 생성 (관리자)",
description = "새 분류를 만든다. ADMIN·ROOT 권한만 호출할 수 있고, 이름은 앞뒤 공백을 제거한 뒤 중복될 수 없다.",
)
@ApiResponses(
value = [
ApiResponse(responseCode = "201", description = "생성 성공. `data`에 만들어진 분류가 담긴다."),
ApiResponse(responseCode = "400", description = "이름이 비어 있거나 50자를 초과함"),
ApiResponse(responseCode = "401", description = "로그인하지 않음"),
ApiResponse(responseCode = "403", description = "관리자 권한 없음"),
ApiResponse(responseCode = "409", description = "같은 이름의 분류가 이미 존재함"),
],
)
@PostMapping
fun create(
@Valid @RequestBody reqDto: CreateCategoryReqDto,
@Parameter(hidden = true) session: HttpSession,
): CommonApiResponse<CategoryResDto> = CommonApiResponse.created("", createCategoryService.execute(reqDto, session))

@Operation(
summary = "분류 목록 조회",
description = "모든 분류와 분류별 문서 수를 이름 순으로 반환한다. 홈의 '분류' 목록에 쓴다. 로그인 없이 호출할 수 있다.",
)
@ApiResponses(value = [ApiResponse(responseCode = "200", description = "조회 성공")])
@GetMapping
fun list(): List<CategoryListItemResDto> = queryCategoriesService.execute()

@Operation(
summary = "분류별 문서 목록 조회",
description = "해당 분류에 속한 문서를 최근 수정 순으로 페이지 단위로 반환한다. 로그인 없이 호출할 수 있다.",
)
@ApiResponses(
value = [
ApiResponse(responseCode = "200", description = "조회 성공"),
ApiResponse(responseCode = "400", description = "page 또는 size가 범위를 벗어남"),
ApiResponse(responseCode = "404", description = "존재하지 않는 분류"),
],
)
@GetMapping("/{categoryId}/articles")
fun articles(
@Parameter(description = "분류 ID", example = "1") @PathVariable categoryId: Long,
@Parameter(description = "페이지 번호 (0부터, 기본 0)", example = "0")
@RequestParam(defaultValue = "0")
@Min(0) page: Int,
@Parameter(description = "페이지 크기 (1~100, 기본 20)", example = "20")
@RequestParam(defaultValue = "20")
@Min(1)
@Max(100) size: Int,
): PageResDto<CategoryArticleResDto> = queryCategoryArticlesService.execute(categoryId, page, size)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package io.github.wntopia.gikipedia.server.domain.category.dto.request

import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size

data class CreateCategoryReqDto(
@field:Schema(description = "분류 이름 (최대 50자, 앞뒤 공백은 제거되며 중복 불가)", example = "동아리")
@field:NotBlank(message = "분류 이름은 비어 있을 수 없습니다.")
@field:Size(max = 50, message = "분류 이름은 50자를 초과할 수 없습니다.")
val name: String,
)
Loading
Loading