-
Notifications
You must be signed in to change notification settings - Fork 0
✨ Feature: 파일 업로드 기능 추가 #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4b863c3
4d6cffc
c712b21
9ab10be
4fb07df
3cc591c
e2c2405
c58b677
9f09266
fab22d9
40fc893
63a1063
28f0acd
3c34883
d74cbf9
39d3b6f
51f580a
b725fb4
6ef1473
db60ddd
6ff7de9
eaead1c
0b66cde
59b5579
fb305fb
6a564ab
3b5948f
094cecd
475236a
b0fd4a8
f5c04e3
913a2b1
fe87736
72de563
2e3978e
62f99de
729826a
392fa4a
8594135
b8ab8db
61cdb85
de5b382
6f38323
9f069b2
484fdf6
1c992c4
f4ee29b
d2d7847
efe93b1
57bc8ab
c2c0508
c90c81d
639ffdb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| package simplerag.ragback.domain.document.controller | ||
|
|
||
| import io.swagger.v3.oas.annotations.Parameter | ||
| import io.swagger.v3.oas.annotations.media.Content | ||
| import jakarta.validation.Valid | ||
| import jakarta.validation.constraints.Size | ||
| import org.springframework.http.HttpStatus | ||
| import org.springframework.http.MediaType | ||
| import org.springframework.validation.annotation.Validated | ||
| import org.springframework.web.bind.annotation.* | ||
| import org.springframework.web.multipart.MultipartFile | ||
| import simplerag.ragback.domain.document.dto.DataFileBulkCreateRequest | ||
| import simplerag.ragback.domain.document.dto.DataFileResponseList | ||
| import simplerag.ragback.domain.document.service.DataFileService | ||
| import simplerag.ragback.global.response.ApiResponse | ||
|
|
||
| @RestController | ||
| @RequestMapping("/api/v1/data-files") | ||
| @Validated | ||
| class DataFileController( | ||
| private val dataFileService: DataFileService | ||
| ) { | ||
|
|
||
| @PostMapping( | ||
| consumes = [ | ||
| MediaType.MULTIPART_FORM_DATA_VALUE, | ||
| ] | ||
| ) | ||
| @ResponseStatus(HttpStatus.CREATED) | ||
| fun upload( | ||
| @RequestPart("files") | ||
| @Size(min = 1, message = "최소 하나 이상 업로드해야 합니다") | ||
| files: List<MultipartFile>, | ||
|
|
||
| @Parameter(content = [Content(mediaType = MediaType.APPLICATION_JSON_VALUE)]) | ||
| @RequestPart("request") | ||
| @Valid | ||
| req: DataFileBulkCreateRequest | ||
| ): ApiResponse<DataFileResponseList> { | ||
| val saved = dataFileService.upload(files, req) | ||
| return ApiResponse.ok(saved, "업로드 완료") | ||
| } | ||
|
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| package simplerag.ragback.domain.document.dto | ||
|
|
||
| import jakarta.validation.Valid | ||
| import jakarta.validation.constraints.NotBlank | ||
| import jakarta.validation.constraints.Size | ||
|
|
||
| data class DataFileBulkCreateRequest( | ||
| @field:Size(min = 1, message = "최소 하나 이상 업로드해야 합니다") | ||
| @Valid | ||
| val items: List<DataFileCreateItem> | ||
| ) | ||
|
|
||
| data class DataFileCreateItem( | ||
| @field:NotBlank(message = "title은 비어있을 수 없습니다") | ||
| @field:Size(max = 100) | ||
| val title: String, | ||
|
|
||
| @field:Size(max = 10, message = "태그는 최대 10개까지 가능합니다") | ||
| val tags: List<String> = emptyList() | ||
catturtle123 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ) | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,10 @@ | ||||||||||||||
| package simplerag.ragback.domain.document.dto | ||||||||||||||
|
|
||||||||||||||
| data class DataFileResponseList( | ||||||||||||||
| val dataFilePreviewResponseList: List<DataFilePreviewResponse>, | ||||||||||||||
| ) | ||||||||||||||
|
Comment on lines
+3
to
+5
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) 응답 목록 필드명 간결화 제안: items로 축약 현재 -data class DataFileResponseList(
- val dataFilePreviewResponseList: List<DataFilePreviewResponse>,
-)
+data class DataFileResponseList(
+ val items: List<DataFilePreviewResponse>,
+)호출부 및 테스트의 필드 접근도 함께 정리해야 합니다. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
|
|
||||||||||||||
| data class DataFilePreviewResponse( | ||||||||||||||
| val id: Long, | ||||||||||||||
| val sha256: String, | ||||||||||||||
| ) | ||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| package simplerag.ragback.domain.document.entity | ||
|
|
||
| import jakarta.persistence.* | ||
| import java.time.LocalDateTime | ||
|
|
||
| @Entity | ||
| @Table( | ||
| name = "data_file", | ||
| uniqueConstraints = [UniqueConstraint(columnNames = ["sha256"])] | ||
| ) | ||
catturtle123 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| class DataFile( | ||
catturtle123 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| @Column(nullable = false, length = 255) | ||
| val title: String, | ||
|
|
||
| @Column(name = "file_type", nullable = false, length = 120) | ||
| val type: String, | ||
|
|
||
| @Column(name = "size_bytes", nullable = false) | ||
| val sizeBytes: Long, | ||
|
|
||
| @Column(nullable = false, length = 64) | ||
| val sha256: String, | ||
|
|
||
| @Column(nullable = false, length = 2048) | ||
| val fileUrl: String, | ||
|
|
||
| @Column(nullable = false) | ||
| val updatedAt: LocalDateTime, | ||
|
|
||
| @Column(nullable = false) | ||
| val createdAt: LocalDateTime, | ||
|
|
||
catturtle123 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| @Id @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| val id: Long? = null, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package simplerag.ragback.domain.document.entity | ||
|
|
||
| import jakarta.persistence.* | ||
|
|
||
| @Entity | ||
| @Table( | ||
| name = "data_file_tags", | ||
| uniqueConstraints = [UniqueConstraint(columnNames = ["data_file_id", "tag_id"])] | ||
| ) | ||
catturtle123 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| class DataFileTag( | ||
|
|
||
| @ManyToOne(fetch = FetchType.LAZY) | ||
| @JoinColumn(name = "tag_id", nullable = false) | ||
| var tag: Tag, | ||
|
|
||
| @ManyToOne(fetch = FetchType.LAZY) | ||
| @JoinColumn(name = "data_file_id", nullable = false) | ||
| var dataFile: DataFile, | ||
|
|
||
| @Id @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| val id: Long? = null, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| package simplerag.ragback.domain.document.entity | ||
|
|
||
| import jakarta.persistence.* | ||
|
|
||
| @Entity | ||
| @Table( | ||
| name = "tags", | ||
| uniqueConstraints = [UniqueConstraint(columnNames = ["name"])] | ||
| ) | ||
| class Tag( | ||
|
|
||
| @Column(nullable = false, length = 60) | ||
| val name: String, | ||
|
|
||
| @Id @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| val id: Long? = null, | ||
| ) |
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,8 @@ | ||||||||||||||||
| package simplerag.ragback.domain.document.repository | ||||||||||||||||
|
|
||||||||||||||||
| import org.springframework.data.jpa.repository.JpaRepository | ||||||||||||||||
| import simplerag.ragback.domain.document.entity.DataFile | ||||||||||||||||
|
|
||||||||||||||||
| interface DataFileRepository : JpaRepository<DataFile, Long> { | ||||||||||||||||
| fun existsBySha256(sha256: String): Boolean | ||||||||||||||||
| } | ||||||||||||||||
|
Comment on lines
+6
to
+8
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) 대량 업로드 시 N+1 exists 호출 최소화를 위한 배치 조회 메서드 제안 여러 파일을 한 번에 업로드한다면 interface DataFileRepository : JpaRepository<DataFile, Long> {
fun existsBySha256(sha256: String): Boolean
+ fun findAllBySha256In(hashes: Collection<String>): List<DataFile>
}서비스에서는 요청된 해시 집합을 기준으로 DB에 이미 존재하는 해시를 미리 회수해 중복을 제외한 뒤 저장하세요. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents💡 Verification agent 🧩 Analysis chainexists-then-insert 경쟁 상태 가능 — 유니크 제약 위반 예외를 서비스 레이어에서 매핑하세요 sha256 중복 방지를 원하시면 🏁 Script executed: #!/bin/bash
set -e
echo "Searching for DataFile entity..."
rg -nP "class\s+DataFile\b" -g "*.kt"
echo "Searching for `sha256` field in the entity (with or without unique constraint)..."
rg -nP "sha256\s*[:=]" -g "*.kt" -C2
echo "Searching for `DataFileService` class..."
rg -nP "class\s+DataFileService\b" -g "*.kt"
echo "Searching for exception handling of DataIntegrityViolationException in service layer..."
rg -nP "DataIntegrityViolationException" -g "*.kt" || echo "No DataIntegrityViolationException catch blocks found"Length of output: 3691 🏁 Script executed: #!/bin/bash
set -e
echo "Checking for @Table annotation on DataFile entity..."
rg -n "@Table" -g "src/main/kotlin/simplerag/ragback/domain/document/entity/DataFile.kt" -C2 || echo "No @Table annotation found"
echo "Checking for unique constraint on sha256 field..."
rg -n "unique" -g "src/main/kotlin/simplerag/ragback/domain/document/entity/DataFile.kt" -C2 || echo "No unique constraint found"Length of output: 1360 DataIntegrityViolationException 캐치 및 ALREADY_FILE 매핑 필수 현재 수정 대상:
제안 코드 예시: try {
val saved = dataFileRepository.save(dataFile)
// 필요에 따라 saved 사용
} catch (e: DataIntegrityViolationException) {
// 이미 저장된 파일인 경우를 의미하는 비즈니스 예외로 매핑
throw CustomException(ErrorCode.ALREADY_FILE, "이미 등록된 파일입니다.", e)
}
🤖 Prompt for AI Agents |
||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| package simplerag.ragback.domain.document.repository | ||
|
|
||
| import org.springframework.data.jpa.repository.JpaRepository | ||
| import simplerag.ragback.domain.document.entity.DataFileTag | ||
|
|
||
| interface DataFileTagRepository : JpaRepository<DataFileTag, Long> { | ||
| fun existsByDataFileIdAndTagId(dataFileId: Long, tagId: Long): Boolean | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package simplerag.ragback.domain.document.repository | ||
|
|
||
| import org.springframework.data.jpa.repository.JpaRepository | ||
| import simplerag.ragback.domain.document.entity.Tag | ||
|
|
||
| interface TagRepository : JpaRepository<Tag, Long> { | ||
|
|
||
| fun findByName(name: String): Tag? | ||
|
|
||
| fun findByNameIn(names: Collection<String>): List<Tag> | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| package simplerag.ragback.domain.document.service | ||
|
|
||
| import org.springframework.dao.DataIntegrityViolationException | ||
| import org.springframework.stereotype.Service | ||
| import org.springframework.transaction.annotation.Transactional | ||
| import org.springframework.transaction.support.TransactionSynchronization | ||
| import org.springframework.transaction.support.TransactionSynchronizationManager | ||
| import org.springframework.web.multipart.MultipartFile | ||
| import simplerag.ragback.domain.document.dto.DataFileBulkCreateRequest | ||
| import simplerag.ragback.domain.document.dto.DataFilePreviewResponse | ||
| import simplerag.ragback.domain.document.dto.DataFileResponseList | ||
| import simplerag.ragback.domain.document.entity.DataFile | ||
| import simplerag.ragback.domain.document.entity.DataFileTag | ||
| import simplerag.ragback.domain.document.entity.Tag | ||
| import simplerag.ragback.domain.document.repository.DataFileRepository | ||
| import simplerag.ragback.domain.document.repository.DataFileTagRepository | ||
| import simplerag.ragback.domain.document.repository.TagRepository | ||
| import simplerag.ragback.global.error.CustomException | ||
| import simplerag.ragback.global.error.ErrorCode | ||
| import simplerag.ragback.global.error.FileException | ||
| import simplerag.ragback.global.util.S3Type | ||
| import simplerag.ragback.global.util.S3Util | ||
| import simplerag.ragback.global.util.computeMetricsStreaming | ||
| import simplerag.ragback.global.util.resolveContentType | ||
| import java.time.LocalDateTime | ||
| import java.util.* | ||
|
|
||
| @Service | ||
| class DataFileService( | ||
| private val dataFileRepository: DataFileRepository, | ||
| private val tagRepository: TagRepository, | ||
| private val dataFileTagRepository: DataFileTagRepository, | ||
| private val s3Util: S3Util, | ||
| ) { | ||
|
|
||
| @Transactional | ||
| fun upload( | ||
| files: List<MultipartFile>, | ||
| req: DataFileBulkCreateRequest | ||
| ): DataFileResponseList { | ||
| if (files.isEmpty() || files.size != req.items.size) { | ||
| throw CustomException(ErrorCode.INVALID_INPUT) | ||
| } | ||
|
|
||
| val now = LocalDateTime.now() | ||
| val uploadedUrls = mutableListOf<String>() | ||
|
|
||
| registerRollbackCleanup(uploadedUrls) | ||
|
|
||
| val responses = files.mapIndexed { idx, file -> | ||
| val meta = req.items[idx] | ||
|
|
||
| val metrics = file.computeMetricsStreaming() | ||
| val sha256 = metrics.sha256 | ||
| val sizeBytes = metrics.sizeByte | ||
| val type = file.resolveContentType() | ||
|
|
||
| if (dataFileRepository.existsBySha256(sha256)) { | ||
| throw FileException(ErrorCode.ALREADY_FILE, sha256) | ||
| } | ||
|
|
||
| val fileUrl = s3Util.upload(file, S3Type.ORIGINAL_FILE) | ||
| uploadedUrls += fileUrl | ||
|
|
||
| val dataFile = try { | ||
| dataFileRepository.save(DataFile(meta.title, type, sizeBytes, sha256, fileUrl, now, now)) | ||
| } catch (ex: DataIntegrityViolationException) { | ||
| throw FileException(ErrorCode.ALREADY_FILE, sha256) | ||
| } | ||
|
|
||
| val tags = getOrCreateTags(meta.tags) | ||
| attachTagsIfMissing(dataFile, tags) | ||
|
|
||
| DataFilePreviewResponse(requireNotNull(dataFile.id), dataFile.sha256) | ||
| } | ||
|
|
||
| return DataFileResponseList(responses) | ||
| } | ||
|
|
||
| private fun registerRollbackCleanup(uploadedUrls: MutableList<String>) { | ||
| if (TransactionSynchronizationManager.isSynchronizationActive()) { | ||
| TransactionSynchronizationManager.registerSynchronization(object : TransactionSynchronization { | ||
| override fun afterCompletion(status: Int) { | ||
| if (status == TransactionSynchronization.STATUS_ROLLED_BACK) { | ||
| uploadedUrls.forEach { runCatching { s3Util.deleteByUrl(it) } } | ||
| } | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
Comment on lines
+80
to
+90
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) 롤백 보상삭제 시 예외 삼킴 — 최소한 경고 로그 남기기 보상 삭제에서 예외를 무시하면(삼키면) S3에 고아 오브젝트가 남아도 추적이 어렵습니다. 경고 로그를 추가해 가시성을 높이세요. - uploadedUrls.forEach { runCatching { s3Util.deleteByUrl(it) } }
+ uploadedUrls.forEach { url ->
+ runCatching { s3Util.deleteByUrl(url) }
+ .onFailure { e ->
+ // TODO: 클래스에 logger 추가 후 아래 라인 사용
+ // log.warn("Rollback cleanup failed for S3 object: {}", url, e)
+ }
+ }필요 시 클래스 상단에 로거를 추가하세요: private val log = org.slf4j.LoggerFactory.getLogger(DataFileService::class.java)🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| private fun getOrCreateTags(names: List<String>): List<Tag> { | ||
| val normalized = names | ||
| .map { it.trim().uppercase(Locale.ROOT) } | ||
| .filter { it.isNotEmpty() } | ||
| .distinct() | ||
|
|
||
| if (normalized.isEmpty()) return emptyList() | ||
|
|
||
| val existing = tagRepository.findByNameIn(normalized) | ||
| val existingByName = existing.associateBy { it.name } | ||
|
|
||
| val toCreate = normalized | ||
| .asSequence() | ||
| .filter { it !in existingByName } | ||
| .map { Tag(name = it) } | ||
| .toList() | ||
|
|
||
| val created = if (toCreate.isNotEmpty()) { | ||
| try { | ||
| tagRepository.saveAllAndFlush(toCreate) | ||
| } catch (ex: DataIntegrityViolationException) { | ||
| tagRepository.findByNameIn(toCreate.map { it.name }) | ||
| } | ||
| } else emptyList() | ||
|
|
||
| return existing + created | ||
| } | ||
|
|
||
|
|
||
| private fun attachTagsIfMissing(dataFile: DataFile, tags: List<Tag>) { | ||
| val fileId = dataFile.id ?: return | ||
| tags.forEach { tag -> | ||
| val tagId = tag.id ?: return@forEach | ||
| val exists = dataFileTagRepository.existsByDataFileIdAndTagId(fileId, tagId) | ||
| if (!exists) { | ||
| dataFileTagRepository.save(DataFileTag(tag = tag, dataFile = dataFile)) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick (assertive)
(선택) Location 헤더 설정 고려
다건 생성이지만 컬렉션 조회 엔드포인트를 Location으로 제공하면 REST 완결성이 올라갑니다. 현재는 @ResponseStatus로 201만 반환하므로, 원하시면 ResponseEntity.created(...)로 Location 헤더를 추가하는 형태로 바꾸는 스니펫을 드릴 수 있습니다.
🤖 Prompt for AI Agents