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
@@ -1,5 +1,6 @@
package app.bottlenote.alcohols.dto.response

import app.bottlenote.alcohols.dto.request.AdminAlcoholBulkRowRequest
import io.swagger.v3.oas.annotations.media.Schema

@Schema(name = "AdminAlcoholExcelValidateResponse", description = "알코올 엑셀 업로드 검증 결과")
Expand All @@ -20,6 +21,8 @@ data class AdminAlcoholExcelValidateResponse(
data class AdminAlcoholExcelRowResult(
@Schema(description = "엑셀 행 번호(1-based, 헤더=1, 설명=2, 데이터 시작=3)")
val rowNumber: Int,
@Schema(description = "공통 벌크 요청에 전달할 엑셀 행 식별자")
val clientRowId: String,
val korName: String?,
val engName: String?,
val abv: String?,
Expand All @@ -44,7 +47,9 @@ data class AdminAlcoholExcelRowResult(
val candidateAlcoholIds: List<Long>? = null,
val valid: Boolean,
val errors: List<AdminAlcoholExcelIssue>,
val warnings: List<AdminAlcoholExcelIssue>
val warnings: List<AdminAlcoholExcelIssue>,
@Schema(description = "오류가 없을 때만 반환하는 공통 벌크 정규화 행")
val normalized: AdminAlcoholBulkRowRequest? = null
)

@Schema(name = "AdminAlcoholExcelIssue", description = "행 단위 오류 또는 경고")
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
package app.bottlenote.alcohols.excel

/**
* Admin 알코올 XLSX 템플릿의 고정 스키마.
* Admin 알코올 XLSX 템플릿의 고정 입력 열 스키마.
* 사용자에게 노출되는 헤더는 한글 필드명/설명만 사용한다.
*
* 시트 순서:
* 템플릿 기본 시트 구성:
* 1. 사용 안내 (설명/예제/오류 코드 첫 페이지)
* 2. 지역
* 3. 증류소
* 4. 테이스팅 태그
* 5. 카테고리
* 6. 알코올 데이터 (실제 입력 시트, 마지막 고정)
* 6. 알코올 데이터 (실제 입력 시트)
*
* 검증할 때는 알코올 데이터 시트, 첫 행의 정확한 13개 헤더와 두 번째 설명 행이 필수다.
* 안내·참조 시트의 순서와 추가 메모 시트는 허용한다.
*
* 매핑 규칙:
* - 주류 종류, 카테고리 그룹: 한글 enum 표시값
* - 지역/증류소/테이스팅 태그/카테고리: 참조 시트의 ID
* - 도수/용량: 숫자만 입력(소수 2자리), 서버가 % / ml 표기를 붙임
* - 주류 종류, 카테고리 그룹: 한글 표시값 또는 enum 이름
* - 지역/증류소/테이스팅 태그: 존재하는 ID, 카테고리: 그룹|한글|영문 안정 키
* - 도수: % 표기 또는 숫자, 용량: ml·cl·L 표기 또는 숫자
*/
object AlcoholExcelSchema {
const val GUIDE_SHEET_NAME = "사용 안내"
Expand Down Expand Up @@ -66,16 +69,16 @@ object AlcoholExcelSchema {
listOf(
"제품의 한글 이름",
"제품의 영문 이름",
"숫자만 입력 (예: 40 또는 40.50). 서버가 %를 붙입니다. 소수 2자리까지",
"주류 종류 한글 표시값 (예: 위스키)",
"숫자 또는 % 표기 (예: 40, 40%, 40.50%). 퍼센트 서식 셀도 허용합니다",
"주류 종류 한글 표시값 또는 enum 이름 (예: 위스키, WHISKY)",
"카테고리 시트의 ID(그룹|한글|영문 안정 키)를 입력합니다",
"카테고리 그룹 한글 표시값 (예: 싱글몰트 위스키). 카테고리 ID와 함께 일치해야 합니다",
"선택: 카테고리 ID의 그룹을 자동 사용합니다. 입력 시 한글 표시값 또는 enum 이름을 허용합니다",
"지역 시트의 ID를 입력합니다",
"증류소 시트의 ID를 입력합니다",
"숙성 연도 또는 표기값",
"캐스크 타입",
"제품 설명",
"숫자만 입력 (예: 700 또는 700.00). 서버가 ml를 붙입니다. 소수 2자리까지",
"선택: 숙성 연도 또는 표기값",
"선택: 캐스크 타입",
"선택: 제품 설명",
"숫자 또는 단위 표기 (예: 700ml, 70cl, 0.7L)",
"테이스팅 태그 시트의 ID. 여러 개는 | 로 구분 (예: 1|3)"
)

Expand Down Expand Up @@ -106,31 +109,30 @@ object AlcoholExcelSchema {
val ERROR_CATALOG =
listOf(
ErrorCatalogItem("REQUIRED_FIELD", "{{필드}} 필드가 누락되었거나 비어 있습니다."),
ErrorCatalogItem("INVALID_NUMBER", "{{필드}} 필드가 잘못 입력되었습니다. 숫자만 입력하고 소수 2자리까지 허용됩니다."),
ErrorCatalogItem("INVALID_ENUM_VALUE", "{{필드}} 필드가 잘못 입력되었습니다. 허용된 한글 값을 입력하세요."),
ErrorCatalogItem("INVALID_ID", "{{필드}} 필드가 잘못 입력되었습니다. 참조 시트의 ID 숫자를 입력하세요."),
ErrorCatalogItem("INVALID_NUMBER", "{{필드}} 필드의 숫자·단위 또는 범위가 올바르지 않습니다."),
ErrorCatalogItem("INVALID_ENUM_VALUE", "{{필드}} 필드가 잘못 입력되었습니다. 한글 표시값 또는 enum 이름을 입력하세요."),
ErrorCatalogItem("INVALID_ID", "{{필드}} 필드는 Long 범위의 참조 ID여야 합니다."),
ErrorCatalogItem("REGION_NOT_FOUND", "지역 ID를 찾을 수 없습니다: {{정보}}"),
ErrorCatalogItem("DISTILLERY_NOT_FOUND", "증류소 ID를 찾을 수 없습니다: {{정보}}"),
ErrorCatalogItem("CATEGORY_NOT_FOUND", "카테고리 ID를 찾을 수 없습니다: {{정보}}"),
ErrorCatalogItem("UNKNOWN_CATEGORY", "기존 참조에 없는 카테고리 조합을 보존합니다."),
ErrorCatalogItem(
"CATEGORY_GROUP_MISMATCH",
"카테고리 ID와 카테고리 그룹이 일치하지 않습니다: {{정보}}"
),
ErrorCatalogItem("TASTING_TAG_NOT_FOUND", "테이스팅 태그 ID를 찾을 수 없습니다: {{정보}}"),
ErrorCatalogItem("DUPLICATE_TASTING_TAG", "중복된 테이스팅 태그 ID입니다: {{정보}}"),
ErrorCatalogItem(
"DUPLICATE_IN_FILE",
"파일 내부에 동일한 식별 조합(이름·증류소·도수·용량)이 중복됩니다: {{정보}}"
),
ErrorCatalogItem("DUPLICATE_TASTING_TAG", "중복된 테이스팅 태그 ID를 제거했습니다: {{정보}}"),
ErrorCatalogItem("DUPLICATE_IN_FILE", "파일 내부 중복 후보입니다. 저장은 가능하지만 확인이 필요합니다: {{정보}}"),
ErrorCatalogItem(
"DUPLICATE_CANDIDATE",
"이미 등록된 위스키입니다 {{정보}}"
"이미 등록된 알코올 후보입니다 {{정보}}"
),
ErrorCatalogItem("NON_SCALAR_VALUE", "범위·배치·세트 표현을 원문으로 보존합니다."),
ErrorCatalogItem("TYPE_GROUP_MISMATCH", "주류 타입과 카테고리 그룹의 의미를 확인해 주세요."),
ErrorCatalogItem("EXCEL_INVALID_FILE_TYPE", "OOXML .xlsx 파일만 업로드할 수 있습니다."),
ErrorCatalogItem("EXCEL_FILE_TOO_LARGE", "엑셀 파일 크기는 5MiB를 초과할 수 없습니다."),
ErrorCatalogItem("EXCEL_SHEET_NOT_FOUND", "필수 시트가 없거나 시트명이 올바르지 않습니다."),
ErrorCatalogItem("EXCEL_HEADER_MISMATCH", "엑셀 헤더(1행)가 고정 템플릿과 일치하지 않습니다."),
ErrorCatalogItem("EXCEL_DESCRIPTION_MISMATCH", "엑셀 설명(2행)이 고정 템플릿과 일치하지 않습니다."),
ErrorCatalogItem("EXCEL_DESCRIPTION_MISMATCH", "엑셀 설명(2행)을 확인할 수 없습니다. 템플릿을 다시 내려받아 데이터는 3행부터 입력해 주세요."),
ErrorCatalogItem("EXCEL_DUPLICATE_HEADER", "엑셀 헤더에 중복된 필드명이 있습니다."),
ErrorCatalogItem("EXCEL_FORMULA_NOT_ALLOWED", "수식 셀은 허용되지 않습니다."),
ErrorCatalogItem("EXCEL_EXTERNAL_LINK_NOT_ALLOWED", "외부 링크는 허용되지 않습니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package app.bottlenote.alcohols.presentation

import app.bottlenote.alcohols.dto.request.AdminAlcoholBulkRequest
import app.bottlenote.alcohols.presentation.docs.AdminAlcoholBulkApiDocs
import app.bottlenote.alcohols.service.AdminAlcoholBulkService
import app.bottlenote.global.annotation.SecurityPolicy
import app.bottlenote.global.data.response.GlobalResponse
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
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.RestController

@RestController
@RequestMapping("/alcohols/bulk")
@SecurityPolicy
@AdminAlcoholBulkApiDocs.ApiTag
class AdminAlcoholBulkController(
private val adminAlcoholBulkService: AdminAlcoholBulkService
) {
@PostMapping("/validate")
@AdminAlcoholBulkApiDocs.ValidateBulk
fun validate(
@RequestBody @Valid request: AdminAlcoholBulkRequest
): ResponseEntity<GlobalResponse> = GlobalResponse.ok(adminAlcoholBulkService.validate(request))

@PostMapping
@AdminAlcoholBulkApiDocs.CreateBulk
fun create(
@RequestBody @Valid request: AdminAlcoholBulkRequest
): ResponseEntity<GlobalResponse> {
val result = adminAlcoholBulkService.create(request)
return if (result.validation().invalidRows() > 0) {
ResponseEntity.badRequest().body(GlobalResponse.fail(result.validation()))
} else {
GlobalResponse.ok(result)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package app.bottlenote.alcohols.presentation

import app.bottlenote.alcohols.exception.AlcoholException
import app.bottlenote.alcohols.exception.AlcoholExceptionCode
import app.bottlenote.global.data.response.GlobalResponse
import org.springframework.core.Ordered
import org.springframework.core.annotation.Order
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.multipart.MaxUploadSizeExceededException
import org.springframework.web.multipart.MultipartException
import org.springframework.web.multipart.support.MissingServletRequestPartException

@RestControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE)
class AdminAlcoholExcelExceptionHandler {
@ExceptionHandler(MaxUploadSizeExceededException::class)
fun handleSizeLimit(): ResponseEntity<GlobalResponse> = GlobalResponse.error(AlcoholException(AlcoholExceptionCode.EXCEL_FILE_TOO_LARGE))

@ExceptionHandler(MultipartException::class, MissingServletRequestPartException::class)
fun handleInvalidFile(): ResponseEntity<GlobalResponse> = GlobalResponse.error(AlcoholException(AlcoholExceptionCode.EXCEL_INVALID_FILE_TYPE))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package app.bottlenote.alcohols.presentation.docs

import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkCreateResponse
import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkValidateResponse
import app.bottlenote.global.data.response.Error
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.tags.Tag

object AdminAlcoholBulkApiDocs {
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Tag(name = "알코올 벌크", description = "엑셀과 JSON의 공통 검증 및 일괄 등록")
annotation class ApiTag

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
@Operation(
summary = "알코올 JSON 목록을 검증한다",
description = "최대 1,000행을 저장 없이 검증한다. clientRowId는 요청 안에서 유일해야 한다. 오류 없는 행의 normalized는 벌크 저장 입력으로 재사용할 수 있다. 중복 후보와 데이터 불일치는 경고이며 자동 병합하지 않는다. 필수 항목은 clientRowId, korName, engName, abv, type, korCategory, engCategory, regionId, distilleryId, volume이다. type은 WHISKY/RUM/VODKA/GIN/TEQUILA/BRANDY/BEER/WINE/ETC 또는 한글 표시값이다. categoryGroup은 SINGLE_MALT/BLEND/BLENDED_MALT/BOURBON/RYE/OTHER 또는 한글 표시값이며, 생략 시 카테고리로 유일하게 추론하거나 비위스키에 OTHER를 사용한다. age/cask/description/tastingTagIds/imageUrl은 선택이다."
)
@ApiResponse(responseCode = "200", description = "행별 오류·경고·정규화 결과", content = [Content(mediaType = "application/json", schema = Schema(implementation = AdminAlcoholBulkValidateResponse::class))])
@ApiResponse(responseCode = "400", description = "잘못된 JSON, 빈 목록 또는 최대 행 수 초과", content = [Content(mediaType = "application/json", schema = Schema(implementation = RequestFailureEnvelope::class))])
@ApiResponse(responseCode = "401", description = "관리자 인증 토큰이 유효하지 않은 경우")
@ApiResponse(responseCode = "403", description = "관리자 인증 없이 보호된 API에 접근한 경우")
annotation class ValidateBulk

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
@Operation(
summary = "알코올 목록을 일괄 등록한다",
description = "엑셀 검증 결과 또는 직접 작성한 JSON rows를 다시 검증한 뒤 하나의 트랜잭션으로 등록한다. 오류가 있으면 전혀 저장하지 않고 400 errors에 검증 결과를 반환한다. 경고만 있으면 모두 등록하며 중복 병합은 하지 않는다. 반복 POST는 별도 등록 요청이므로 자동 재시도하지 않는다. 이미지는 선택이며 기존 업로드의 viewUrl을 사용한다."
)
@ApiResponse(responseCode = "200", description = "등록 건수와 clientRowId별 생성 ID", content = [Content(mediaType = "application/json", schema = Schema(implementation = AdminAlcoholBulkCreateResponse::class))])
@ApiResponse(responseCode = "400", description = "행 검증 실패 또는 잘못된 요청 목록", content = [Content(mediaType = "application/json", schema = Schema(oneOf = [ValidationFailureEnvelope::class, RequestFailureEnvelope::class]))])
@ApiResponse(responseCode = "401", description = "관리자 인증 토큰이 유효하지 않은 경우")
@ApiResponse(responseCode = "403", description = "관리자 인증 없이 보호된 API에 접근한 경우")
annotation class CreateBulk

@Schema(name = "AlcoholBulkValidationFailureEnvelope")
data class ValidationFailureEnvelope(
val success: Boolean,
val code: Int,
val data: List<Any> = emptyList(),
val errors: AdminAlcoholBulkValidateResponse,
val meta: Map<String, Any?> = emptyMap()
)

@Schema(name = "AlcoholBulkRequestFailureEnvelope")
data class RequestFailureEnvelope(
val success: Boolean,
val code: Int,
val data: List<Any> = emptyList(),
val errors: List<Error>,
val meta: Map<String, Any?> = emptyMap()
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ object AdminAlcoholsApiDocs {
@ApiResponse(
responseCode = "200",
description = "검증 결과",
content = [Content(schema = Schema(implementation = AlcoholExcelValidateEnvelope::class))]
content = [Content(schema = Schema(implementation = AdminAlcoholExcelValidateResponse::class))]
)
annotation class ValidateAlcoholExcel

Expand Down Expand Up @@ -164,15 +164,6 @@ object AdminAlcoholsApiDocs {
val meta: Map<String, Any?> = emptyMap()
)

@Schema(name = "AlcoholExcelValidateEnvelope")
data class AlcoholExcelValidateEnvelope(
val success: Boolean,
val code: Int,
val data: AdminAlcoholExcelValidateResponse,
val errors: List<Any> = emptyList(),
val meta: Map<String, Any?> = emptyMap()
)

@Schema(name = "CategoryReferenceMap")
data class CategoryReferenceMap(
val SINGLE_MALT: List<CategoryPairItem>,
Expand Down
4 changes: 4 additions & 0 deletions bottlenote-admin-api/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ server:
connection-timeout: 5000

spring:
servlet:
multipart:
max-file-size: 5MB
max-request-size: 6MB
profiles:
include:
- datasource
Expand Down
Loading
Loading