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
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ dependencies {
runtimeOnly("com.mysql:mysql-connector-j")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
annotationProcessor("org.projectlombok:lombok")
testImplementation("org.springframework.boot:spring-boot-starter-data-jdbc-test")
testImplementation("org.springframework.boot:spring-boot-starter-flyway-test")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.wafflestudio.team2server.article

import com.wafflestudio.team2server.DomainException
import org.springframework.http.HttpStatus
import org.springframework.http.HttpStatusCode

sealed class ArticleException(
errorCode: Int,
httpStatusCode: HttpStatusCode,
msg: String,
cause: Throwable? = null,
) : DomainException(errorCode, httpStatusCode, msg, cause)

class ArticleNotFoundException :
ArticleException(
errorCode = 0,
httpStatusCode = HttpStatus.NOT_FOUND,
msg = "Article not found",
)

class ArticleBlankContentException :
ArticleException(
errorCode = 0,
httpStatusCode = HttpStatus.BAD_REQUEST,
msg = "Content must not be blank",
)

class ArticleBlankAuthorException :
ArticleException(
errorCode = 0,
httpStatusCode = HttpStatus.BAD_REQUEST,
msg = "Author must not be blank",
)

class ArticleBlankPublishedException :
ArticleException(
errorCode = 0,
httpStatusCode = HttpStatus.BAD_REQUEST,
msg = "PublishedAt must not be blank",
)

class ArticleBlankOriginLinkException :
ArticleException(
errorCode = 0,
httpStatusCode = HttpStatus.BAD_REQUEST,
msg = "OriginLink must not be blank",
)

class ArticleBlankTitleException :
ArticleException(
errorCode = 0,
httpStatusCode = HttpStatus.BAD_REQUEST,
msg = "Title must not be blank",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package com.wafflestudio.team2server.article.controller

import com.wafflestudio.team2server.article.dto.UpdateArticleRequest
import com.wafflestudio.team2server.article.dto.core.ArticleDto
import com.wafflestudio.team2server.article.dto.request.CreateArticleRequest
import com.wafflestudio.team2server.article.dto.response.ArticlePagingResponse
import com.wafflestudio.team2server.article.dto.response.CreateArticleResponse
import com.wafflestudio.team2server.article.service.ArticleService
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 org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PatchMapping
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 java.time.Instant

@RestController
@RequestMapping("/api/v1")
@Tag(name = "Article", description = "게시글 관리 API")
class ArticleController(
private val articleService: ArticleService,
) {
@Operation(summary = "게시글 생성", description = "게시판에 글이 작성되는지 확인.")
@ApiResponses(
value = [
ApiResponse(responseCode = "201", description = "게시글 생성"),
ApiResponse(responseCode = "400", description = "잘못된 요청(제목, 글쓴이, 원본링크, 글 작성시간 중 하나가 작성되지 않음)"),
ApiResponse(responseCode = "404", description = "게시판을 찾을 수 없음"),
],
)
@PostMapping("/boards/{boardId}/articles")
fun create(
@Parameter(
description = "게시판 ID",
example = "1",
) @PathVariable boardId: Long,
@RequestBody createArticleRequest: CreateArticleRequest,
): ResponseEntity<CreateArticleResponse> {
val articleDto =
articleService.create(
content = createArticleRequest.content,
title = createArticleRequest.title,
author = createArticleRequest.author,
originLink = createArticleRequest.originLink,
publihedAt = createArticleRequest.publishedAt,
boardId = boardId,
)
return ResponseEntity.ok(articleDto)
}

@Operation(
summary = "게시글 목록 조회",
description = """
게시판의 게시글을 페이지네이션 해서 가져옴
커서 기반 페이지 네이션 사용
정렬: 게시글 생성시간 내림차순
""",
)
@ApiResponses(
value = [
ApiResponse(responseCode = "200", description = "게시글 목록 조회 성공"),
ApiResponse(responseCode = "404", description = "게시판을 찾을 수 없음"),
],
)
@GetMapping("/boards/{boardId}/articles")
fun paging(
@Parameter(description = "게시판 ID", example = "1")
@PathVariable boardId: Long,
@Parameter(
description = "다음 페이지 커서 - 이전 응답의 마지막 게시글 생성 시간 (Unix timestamp, milliseconds)",
) @RequestParam(value = "nextPublishedAt", required = false) nextPublishedAt: Long?,
@Parameter(
description = "다음 페이지 커서 - 이전 응답의 마지막 게시글 ID (nextPublishedAt와 함께 사용)",
) @RequestParam(value = "nextId", required = false) nextId: Long?,
@Parameter(
description = "페이지당 게시글 수",
example = "20",
) @RequestParam(value = "limit", defaultValue = "20") limit: Int,
): ResponseEntity<ArticlePagingResponse> {
val articlePagingResponse =
articleService.pageByBoardId(
boardId = boardId,
nextPublishedAt = nextPublishedAt?.let { Instant.ofEpochMilli(it) },
nextId = nextId,
limit = limit,
)
return ResponseEntity.ok(articlePagingResponse)
}

@Operation(summary = "특정 게시글 조회", description = "게시글 ID로 게시글 상세 정보 조회")
@ApiResponses(
value = [
ApiResponse(responseCode = "200", description = "특정 게시글 조회 성공"),
ApiResponse(responseCode = "404", description = "게시판을 찾을 수 없습니다."),
],
)
@GetMapping("/articles/{articleId}")
fun get(
@Parameter(
description = "게시글 ID",
example = "1",
) @PathVariable articleId: Long,
): ResponseEntity<ArticleDto> {
val articleDto = articleService.get(articleId)
return ResponseEntity.ok(articleDto)
}

@Operation(summary = "특정 게시글 업데이트", description = "게시글 ID로 게시글 상세 정보 업데이트")
@ApiResponses(
value = [
ApiResponse(responseCode = "200", description = "특정 게시글 업데이트 성공"),
ApiResponse(responseCode = "400", description = "잘못된 요청(제목, 글쓴이, 원본링크, 글 작성시간 중 하나가 작성되지 않음)"),
ApiResponse(responseCode = "404", description = "게시글을 찾을 수 없습니다."),
],
)
@PatchMapping("/articles/{articleId}")
fun update(
@Parameter(
description = "게시글 ID",
example = "1",
)
@PathVariable articleId: Long,
@RequestBody updateArticleRequest: UpdateArticleRequest,
): ResponseEntity<ArticleDto> {
val articleDto =
articleService.update(
articleId = articleId,
content = updateArticleRequest.content,
author = updateArticleRequest.author,
originLink = updateArticleRequest.originLink,
title = updateArticleRequest.title,
publishedAt = updateArticleRequest.publishedAt,
)
return ResponseEntity.ok(articleDto)
}

@Operation(summary = "특정 게시글 삭제", description = "게시글 ID로 게시글 삭제")
@ApiResponses(
value = [
ApiResponse(responseCode = "204", description = "특정 게시글 삭제 성공"),
ApiResponse(responseCode = "404", description = "게시글을 찾을 수 없습니다."),
],
)
@DeleteMapping("/articles/{articleId}")
fun delete(
@Parameter(
description = "게시글 ID",
example = "1",
)
@PathVariable articleId: Long,
): ResponseEntity<Unit> {
articleService.delete(articleId)
return ResponseEntity.noContent().build()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.wafflestudio.team2server.article.dto

data class ArticlePaging(
val nextPublishedAt: Long?,
val nextId: Long?,
val hasNext: Boolean,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.wafflestudio.team2server.article.dto.core

import com.wafflestudio.team2server.article.model.Article
import com.wafflestudio.team2server.article.model.ArticleWithBoard
import com.wafflestudio.team2server.board.dto.core.BoardDto
import com.wafflestudio.team2server.board.model.Board

data class ArticleDto(
val id: Long,
val board: BoardDto,
val content: String,
val author: String,
val originLink: String,
val title: String,
val publishedAt: Long,
val createdAt: Long,
val updatedAt: Long,
) {
constructor(article: Article, board: Board) : this(
id = article.id!!,
board = BoardDto(board),
content = article.content,
author = article.author,
originLink = article.originLink,
title = article.title,
publishedAt = article.publishedAt.toEpochMilli(),
createdAt = article.createdAt!!.toEpochMilli(),
updatedAt = article.updatedAt!!.toEpochMilli(),
)

constructor(articleWithBoard: ArticleWithBoard) : this(
id = articleWithBoard.id,
board =
BoardDto(
id = articleWithBoard.board!!.id,
name = articleWithBoard.board.name,
sourceUrl = articleWithBoard.board.sourceUrl,
),
content = articleWithBoard.content,
author = articleWithBoard.author,
originLink = articleWithBoard.originLink,
title = articleWithBoard.title,
publishedAt = articleWithBoard.publishedAt.toEpochMilli(),
createdAt = articleWithBoard.createdAt.toEpochMilli(),
updatedAt = articleWithBoard.updatedAt.toEpochMilli(),
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.wafflestudio.team2server.article.dto

import io.swagger.v3.oas.annotations.media.Schema

@Schema(description = "게시글 목록 커서 페이지네이션 요청")
data class ArticlePagingRequest(
@Schema(
description = "다음 페이지 커서( 게시글 생성 시간)",
required = false,
)
val nextPublishedAt: Long? = null,
@Schema(
description = "다음 페이지 커서(게시글 ID)",
required = false,
)
val nextId: Long? = null,
@Schema(
description = "페이지당 게시글 수",
example = "30",
required = false,
)
val limit: Int = 30,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.wafflestudio.team2server.article.dto.request

import io.swagger.v3.oas.annotations.media.Schema
import java.time.Instant

@Schema(description = "게시글 생성 요청")
data class CreateArticleRequest(
@Schema(
description = "게시글 제목",
example = "교내 장학금 신청 안내",
required = true,
)
val title: String,
@Schema(
description = "게시글 본문 내용",
example = "mysnu에서 신청하세요...",
required = true,
)
val content: String,
@Schema(
description = "기사 작성자",
example = "행정부",
required = true,
)
val author: String,
@Schema(
description = "원문 기사 링크",
example = "https://www.mysnu/...",
required = true,
)
val originLink: String,
@Schema(
description = "기사 실제 게시 시각",
example = "2026-01-01T12:00:00Z",
required = true,
)
val publishedAt: Instant,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.wafflestudio.team2server.article.dto

import io.swagger.v3.oas.annotations.media.Schema
import java.time.Instant

@Schema(description = "게시글 수정 요청")
data class UpdateArticleRequest(
@Schema(description = "게시글 제목", example = "수정된 제목")
val title: String? = null,
@Schema(description = "게시글 내용", example = "수정된 내용")
val content: String? = null,
@Schema(description = "게시글 작성자", example = "다른 작성자")
val author: String? = null,
@Schema(description = "원문 게시글 링크", example = "www.youtube.com")
val originLink: String? = null,
@Schema(description = "게시글 작성 시각")
val publishedAt: Instant? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.wafflestudio.team2server.article.dto.response

import com.wafflestudio.team2server.article.dto.ArticlePaging
import com.wafflestudio.team2server.article.dto.core.ArticleDto

data class ArticlePagingResponse(
val data: List<ArticleDto>,
val paging: ArticlePaging,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.wafflestudio.team2server.article.dto.response

import com.wafflestudio.team2server.article.dto.core.ArticleDto

typealias CreateArticleResponse = ArticleDto
Loading