From 43248fcb3cc9c0e964cfcb8f81a71fe378cfb5b1 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Sat, 5 Sep 2026 12:34:27 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=EC=95=8C=EC=BD=94=EC=98=AC=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=20=EB=B2=8C=ED=81=AC=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=EA=B3=BC=20=EC=A0=80=EC=9E=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/AlcoholQueryRepository.java | 3 + .../dto/request/AdminAlcoholBulkRequest.java | 15 + .../request/AdminAlcoholBulkRowRequest.java | 22 + .../request/AdminAlcoholUpsertRequest.java | 6 +- .../request/AlcoholBulkIdDeserializer.java | 25 + .../AdminAlcoholBulkCreateResponse.java | 8 + .../dto/response/AdminAlcoholBulkIssue.java | 3 + .../response/AdminAlcoholBulkRowResult.java | 12 + .../AdminAlcoholBulkValidateResponse.java | 10 + .../response/AlcoholBulkReferenceItem.java | 16 + .../exception/AlcoholExceptionCode.java | 2 + .../repository/JpaAlcoholQueryRepository.java | 10 + .../service/AdminAlcoholBulkService.java | 11 + .../service/AlcoholBulkInputNormalizer.java | 148 ++++++ .../DefaultAdminAlcoholBulkService.java | 443 ++++++++++++++++ .../custom/code/ValidExceptionCode.java | 3 + .../AlcoholBulkInputNormalizerTest.java | 95 ++++ .../DefaultAdminAlcoholBulkServiceTest.java | 502 ++++++++++++++++++ .../InMemoryAlcoholQueryRepository.java | 23 + ...4\355\201\254 \353\223\261\353\241\235.md" | 116 ++++ 20 files changed, 1470 insertions(+), 3 deletions(-) create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholBulkRequest.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholBulkRowRequest.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AlcoholBulkIdDeserializer.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkCreateResponse.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkIssue.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkRowResult.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkValidateResponse.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AlcoholBulkReferenceItem.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AdminAlcoholBulkService.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholBulkInputNormalizer.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholBulkService.java create mode 100644 bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/AlcoholBulkInputNormalizerTest.java create mode 100644 bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholBulkServiceTest.java create mode 100644 "plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java index eb1692a8b..063a42b59 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java @@ -6,6 +6,7 @@ import app.bottlenote.alcohols.dto.dsl.ExploreStandardCriteria; import app.bottlenote.alcohols.dto.request.AdminAlcoholSearchRequest; import app.bottlenote.alcohols.dto.response.AdminAlcoholItem; +import app.bottlenote.alcohols.dto.response.AlcoholBulkReferenceItem; import app.bottlenote.alcohols.dto.response.AlcoholDetailItem; import app.bottlenote.alcohols.dto.response.AlcoholLookupItem; import app.bottlenote.alcohols.dto.response.CategoryItem; @@ -37,6 +38,8 @@ public interface AlcoholQueryRepository { List findAllLookupItems(); + List findAllBulkReferenceItems(); + List findAllMatchTargets(); List findMatchTargetsByDistilleryIdIn(List distilleryIds); diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholBulkRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholBulkRequest.java new file mode 100644 index 000000000..3aa123b1b --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholBulkRequest.java @@ -0,0 +1,15 @@ +package app.bottlenote.alcohols.dto.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; + +public record AdminAlcoholBulkRequest( + @NotEmpty(message = "ALCOHOL_BULK_ROWS_REQUIRED") + @Size(max = MAX_ROWS, message = "ALCOHOL_BULK_ROWS_MAX_SIZE") + List<@NotNull(message = "ALCOHOL_BULK_ROW_REQUIRED") @Valid AdminAlcoholBulkRowRequest> + rows) { + public static final int MAX_ROWS = 1000; +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholBulkRowRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholBulkRowRequest.java new file mode 100644 index 000000000..b6b60501c --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholBulkRowRequest.java @@ -0,0 +1,22 @@ +package app.bottlenote.alcohols.dto.request; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import java.util.List; + +public record AdminAlcoholBulkRowRequest( + String clientRowId, + String korName, + String engName, + String abv, + String type, + String korCategory, + String engCategory, + String categoryGroup, + @JsonDeserialize(using = AlcoholBulkIdDeserializer.class) Long regionId, + @JsonDeserialize(using = AlcoholBulkIdDeserializer.class) Long distilleryId, + String age, + String cask, + String description, + String volume, + @JsonDeserialize(contentUsing = AlcoholBulkIdDeserializer.class) List tastingTagIds, + String imageUrl) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholUpsertRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholUpsertRequest.java index be6eecdec..f308632aa 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholUpsertRequest.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholUpsertRequest.java @@ -17,9 +17,9 @@ public record AdminAlcoholUpsertRequest( @NotNull(message = "카테고리 그룹은 필수입니다.") AlcoholCategoryGroup categoryGroup, @NotNull(message = "지역 ID는 필수입니다.") Long regionId, @NotNull(message = "증류소 ID는 필수입니다.") Long distilleryId, - @NotBlank(message = "숙성년도는 필수입니다.") String age, - @NotBlank(message = "캐스크 타입은 필수입니다.") String cask, + @Nullable String age, + @Nullable String cask, @Nullable String imageUrl, - @NotBlank(message = "설명은 필수입니다.") String description, + @Nullable String description, @NotBlank(message = "용량은 필수입니다.") String volume, List tastingTagIds) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AlcoholBulkIdDeserializer.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AlcoholBulkIdDeserializer.java new file mode 100644 index 000000000..acacf0809 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AlcoholBulkIdDeserializer.java @@ -0,0 +1,25 @@ +package app.bottlenote.alcohols.dto.request; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import java.io.IOException; + +public class AlcoholBulkIdDeserializer extends JsonDeserializer { + @Override + public Long deserialize(JsonParser parser, DeserializationContext context) throws IOException { + if (parser.hasToken(JsonToken.VALUE_NUMBER_INT)) { + return parser.getLongValue(); + } + if (parser.hasToken(JsonToken.VALUE_STRING)) { + String value = parser.getText().trim(); + try { + return Long.valueOf(value); + } catch (NumberFormatException exception) { + return (Long) context.handleWeirdStringValue(Long.class, value, "ID는 Long 범위의 정수여야 합니다."); + } + } + return (Long) context.handleUnexpectedToken(Long.class, parser); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkCreateResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkCreateResponse.java new file mode 100644 index 000000000..85c714b41 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkCreateResponse.java @@ -0,0 +1,8 @@ +package app.bottlenote.alcohols.dto.response; + +import java.util.List; + +public record AdminAlcoholBulkCreateResponse( + int createdRows, List rows, AdminAlcoholBulkValidateResponse validation) { + public record CreatedRow(String clientRowId, Long alcoholId) {} +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkIssue.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkIssue.java new file mode 100644 index 000000000..4c7899f2f --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkIssue.java @@ -0,0 +1,3 @@ +package app.bottlenote.alcohols.dto.response; + +public record AdminAlcoholBulkIssue(String code, String field, String message) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkRowResult.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkRowResult.java new file mode 100644 index 000000000..9a3035ca2 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkRowResult.java @@ -0,0 +1,12 @@ +package app.bottlenote.alcohols.dto.response; + +import app.bottlenote.alcohols.dto.request.AdminAlcoholBulkRowRequest; +import java.util.List; + +public record AdminAlcoholBulkRowResult( + String clientRowId, + boolean valid, + AdminAlcoholBulkRowRequest normalized, + List errors, + List warnings, + List candidateAlcoholIds) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkValidateResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkValidateResponse.java new file mode 100644 index 000000000..cc62b35ff --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkValidateResponse.java @@ -0,0 +1,10 @@ +package app.bottlenote.alcohols.dto.response; + +import java.util.List; + +public record AdminAlcoholBulkValidateResponse( + int totalRows, + int validRows, + int invalidRows, + int warningRows, + List rows) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AlcoholBulkReferenceItem.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AlcoholBulkReferenceItem.java new file mode 100644 index 000000000..625e1e635 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AlcoholBulkReferenceItem.java @@ -0,0 +1,16 @@ +package app.bottlenote.alcohols.dto.response; + +import app.bottlenote.alcohols.constant.AlcoholCategoryGroup; +import app.bottlenote.alcohols.constant.AlcoholType; + +public record AlcoholBulkReferenceItem( + Long alcoholId, + String korName, + String engName, + String korCategory, + String engCategory, + AlcoholCategoryGroup categoryGroup, + AlcoholType type, + Long distilleryId, + String abv, + String volume) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/exception/AlcoholExceptionCode.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/exception/AlcoholExceptionCode.java index 041fb1e91..e496f0023 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/exception/AlcoholExceptionCode.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/exception/AlcoholExceptionCode.java @@ -4,6 +4,8 @@ import org.springframework.http.HttpStatus; public enum AlcoholExceptionCode implements ExceptionCode { + BULK_EMPTY_REQUEST(HttpStatus.BAD_REQUEST, "등록할 알코올 행이 필요합니다."), + BULK_ROW_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "알코올은 최대 1,000행까지 등록할 수 있습니다."), ALCOHOL_NOT_FOUND(HttpStatus.NOT_FOUND, "위스키를 찾을 수 없습니다."), REGION_NOT_FOUND(HttpStatus.NOT_FOUND, "지역을 찾을 수 없습니다."), DISTILLERY_NOT_FOUND(HttpStatus.NOT_FOUND, "증류소를 찾을 수 없습니다."), diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholQueryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholQueryRepository.java index fe091ada3..d7d4162c2 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholQueryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholQueryRepository.java @@ -3,6 +3,7 @@ import app.bottlenote.alcohols.constant.AlcoholType; import app.bottlenote.alcohols.domain.Alcohol; import app.bottlenote.alcohols.domain.AlcoholQueryRepository; +import app.bottlenote.alcohols.dto.response.AlcoholBulkReferenceItem; import app.bottlenote.alcohols.dto.response.CategoryItem; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; @@ -12,6 +13,15 @@ public interface JpaAlcoholQueryRepository extends AlcoholQueryRepository, JpaRepository, CustomAlcoholQueryRepository { + @Override + @Query( + """ + select new app.bottlenote.alcohols.dto.response.AlcoholBulkReferenceItem( + a.id, a.korName, a.engName, a.korCategory, a.engCategory, a.categoryGroup, a.type, a.distillery.id, a.abv, a.volume) + from alcohol a where a.deletedAt is null order by a.id + """) + List findAllBulkReferenceItems(); + @Override @Query("select distinct a from alcohol a left join fetch a.region where a.id in :ids") List findAllByIdIn(@Param("ids") List ids); diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AdminAlcoholBulkService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AdminAlcoholBulkService.java new file mode 100644 index 000000000..e0699007e --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AdminAlcoholBulkService.java @@ -0,0 +1,11 @@ +package app.bottlenote.alcohols.service; + +import app.bottlenote.alcohols.dto.request.AdminAlcoholBulkRequest; +import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkCreateResponse; +import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkValidateResponse; + +public interface AdminAlcoholBulkService { + AdminAlcoholBulkValidateResponse validate(AdminAlcoholBulkRequest request); + + AdminAlcoholBulkCreateResponse create(AdminAlcoholBulkRequest request); +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholBulkInputNormalizer.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholBulkInputNormalizer.java new file mode 100644 index 000000000..181de7975 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholBulkInputNormalizer.java @@ -0,0 +1,148 @@ +package app.bottlenote.alcohols.service; + +import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkIssue; +import java.math.BigDecimal; +import java.text.Normalizer; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +final class AlcoholBulkInputNormalizer { + private static final String NUMBER = "[+-]?(?:[0-9]{1,3}(?:,[0-9]{3})+|[0-9]+)(?:\\.[0-9]+)?"; + private static final Pattern SCALAR = + Pattern.compile("^(" + NUMBER + ")\\s*(%|ml|cl|l)?$", Pattern.CASE_INSENSITIVE); + private static final Pattern RANGE = + Pattern.compile( + "^(" + NUMBER + ")\\s*(%|ml|cl|l)?\\s*[-~–]\\s*(" + NUMBER + ")\\s*(%|ml|cl|l)?$", + Pattern.CASE_INSENSITIVE); + private static final Pattern SET_SUFFIX = + Pattern.compile( + "^(.+?)\\s*[x×*]\\s*([1-9][0-9]{0,5})(?:\\s*(?:병|bottles?))?$", Pattern.CASE_INSENSITIVE); + private static final Pattern SET_PREFIX = + Pattern.compile("^([1-9][0-9]{0,5})\\s*[x×*]\\s*(.+)$", Pattern.CASE_INSENSITIVE); + private static final Pattern BATCH = + Pattern.compile("^(?:batch|배치)\\s*[a-z0-9]+\\s*[:=]\\s*(.+)$", Pattern.CASE_INSENSITIVE); + + private AlcoholBulkInputNormalizer() {} + + static String clean(String value) { + if (value == null) return null; + String cleaned = value.replace('\u00a0', ' ').strip(); + return cleaned.isBlank() ? null : cleaned; + } + + static String key(String value) { + String cleaned = clean(value); + return cleaned == null + ? "" + : Normalizer.normalize(cleaned, Normalizer.Form.NFKC) + .replaceAll("[\\s\\p{Z}]+", " ") + .toLowerCase(Locale.ROOT); + } + + static String quantity( + String raw, + String field, + List errors, + List warnings) { + String value = clean(raw); + if (value == null) { + errors.add(new AdminAlcoholBulkIssue("REQUIRED", field, "필수 입력값입니다.")); + return null; + } + if (value.length() > 255) { + errors.add(new AdminAlcoholBulkIssue("TOO_LONG", field, "255자를 초과할 수 없습니다.")); + return null; + } + Matcher scalar = SCALAR.matcher(value); + if (scalar.matches()) { + BigDecimal number = scalarNumber(scalar.group(1), scalar.group(2), field); + if (number != null) { + String normalized = + number.stripTrailingZeros().toPlainString() + (field.equals("abv") ? "%" : "ml"); + if (normalized.length() <= 255) return normalized; + errors.add(new AdminAlcoholBulkIssue("TOO_LONG", field, "정규화한 값이 255자를 초과합니다.")); + return null; + } + } else if (isAnnotated(value, field) || isComposite(value, field)) { + warnings.add( + new AdminAlcoholBulkIssue("NON_SCALAR_VALUE", field, "범위·배치·세트·주석 표현을 원문으로 보존합니다.")); + return value; + } + errors.add( + new AdminAlcoholBulkIssue( + "INVALID_QUANTITY", + field, + field.equals("abv") + ? "도수는 0~100의 숫자와 % 단위로 입력해 주세요." + : "용량은 양수와 ml, cl, L 단위로 입력해 주세요.")); + return null; + } + + private static boolean isAnnotated(String value, String field) { + String scalar = value.replaceFirst("^(?:약|approx\\.?)\\s*", ""); + scalar = scalar.replaceAll("\\(\\s*(?:배치\\s*마다\\s*상이|제품\\s*마다\\s*상이|캐스크\\s*스트렝스)\\s*\\)", ""); + if (!scalar.equals(value) && validScalar(scalar, field)) return true; + if (!field.equals("volume")) return false; + int start = value.indexOf('('); + if (start <= 0 || !value.endsWith(")")) return false; + Matcher outside = SCALAR.matcher(value.substring(0, start).strip()); + Matcher inside = SCALAR.matcher(value.substring(start + 1, value.length() - 1).strip()); + if (!outside.matches() || !inside.matches()) return false; + BigDecimal first = scalarNumber(outside.group(1), outside.group(2), field); + BigDecimal second = scalarNumber(inside.group(1), inside.group(2), field); + return first != null && second != null && first.compareTo(second) == 0; + } + + private static boolean isComposite(String value, String field) { + if (validRange(value, field)) return true; + if (field.equals("volume")) { + Matcher suffix = SET_SUFFIX.matcher(value); + if (suffix.matches() && validScalar(suffix.group(1), field)) return true; + Matcher prefix = SET_PREFIX.matcher(value); + if (prefix.matches() && validScalar(prefix.group(2), field)) return true; + } + String[] parts = value.split("\\s*(?:/|;|\\+|,(?=\\s*(?:[Bb][Aa][Tt][Cc][Hh]|배치)))\\s*", -1); + boolean batchFound = false; + for (String part : parts) { + Matcher batch = BATCH.matcher(part); + if (batch.matches()) { + batchFound = true; + part = batch.group(1); + } + if (!validScalar(part, field) && !validRange(part, field)) return false; + } + return parts.length > 1 || batchFound; + } + + private static boolean validScalar(String value, String field) { + Matcher matcher = SCALAR.matcher(value); + return matcher.matches() && scalarNumber(matcher.group(1), matcher.group(2), field) != null; + } + + private static boolean validRange(String value, String field) { + Matcher matcher = RANGE.matcher(value); + if (!matcher.matches()) return false; + String firstUnit = matcher.group(2) == null ? matcher.group(4) : matcher.group(2); + String lastUnit = matcher.group(4) == null ? matcher.group(2) : matcher.group(4); + BigDecimal first = scalarNumber(matcher.group(1), firstUnit, field); + BigDecimal last = scalarNumber(matcher.group(3), lastUnit, field); + return first != null && last != null && first.compareTo(last) <= 0; + } + + private static BigDecimal scalarNumber(String value, String unit, String field) { + BigDecimal number = new BigDecimal(value.replace(",", "")); + String normalizedUnit = unit == null ? "" : unit.toLowerCase(Locale.ROOT); + if (field.equals("abv")) { + if ((!normalizedUnit.isEmpty() && !normalizedUnit.equals("%")) + || number.signum() < 0 + || number.compareTo(BigDecimal.valueOf(100)) > 0) return null; + } else { + if (normalizedUnit.equals("%") || number.signum() <= 0) return null; + if (normalizedUnit.equals("cl")) number = number.multiply(BigDecimal.TEN); + if (normalizedUnit.equals("l")) number = number.multiply(BigDecimal.valueOf(1000)); + } + return number; + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholBulkService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholBulkService.java new file mode 100644 index 000000000..aee7551ae --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholBulkService.java @@ -0,0 +1,443 @@ +package app.bottlenote.alcohols.service; + +import static app.bottlenote.alcohols.service.AlcoholBulkInputNormalizer.clean; +import static app.bottlenote.alcohols.service.AlcoholBulkInputNormalizer.key; + +import app.bottlenote.alcohols.constant.AlcoholCategoryGroup; +import app.bottlenote.alcohols.constant.AlcoholType; +import app.bottlenote.alcohols.domain.Alcohol; +import app.bottlenote.alcohols.domain.AlcoholQueryRepository; +import app.bottlenote.alcohols.domain.AlcoholsTastingTags; +import app.bottlenote.alcohols.domain.AlcoholsTastingTagsRepository; +import app.bottlenote.alcohols.domain.Distillery; +import app.bottlenote.alcohols.domain.DistilleryRepository; +import app.bottlenote.alcohols.domain.Region; +import app.bottlenote.alcohols.domain.RegionRepository; +import app.bottlenote.alcohols.domain.TastingTag; +import app.bottlenote.alcohols.domain.TastingTagRepository; +import app.bottlenote.alcohols.dto.request.AdminAlcoholBulkRequest; +import app.bottlenote.alcohols.dto.request.AdminAlcoholBulkRowRequest; +import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkCreateResponse; +import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkCreateResponse.CreatedRow; +import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkIssue; +import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkRowResult; +import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkValidateResponse; +import app.bottlenote.alcohols.dto.response.AlcoholBulkReferenceItem; +import app.bottlenote.alcohols.exception.AlcoholException; +import app.bottlenote.alcohols.exception.AlcoholExceptionCode; +import app.bottlenote.common.file.event.payload.ImageResourceActivatedEvent; +import app.bottlenote.common.image.ImageUtil; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class DefaultAdminAlcoholBulkService implements AdminAlcoholBulkService { + private static final int MAX_TAGS = 1000; + private final AlcoholQueryRepository alcoholRepository; + private final RegionRepository regionRepository; + private final DistilleryRepository distilleryRepository; + private final TastingTagRepository tagRepository; + private final AlcoholsTastingTagsRepository mappingRepository; + private final ApplicationEventPublisher eventPublisher; + + @Override + public AdminAlcoholBulkValidateResponse validate(AdminAlcoholBulkRequest request) { + checkRequest(request); + return validateRows(request.rows(), loadReferences(request.rows())); + } + + @Override + @Transactional + public AdminAlcoholBulkCreateResponse create(AdminAlcoholBulkRequest request) { + checkRequest(request); + References references = loadReferences(request.rows()); + AdminAlcoholBulkValidateResponse validation = validateRows(request.rows(), references); + if (validation.invalidRows() > 0) { + return new AdminAlcoholBulkCreateResponse(0, List.of(), validation); + } + List created = new ArrayList<>(); + for (AdminAlcoholBulkRowResult result : validation.rows()) { + AdminAlcoholBulkRowRequest row = result.normalized(); + Alcohol saved = + alcoholRepository.save( + Alcohol.builder() + .korName(row.korName()) + .engName(row.engName()) + .abv(row.abv()) + .type(AlcoholType.valueOf(row.type())) + .korCategory(row.korCategory()) + .engCategory(row.engCategory()) + .categoryGroup(AlcoholCategoryGroup.valueOf(row.categoryGroup())) + .region(references.regions().get(row.regionId())) + .distillery(references.distilleries().get(row.distilleryId())) + .age(row.age()) + .cask(row.cask()) + .description(row.description()) + .volume(row.volume()) + .imageUrl(row.imageUrl()) + .build()); + if (!row.tastingTagIds().isEmpty()) { + mappingRepository.saveAll( + row.tastingTagIds().stream() + .map(id -> AlcoholsTastingTags.of(saved, references.tags().get(id))) + .toList()); + } + if (row.imageUrl() != null) { + String resourceKey = ImageUtil.extractResourceKey(row.imageUrl()); + if (resourceKey != null) { + eventPublisher.publishEvent( + ImageResourceActivatedEvent.of(resourceKey, saved.getId(), "ALCOHOL")); + } + } + created.add(new CreatedRow(row.clientRowId(), saved.getId())); + } + return new AdminAlcoholBulkCreateResponse(created.size(), List.copyOf(created), validation); + } + + private void checkRequest(AdminAlcoholBulkRequest request) { + if (request == null || request.rows() == null || request.rows().isEmpty()) { + throw new AlcoholException(AlcoholExceptionCode.BULK_EMPTY_REQUEST); + } + if (request.rows().size() > AdminAlcoholBulkRequest.MAX_ROWS) { + throw new AlcoholException(AlcoholExceptionCode.BULK_ROW_LIMIT_EXCEEDED); + } + } + + private References loadReferences(List rows) { + Set regionIds = new HashSet<>(); + Set distilleryIds = new HashSet<>(); + for (AdminAlcoholBulkRowRequest row : rows) { + if (row == null) continue; + if (row.regionId() != null && row.regionId() > 0) regionIds.add(row.regionId()); + if (row.distilleryId() != null && row.distilleryId() > 0) + distilleryIds.add(row.distilleryId()); + } + Map regions = + regionIds.isEmpty() + ? Map.of() + : index(regionRepository.findAllByIdInOrderBySortOrderAsc(regionIds), Region::getId); + Map distilleries = + distilleryIds.isEmpty() + ? Map.of() + : index( + distilleryRepository.findAllByIdInOrderBySortOrderAsc(distilleryIds), + Distillery::getId); + Map> categories = new HashMap<>(); + Map> categoryTypes = new HashMap<>(); + Map> candidates = new HashMap<>(); + for (AlcoholBulkReferenceItem item : alcoholRepository.findAllBulkReferenceItems()) { + if (item.categoryGroup() != null) { + categories + .computeIfAbsent( + categoryKey(item.korCategory(), item.engCategory()), ignored -> new HashSet<>()) + .add(item.categoryGroup()); + } + if (item.type() != null) { + categoryTypes + .computeIfAbsent( + categoryKey(item.korCategory(), item.engCategory()), ignored -> new HashSet<>()) + .add(item.type()); + } + addCandidate( + candidates, + identity(item.korName(), item.distilleryId(), item.abv(), item.volume()), + item.alcoholId()); + } + return new References( + regions, + distilleries, + index(tagRepository.findAll(), TastingTag::getId), + categories, + categoryTypes, + candidates); + } + + private AdminAlcoholBulkValidateResponse validateRows( + List rows, References refs) { + Map clientIds = new HashMap<>(); + Map identities = new HashMap<>(); + for (AdminAlcoholBulkRowRequest row : rows) { + if (row == null) continue; + if (clean(row.clientRowId()) != null) + clientIds.merge(clean(row.clientRowId()), 1, Integer::sum); + identities.merge( + identity(row.korName(), row.distilleryId(), row.abv(), row.volume()), 1, Integer::sum); + } + List results = new ArrayList<>(); + for (AdminAlcoholBulkRowRequest row : rows) { + results.add(validateRow(row, refs, clientIds, identities)); + } + int valid = (int) results.stream().filter(AdminAlcoholBulkRowResult::valid).count(); + int warning = (int) results.stream().filter(row -> !row.warnings().isEmpty()).count(); + return new AdminAlcoholBulkValidateResponse( + rows.size(), valid, rows.size() - valid, warning, List.copyOf(results)); + } + + private AdminAlcoholBulkRowResult validateRow( + AdminAlcoholBulkRowRequest row, + References refs, + Map clientIds, + Map identities) { + List errors = new ArrayList<>(); + List warnings = new ArrayList<>(); + if (row == null) { + return new AdminAlcoholBulkRowResult( + null, + false, + null, + List.of(issue("REQUIRED", "row", "행은 null일 수 없습니다.")), + List.of(), + List.of()); + } + String clientId = text(row.clientRowId(), "clientRowId", true, errors); + String korName = text(row.korName(), "korName", true, errors); + String engName = text(row.engName(), "engName", true, errors); + String korCategory = text(row.korCategory(), "korCategory", true, errors); + String engCategory = text(row.engCategory(), "engCategory", true, errors); + String age = text(row.age(), "age", false, errors); + String cask = text(row.cask(), "cask", false, errors); + String image = text(row.imageUrl(), "imageUrl", false, errors); + String description = clean(row.description()); + if (description != null + && (description.length() > 65535 + || description.getBytes(StandardCharsets.UTF_8).length > 65535)) { + errors.add(issue("TOO_LONG", "description", "설명은 UTF-8 기준 65,535바이트를 초과할 수 없습니다.")); + } + String abv = AlcoholBulkInputNormalizer.quantity(row.abv(), "abv", errors, warnings); + String volume = AlcoholBulkInputNormalizer.quantity(row.volume(), "volume", errors, warnings); + AlcoholType type = parseType(clean(row.type())); + if (type == null) errors.add(issue("INVALID_ENUM", "type", "유효한 주류 타입을 입력해 주세요.")); + AlcoholCategoryGroup group = resolveGroup(row, type, refs.categories(), errors, warnings); + Set categoryTypes = + refs.categoryTypes().getOrDefault(categoryKey(korCategory, engCategory), Set.of()); + if (type != null && !categoryTypes.isEmpty() && !categoryTypes.contains(type)) { + warnings.add(issue("TYPE_CATEGORY_MISMATCH", "type", "기존 카테고리의 주류 타입과 다릅니다.")); + } + checkReference(row.regionId(), "regionId", refs.regions(), errors); + checkReference(row.distilleryId(), "distilleryId", refs.distilleries(), errors); + List tags = tags(row.tastingTagIds(), refs.tags(), errors, warnings); + if (image != null && !validUrl(image)) + errors.add(issue("INVALID_URL", "imageUrl", "유효한 http 또는 https 이미지 URL을 입력해 주세요.")); + if (clientId != null && clientIds.getOrDefault(clientId, 0) > 1) { + errors.add(issue("DUPLICATE_CLIENT_ROW_ID", "clientRowId", "요청 내 clientRowId는 유일해야 합니다.")); + } + IdentityKey identity = identity(korName, row.distilleryId(), row.abv(), row.volume()); + if (identities.getOrDefault(identity, 0) > 1) { + warnings.add( + issue("DUPLICATE_REQUEST_ROW", "korName", "요청 안에 이름·증류소·도수·용량이 같은 중복 후보가 있습니다.")); + } + Set candidates = refs.candidates().getOrDefault(identity, Set.of()); + if (!candidates.isEmpty()) + warnings.add(issue("DUPLICATE_DB_CANDIDATE", "korName", "DB에 이름·증류소·도수·용량이 같은 중복 후보가 있습니다.")); + if (candidates.size() > 100) + warnings.add(issue("CANDIDATES_TRUNCATED", "korName", "중복 후보가 많아 ID를 최대 100개까지 반환합니다.")); + AdminAlcoholBulkRowRequest normalized = + errors.isEmpty() + ? new AdminAlcoholBulkRowRequest( + clientId, + korName, + engName, + abv, + type.name(), + korCategory, + engCategory, + group.name(), + row.regionId(), + row.distilleryId(), + age, + cask, + description, + volume, + tags, + image) + : null; + return new AdminAlcoholBulkRowResult( + clientId, + errors.isEmpty(), + normalized, + List.copyOf(errors), + List.copyOf(warnings), + candidates.stream().sorted().limit(100).toList()); + } + + private AlcoholCategoryGroup resolveGroup( + AdminAlcoholBulkRowRequest row, + AlcoholType type, + Map> categories, + List errors, + List warnings) { + Set known = + categories.getOrDefault(categoryKey(row.korCategory(), row.engCategory()), Set.of()); + String raw = clean(row.categoryGroup()); + AlcoholCategoryGroup group = null; + if (raw != null) { + group = + Arrays.stream(AlcoholCategoryGroup.values()) + .filter( + value -> value.name().equalsIgnoreCase(raw) || value.getDescription().equals(raw)) + .findFirst() + .orElse(null); + if (group == null) + errors.add(issue("INVALID_ENUM", "categoryGroup", "유효한 카테고리 그룹을 입력해 주세요.")); + } else if (known.size() == 1) { + group = known.iterator().next(); + } else if (type != null && type != AlcoholType.WHISKY) { + group = AlcoholCategoryGroup.OTHER; + } else { + errors.add( + issue("CATEGORY_GROUP_REQUIRED", "categoryGroup", "카테고리 그룹을 유일하게 추론할 수 없어 직접 입력해야 합니다.")); + } + if (known.isEmpty()) + warnings.add(issue("UNKNOWN_CATEGORY", "engCategory", "기존 참조에 없는 카테고리 조합을 보존합니다.")); + if (group != null && !known.isEmpty() && !known.contains(group)) { + warnings.add(issue("CATEGORY_GROUP_MISMATCH", "categoryGroup", "기존 카테고리와 그룹이 다릅니다.")); + } + if (group != null + && type != null + && type != AlcoholType.WHISKY + && group != AlcoholCategoryGroup.OTHER) { + warnings.add( + issue("TYPE_GROUP_MISMATCH", "categoryGroup", "주류 타입과 위스키 카테고리 그룹의 의미를 확인해 주세요.")); + } + return group; + } + + private List tags( + List input, + Map known, + List errors, + List warnings) { + if (input == null) return List.of(); + if (input.size() > MAX_TAGS) { + errors.add(issue("TOO_MANY_TAGS", "tastingTagIds", "태그는 행마다 최대 1,000개까지 입력할 수 있습니다.")); + return List.of(); + } + Set unique = new LinkedHashSet<>(); + boolean invalid = false; + boolean duplicate = false; + for (Long id : input) { + if (id == null || id <= 0 || !known.containsKey(id)) invalid = true; + else if (!unique.add(id)) duplicate = true; + } + if (invalid) + errors.add(issue("INVALID_REFERENCE", "tastingTagIds", "null 또는 존재하지 않는 태그 ID가 있습니다.")); + if (duplicate) + warnings.add(issue("DUPLICATE_TAG_REMOVED", "tastingTagIds", "중복 태그 ID를 제거했습니다.")); + return List.copyOf(unique); + } + + private static AlcoholType parseType(String raw) { + if (raw == null) return null; + return Arrays.stream(AlcoholType.values()) + .filter( + value -> + value.name().equalsIgnoreCase(raw) + || value.getType().equals(raw) + || value.getEngCategory().equalsIgnoreCase(raw)) + .findFirst() + .orElse(null); + } + + private static String text( + String raw, String field, boolean required, List errors) { + String value = clean(raw); + if (required && value == null) errors.add(issue("REQUIRED", field, "필수 입력값입니다.")); + if (value != null && value.length() > 255) + errors.add(issue("TOO_LONG", field, "255자를 초과할 수 없습니다.")); + return value; + } + + private static boolean validUrl(String value) { + try { + URI uri = new URI(value); + return ("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme())) + && uri.getHost() != null + && uri.getRawUserInfo() == null + && !signedQuery(uri.getRawQuery()) + && (uri.getPort() == -1 || uri.getPort() > 0 && uri.getPort() <= 65535); + } catch (URISyntaxException exception) { + return false; + } + } + + private static boolean signedQuery(String rawQuery) { + if (rawQuery == null) return false; + String query = URLDecoder.decode(rawQuery, StandardCharsets.UTF_8).toLowerCase(Locale.ROOT); + return Arrays.stream(query.split("&")) + .map(part -> part.split("=", 2)[0]) + .anyMatch( + name -> + name.equals("x-amz-signature") + || name.equals("x-goog-signature") + || name.equals("signature") + || name.equals("awsaccesskeyid")); + } + + private static void checkReference( + Long id, String field, Map known, List errors) { + if (id == null || id <= 0 || !known.containsKey(id)) { + errors.add(issue("INVALID_REFERENCE", field, "존재하는 참조 ID를 입력해 주세요.")); + } + } + + private static Map index(List values, Function id) { + return values.stream().collect(Collectors.toMap(id, Function.identity())); + } + + private static IdentityKey identity(String name, Long distilleryId, String abv, String volume) { + return new IdentityKey( + key(name), distilleryId, quantityKey(abv, "abv"), quantityKey(volume, "volume")); + } + + private static String quantityKey(String value, String field) { + String normalized = + AlcoholBulkInputNormalizer.quantity(value, field, new ArrayList<>(), new ArrayList<>()); + return key(normalized == null ? value : normalized); + } + + private static void addCandidate( + Map> index, IdentityKey identity, Long id) { + if (identity.name().isEmpty() || id == null) return; + Set candidates = index.computeIfAbsent(identity, ignored -> new LinkedHashSet<>()); + if (candidates.size() <= 100) candidates.add(id); + } + + private static CategoryKey categoryKey(String korCategory, String engCategory) { + return new CategoryKey(key(korCategory), key(engCategory)); + } + + private static AdminAlcoholBulkIssue issue(String code, String field, String message) { + return new AdminAlcoholBulkIssue(code, field, message); + } + + private record IdentityKey(String name, Long distilleryId, String abv, String volume) {} + + private record CategoryKey(String korCategory, String engCategory) {} + + private record References( + Map regions, + Map distilleries, + Map tags, + Map> categories, + Map> categoryTypes, + Map> candidates) {} +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/exception/custom/code/ValidExceptionCode.java b/bottlenote-mono/src/main/java/app/bottlenote/global/exception/custom/code/ValidExceptionCode.java index c13832c4b..ba24b3939 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/global/exception/custom/code/ValidExceptionCode.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/exception/custom/code/ValidExceptionCode.java @@ -29,6 +29,9 @@ public enum ValidExceptionCode implements ExceptionCode { PLATFORM_REQUIRED(HttpStatus.BAD_REQUEST, "플랫폼은 필수입니다."), // ALCOHOL + ALCOHOL_BULK_ROWS_REQUIRED(HttpStatus.BAD_REQUEST, "등록할 알코올 목록은 필수입니다."), + ALCOHOL_BULK_ROWS_MAX_SIZE(HttpStatus.BAD_REQUEST, "알코올은 한 번에 최대 1,000개까지 등록할 수 있습니다."), + ALCOHOL_BULK_ROW_REQUIRED(HttpStatus.BAD_REQUEST, "알코올 행은 null일 수 없습니다."), ALCOHOL_ID_REQUIRED(HttpStatus.BAD_REQUEST, "알코올 식별자는 필수입니다."), ALCOHOL_ID_MINIMUM(HttpStatus.BAD_REQUEST, "알코올 식별자는 최소 1 이상 이어야 합니다."), diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/AlcoholBulkInputNormalizerTest.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/AlcoholBulkInputNormalizerTest.java new file mode 100644 index 000000000..30e74344f --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/AlcoholBulkInputNormalizerTest.java @@ -0,0 +1,95 @@ +package app.bottlenote.alcohols.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkIssue; +import java.util.ArrayList; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +@Tag("unit") +@DisplayName("알코올 벌크 수치 정규화") +class AlcoholBulkInputNormalizerTest { + @ParameterizedTest + @CsvSource({ + "abv,40,40%", + "abv, 40.50 % ,40.5%", + "abv,0,0%", + "abv,100%,100%", + "volume,70 cl,700ml", + "volume,0.7 L,700ml", + "volume,700,700ml", + "volume,'1,000 ml',1000ml", + "volume,'1,000.5 cl',10005ml" + }) + @DisplayName("숫자와 단위가 유효할 때 표준 단위로 정규화한다") + void 수치를_정규화한다(String field, String input, String expected) { + var errors = new ArrayList(); + var warnings = new ArrayList(); + assertThat(AlcoholBulkInputNormalizer.quantity(input, field, errors, warnings)) + .isEqualTo(expected); + assertThat(errors).isEmpty(); + assertThat(warnings).isEmpty(); + } + + @ParameterizedTest + @CsvSource({ + "abv,-1", + "abv,100.01%", + "abv,40ml", + "abv,NaN", + "abv,Infinity", + "abv,forty", + "abv,'4,0%'", + "abv,40-101%", + "abv,50-40%", + "abv,40/200%", + "abv,batch 1: invalid", + "volume,0", + "volume,-700ml", + "volume,700%", + "volume,700oz", + "volume,'70,0ml'", + "volume,'1,00,000ml'", + "volume,700mL garbage", + "volume,700ml x 0", + "volume,0ml x 2", + "volume,700ml x -2", + "volume,700ml/garbage", + "volume,1e3", + "volume,700ml/", + "abv,40%%", + "abv,약101(배치마다상이)%", + "abv,58.5(쓰레기)%", + "volume,1L(700ml)", + "volume,'1,,000ml'" + }) + @DisplayName("값이나 단위가 잘못됐을 때 오류를 반환한다") + void 잘못된_수치를_거절한다(String field, String input) { + var errors = new ArrayList(); + var warnings = new ArrayList(); + assertThat(AlcoholBulkInputNormalizer.quantity(input, field, errors, warnings)).isNull(); + assertThat(errors).extracting(AdminAlcoholBulkIssue::code).containsExactly("INVALID_QUANTITY"); + assertThat(warnings).isEmpty(); + } + + @ParameterizedTest + @CsvSource({ + "abv,40-46%", "abv,40% / 43%", "abv,batch 1: 40%", "abv,'Batch 1: 40%, Batch 2: 43%'", + "abv,약60(배치마다상이)%", "abv,63.4(제품마다상이)%", "abv,58.5(캐스크 스트렝스)%", "volume,1L(1000ml)", + "volume,700ml x 2", "volume,2 x 700ml", "volume,700ml + 50ml", "volume,0.7-1L" + }) + @DisplayName("범위와 배치 및 세트 표현이 유효할 때 원문과 경고를 반환한다") + void 복합_표현을_보존한다(String field, String input) { + var errors = new ArrayList(); + var warnings = new ArrayList(); + assertThat(AlcoholBulkInputNormalizer.quantity(input, field, errors, warnings)) + .isEqualTo(input); + assertThat(errors).isEmpty(); + assertThat(warnings) + .extracting(AdminAlcoholBulkIssue::code) + .containsExactly("NON_SCALAR_VALUE"); + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholBulkServiceTest.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholBulkServiceTest.java new file mode 100644 index 000000000..d7c03769f --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholBulkServiceTest.java @@ -0,0 +1,502 @@ +package app.bottlenote.alcohols.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import app.bottlenote.alcohols.constant.AlcoholCategoryGroup; +import app.bottlenote.alcohols.constant.AlcoholType; +import app.bottlenote.alcohols.domain.Alcohol; +import app.bottlenote.alcohols.domain.Distillery; +import app.bottlenote.alcohols.domain.Region; +import app.bottlenote.alcohols.domain.TastingTag; +import app.bottlenote.alcohols.dto.request.AdminAlcoholBulkRequest; +import app.bottlenote.alcohols.dto.request.AdminAlcoholBulkRowRequest; +import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkIssue; +import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkRowResult; +import app.bottlenote.alcohols.dto.response.AlcoholBulkReferenceItem; +import app.bottlenote.alcohols.exception.AlcoholException; +import app.bottlenote.alcohols.fixture.InMemoryAlcoholQueryRepository; +import app.bottlenote.alcohols.fixture.InMemoryAlcoholsTastingTagsRepository; +import app.bottlenote.alcohols.fixture.InMemoryDistilleryRepository; +import app.bottlenote.alcohols.fixture.InMemoryRegionRepository; +import app.bottlenote.alcohols.fixture.InMemoryTastingTagRepository; +import app.bottlenote.common.file.event.payload.ImageResourceActivatedEvent; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.stream.IntStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@Tag("unit") +@DisplayName("알코올 벌크 검증 및 생성") +class DefaultAdminAlcoholBulkServiceTest { + private CountingAlcoholRepository alcohols; + private CountingRegionRepository regions; + private CountingDistilleryRepository distilleries; + private CountingTagRepository tags; + private InMemoryAlcoholsTastingTagsRepository mappings; + private List events; + private DefaultAdminAlcoholBulkService service; + + @BeforeEach + void setUp() { + alcohols = new CountingAlcoholRepository(); + regions = new CountingRegionRepository(); + distilleries = new CountingDistilleryRepository(); + tags = new CountingTagRepository(); + mappings = new InMemoryAlcoholsTastingTagsRepository(); + events = new ArrayList<>(); + service = + new DefaultAdminAlcoholBulkService( + alcohols, regions, distilleries, tags, mappings, events::add); + regions.save(Region.builder().korName("지역").engName("Region").sortOrder(1).build()); + distilleries.save( + Distillery.builder().korName("증류소").engName("Distillery").sortOrder(1).build()); + tags.save(TastingTag.builder().korName("과일").engName("Fruit").build()); + } + + @Test + @DisplayName("선택값이 비어 있을 때 null로 정규화하고 JSON 재입력을 저장한다") + void 정규화한_행을_저장한다() { + Row row = new Row(); + row.abv = " 40.00 % "; + row.volume = "70cl"; + row.age = " "; + row.type = "위스키"; + row.group = "싱글몰트 위스키"; + var normalized = validate(row).normalized(); + var created = service.create(new AdminAlcoholBulkRequest(List.of(normalized))); + assertThat(created.createdRows()).isEqualTo(1); + assertThat(created.rows().getFirst().clientRowId()).isEqualTo("row1"); + Alcohol saved = alcohols.findById(created.rows().getFirst().alcoholId()).orElseThrow(); + assertThat(saved.getKorName()).isEqualTo("새 위스키"); + assertThat(saved.getAbv()).isEqualTo("40%"); + assertThat(saved.getVolume()).isEqualTo("700ml"); + assertThat(saved.getAge()).isNull(); + assertThat(saved.getCask()).isNull(); + assertThat(saved.getDescription()).isNull(); + assertThat(saved.getImageUrl()).isNull(); + assertThat(events).isEmpty(); + } + + @Test + @DisplayName("유효한 이미지와 중복 태그가 있을 때 태그는 한 번 저장하고 이미지 이벤트를 발행한다") + void 태그와_이미지_이벤트를_저장한다() { + Row row = new Row(); + row.image = "https://cdn.example.com/alcohols/new.png"; + row.tagIds = List.of(1L, 1L); + var response = service.create(request(row)); + assertThat(response.createdRows()).isEqualTo(1); + assertThat(mappings.count()).isEqualTo(1); + assertThat(response.validation().rows().getFirst().warnings()) + .extracting(AdminAlcoholBulkIssue::code) + .contains("DUPLICATE_TAG_REMOVED"); + assertThat(events) + .containsExactly( + ImageResourceActivatedEvent.of( + "alcohols/new.png", response.rows().getFirst().alcoholId(), "ALCOHOL")); + } + + @Test + @DisplayName("한 행에 오류가 있을 때 유효한 앞 행도 저장하지 않는다") + void 오류가_있으면_모두_저장하지_않는다() { + Row valid = new Row(); + valid.image = "https://cdn.example.com/a.png"; + valid.tagIds = List.of(1L); + Row invalid = new Row(); + invalid.clientId = "row2"; + invalid.type = "WHIKSY"; + var result = service.create(request(valid, invalid)); + assertThat(result.createdRows()).isZero(); + assertThat(result.rows()).isEmpty(); + assertThat(result.validation().invalidRows()).isEqualTo(1); + assertThat(alcohols.findAll()).isEmpty(); + assertThat(mappings.count()).isZero(); + assertThat(events).isEmpty(); + } + + @Test + @DisplayName("검증 이후 참조가 삭제됐을 때 생성에서 재검증하여 저장하지 않는다") + void 생성에서_참조를_다시_검증한다() { + Row row = new Row(); + row.tagIds = List.of(1L); + var validated = validate(row); + tags.clear(); + var result = service.create(new AdminAlcoholBulkRequest(List.of(validated.normalized()))); + assertThat(result.validation().invalidRows()).isEqualTo(1); + assertThat(result.createdRows()).isZero(); + assertThat(alcohols.findAll()).isEmpty(); + } + + @Test + @DisplayName("clientRowId가 중복될 때 해당 행 모두에 오류를 반환한다") + void 클라이언트_식별자_중복을_거절한다() { + var result = service.validate(request(new Row(), new Row())); + assertThat(result.invalidRows()).isEqualTo(2); + assertThat(result.rows()) + .allSatisfy( + row -> { + assertThat(row.normalized()).isNull(); + assertThat(row.errors()) + .extracting(AdminAlcoholBulkIssue::code) + .contains("DUPLICATE_CLIENT_ROW_ID"); + }); + } + + @Test + @DisplayName("요청과 DB에 중복 후보가 있을 때 경고와 후보 ID를 반환하고 저장을 허용한다") + void 중복_후보는_경고한다() { + Alcohol existing = existing(AlcoholCategoryGroup.SINGLE_MALT); + Row first = new Row(); + first.korName = existing.getKorName(); + Row second = new Row(); + second.clientId = "row2"; + second.korName = first.korName; + var result = service.create(request(first, second)); + assertThat(result.createdRows()).isEqualTo(2); + assertThat(result.validation().warningRows()).isEqualTo(2); + assertThat(result.validation().rows().getFirst().candidateAlcoholIds()) + .containsExactly(existing.getId()); + assertThat(result.validation().rows().getFirst().warnings()) + .extracting(AdminAlcoholBulkIssue::code) + .contains("DUPLICATE_REQUEST_ROW", "DUPLICATE_DB_CANDIDATE"); + } + + @Test + @DisplayName("카테고리 참조가 유일할 때 생략한 그룹을 도출한다") + void 유일한_그룹을_도출한다() { + existing(AlcoholCategoryGroup.SINGLE_MALT); + Row row = new Row(); + row.group = null; + assertThat(validate(row).normalized().categoryGroup()).isEqualTo("SINGLE_MALT"); + } + + @Test + @DisplayName("위스키 카테고리 참조가 없거나 모호할 때 그룹 생략을 거절한다") + void 위스키_그룹_추론이_모호하면_거절한다() { + Row row = new Row(); + row.group = null; + assertThat(validate(row).errors()) + .extracting(AdminAlcoholBulkIssue::code) + .contains("CATEGORY_GROUP_REQUIRED"); + existing(AlcoholCategoryGroup.SINGLE_MALT); + existing(AlcoholCategoryGroup.BLEND); + assertThat(validate(row).errors()) + .extracting(AdminAlcoholBulkIssue::code) + .contains("CATEGORY_GROUP_REQUIRED"); + } + + @ParameterizedTest + @ValueSource( + strings = {"RUM", "VODKA", "GIN", "TEQUILA", "BRANDY", "BEER", "WINE", "ETC", "기타", "Others"}) + @DisplayName("위스키 이외 타입의 그룹이 없을 때 OTHER를 기본값으로 쓴다") + void 모든_비위스키_타입을_허용한다(String type) { + Row row = new Row(); + row.type = type; + row.group = null; + assertThat(validate(row).normalized().categoryGroup()).isEqualTo("OTHER"); + } + + @Test + @DisplayName("카테고리와 타입 및 그룹이 의미상 다를 때 경고를 반환하고 보존한다") + void 의미상_불일치를_보존한다() { + existing(AlcoholCategoryGroup.SINGLE_MALT); + Row row = new Row(); + row.type = "RUM"; + row.group = "BLEND"; + var result = validate(row); + assertThat(result.valid()).isTrue(); + assertThat(result.normalized().categoryGroup()).isEqualTo("BLEND"); + assertThat(result.warnings()) + .extracting(AdminAlcoholBulkIssue::code) + .contains("CATEGORY_GROUP_MISMATCH", "TYPE_GROUP_MISMATCH", "TYPE_CATEGORY_MISMATCH"); + } + + @ParameterizedTest + @ValueSource( + strings = { + "blob:https://example.com/a", + "data:image/png;base64,AA", + "javascript:alert(1)", + "ftp://example.com/a", + "//example.com/a", + "https:///a", + "https://a b/a", + "https://user:pass@example.com/a", + "https://example.com:99999/a", + "https://example.com/a?X-Amz-Signature=test", + "https://example.com/a?X%2DAmz%2DSignature=test" + }) + @DisplayName("이미지 URL이 유효하지 않을 때 오류를 반환한다") + void 잘못된_URL을_거절한다(String image) { + Row row = new Row(); + row.image = image; + assertThat(validate(row).errors()) + .extracting(AdminAlcoholBulkIssue::code) + .contains("INVALID_URL"); + } + + @Test + @DisplayName("이름과 선택 문자열이 길거나 필수값이 없을 때 필드별 오류를 반환한다") + void 문자열_길이와_필수값을_검증한다() { + Row row = new Row(); + row.korName = "가".repeat(256); + row.engName = " "; + row.age = "a".repeat(256); + row.cask = "c".repeat(256); + row.description = "한".repeat(21846); + row.korCategory = null; + row.group = "invalid"; + row.regionId = null; + row.distilleryId = -1L; + assertThat(validate(row).errors()) + .extracting(AdminAlcoholBulkIssue::field) + .contains( + "korName", + "engName", + "age", + "cask", + "description", + "korCategory", + "categoryGroup", + "regionId", + "distilleryId"); + } + + @Test + @DisplayName("태그에 null이나 존재하지 않는 ID가 있을 때 오류를 반환한다") + void 잘못된_태그를_거절한다() { + Row row = new Row(); + row.tagIds = Arrays.asList(1L, null, 999L); + assertThat(validate(row).errors()) + .extracting(AdminAlcoholBulkIssue::code) + .contains("INVALID_REFERENCE"); + row.tagIds = Collections.nCopies(1001, 1L); + assertThat(validate(row).errors()) + .extracting(AdminAlcoholBulkIssue::code) + .contains("TOO_MANY_TAGS"); + } + + @Test + @DisplayName("기존 325개 이상의 태그를 요청할 때 모두 보존한다") + void 많은_태그를_보존한다() { + for (int i = 2; i <= 400; i++) + tags.save(TastingTag.builder().korName("태그" + i).engName("tag" + i).build()); + Row row = new Row(); + row.tagIds = IntStream.rangeClosed(1, 400).mapToObj(i -> (long) i).toList(); + var created = service.create(request(row)); + assertThat(created.createdRows()).isEqualTo(1); + assertThat(mappings.count()).isEqualTo(400); + } + + @Test + @DisplayName("요청이 비거나 한도를 넘을 때 도메인 오류를 반환한다") + void 요청_행_한도를_검증한다() { + assertThatThrownBy(() -> service.validate(null)).isInstanceOf(AlcoholException.class); + assertThatThrownBy(() -> service.validate(new AdminAlcoholBulkRequest(null))) + .isInstanceOf(AlcoholException.class); + assertThatThrownBy(() -> service.create(new AdminAlcoholBulkRequest(List.of()))) + .isInstanceOf(AlcoholException.class); + assertThatThrownBy( + () -> + service.validate( + new AdminAlcoholBulkRequest(Collections.nCopies(1001, new Row().build())))) + .isInstanceOf(AlcoholException.class); + assertThat( + service + .validate( + new AdminAlcoholBulkRequest(Arrays.asList((AdminAlcoholBulkRowRequest) null))) + .invalidRows()) + .isEqualTo(1); + } + + @Test + @DisplayName("1000개 행이 같은 참조를 쓸 때 참조를 한번씩 일괄 조회한다") + void 참조를_반복_조회하지_않는다() { + var rows = + IntStream.range(0, 1000) + .mapToObj( + i -> { + Row row = new Row(); + row.clientId = "row" + i; + row.tagIds = List.of(1L); + return row.build(); + }) + .toList(); + var result = service.validate(new AdminAlcoholBulkRequest(rows)); + assertThat(result.validRows()).isEqualTo(1000); + assertThat(regions.batchReads).isEqualTo(1); + assertThat(distilleries.batchReads).isEqualTo(1); + assertThat(tags.reads).isEqualTo(1); + assertThat(alcohols.bulkReads).isEqualTo(1); + } + + @Test + @DisplayName("비위스키 기존 데이터가 있을 때 그룹 도출과 중복 후보에 반영한다") + void 비위스키_기존_데이터도_참조한다() { + Alcohol existing = + alcohols.save( + Alcohol.builder() + .korName("새 Whisky") + .engName("New Whisky") + .type(AlcoholType.RUM) + .korCategory("싱글몰트") + .engCategory("Single Malts") + .categoryGroup(AlcoholCategoryGroup.BLEND) + .distillery(distilleries.findById(1L).orElseThrow()) + .abv("40") + .volume("0.7L") + .build()); + Row row = new Row(); + row.type = "RUM"; + row.korName = "새 Whisky"; + row.group = null; + var result = validate(row); + assertThat(result.normalized().categoryGroup()).isEqualTo("BLEND"); + assertThat(result.candidateAlcoholIds()).containsExactly(existing.getId()); + } + + @Test + @DisplayName("DB 후보가 100개를 넘을 때 목록을 제한하고 경고한다") + void 후보_응답_크기를_제한한다() { + for (int i = 0; i < 110; i++) existing(AlcoholCategoryGroup.SINGLE_MALT); + Row row = new Row(); + row.korName = "기존 위스키"; + var result = validate(row); + assertThat(result.candidateAlcoholIds()).hasSize(100); + assertThat(result.warnings()) + .extracting(AdminAlcoholBulkIssue::code) + .contains("CANDIDATES_TRUNCATED"); + } + + @Test + @DisplayName("같은 이름이라도 용량이나 도수 및 증류소가 다를 때 중복으로 경고하지 않는다") + void 다른_제품_규격은_중복이_아니다() { + existing(AlcoholCategoryGroup.SINGLE_MALT); + Row row = new Row(); + row.korName = "기존 위스키"; + row.volume = "1000ml"; + Row second = new Row(); + second.korName = row.korName; + second.clientId = "row2"; + second.abv = "43%"; + var result = service.validate(request(row, second)); + assertThat(result.rows()) + .allSatisfy( + value -> { + assertThat(value.candidateAlcoholIds()).isEmpty(); + assertThat(value.warnings()) + .extracting(AdminAlcoholBulkIssue::code) + .doesNotContain("DUPLICATE_REQUEST_ROW", "DUPLICATE_DB_CANDIDATE"); + }); + } + + private AdminAlcoholBulkRowResult validate(Row row) { + return service.validate(request(row)).rows().getFirst(); + } + + private AdminAlcoholBulkRequest request(Row... rows) { + return new AdminAlcoholBulkRequest(Arrays.stream(rows).map(Row::build).toList()); + } + + private Alcohol existing(AlcoholCategoryGroup group) { + return alcohols.save( + Alcohol.builder() + .korName("기존 위스키") + .engName("Existing whisky") + .type(AlcoholType.WHISKY) + .korCategory("싱글몰트") + .engCategory("Single Malts") + .categoryGroup(group) + .distillery(distilleries.findById(1L).orElseThrow()) + .abv("40") + .volume("700") + .build()); + } + + private static final class Row { + String clientId = "row1"; + String korName = " 새 위스키 "; + String engName = "New Whisky"; + String abv = "40%"; + String type = "WHISKY"; + String korCategory = "싱글몰트"; + String engCategory = "Single Malts"; + String group = "SINGLE_MALT"; + Long regionId = 1L; + Long distilleryId = 1L; + String age; + String cask; + String description; + String volume = "700ml"; + List tagIds; + String image; + + AdminAlcoholBulkRowRequest build() { + return new AdminAlcoholBulkRowRequest( + clientId, + korName, + engName, + abv, + type, + korCategory, + engCategory, + group, + regionId, + distilleryId, + age, + cask, + description, + volume, + tagIds, + image); + } + } + + private static final class CountingAlcoholRepository extends InMemoryAlcoholQueryRepository { + int bulkReads; + + @Override + public List findAllBulkReferenceItems() { + bulkReads++; + return super.findAllBulkReferenceItems(); + } + } + + private static final class CountingRegionRepository extends InMemoryRegionRepository { + int batchReads; + + @Override + public List findAllByIdInOrderBySortOrderAsc(Collection ids) { + batchReads++; + return super.findAllByIdInOrderBySortOrderAsc(ids); + } + } + + private static final class CountingDistilleryRepository extends InMemoryDistilleryRepository { + int batchReads; + + @Override + public List findAllByIdInOrderBySortOrderAsc(Collection ids) { + batchReads++; + return super.findAllByIdInOrderBySortOrderAsc(ids); + } + } + + private static final class CountingTagRepository extends InMemoryTastingTagRepository { + int reads; + + @Override + public List findAll() { + reads++; + return super.findAll(); + } + } +} diff --git a/bottlenote-test-support/src/main/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java b/bottlenote-test-support/src/main/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java index 12e5c75bf..3e85e3dbd 100644 --- a/bottlenote-test-support/src/main/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java +++ b/bottlenote-test-support/src/main/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java @@ -6,6 +6,7 @@ import app.bottlenote.alcohols.dto.dsl.ExploreStandardCriteria; import app.bottlenote.alcohols.dto.request.AdminAlcoholSearchRequest; import app.bottlenote.alcohols.dto.response.AdminAlcoholItem; +import app.bottlenote.alcohols.dto.response.AlcoholBulkReferenceItem; import app.bottlenote.alcohols.dto.response.AlcoholDetailItem; import app.bottlenote.alcohols.dto.response.AlcoholLookupItem; import app.bottlenote.alcohols.dto.response.CategoryItem; @@ -15,6 +16,7 @@ import app.bottlenote.global.pagination.KeysetPageResponse; import app.bottlenote.global.pagination.KeysetPagination; import java.time.Duration; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -129,6 +131,27 @@ public List findAllLookupItems() { .toList(); } + @Override + public List findAllBulkReferenceItems() { + return alcohols.values().stream() + .filter(a -> !a.isDeleted()) + .sorted(Comparator.comparing(Alcohol::getId)) + .map( + a -> + new AlcoholBulkReferenceItem( + a.getId(), + a.getKorName(), + a.getEngName(), + a.getKorCategory(), + a.getEngCategory(), + a.getCategoryGroup(), + a.getType(), + a.getDistillery() == null ? null : a.getDistillery().getId(), + a.getAbv(), + a.getVolume())) + .toList(); + } + @Override public List findAllMatchTargets() { return alcohols.values().stream() diff --git "a/plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" "b/plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" new file mode 100644 index 000000000..bf28323ce --- /dev/null +++ "b/plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" @@ -0,0 +1,116 @@ +# 알코올 벌크 등록 + +## Overview + +사용자가 승인한 기존 검증 개선을 구현하고 두 가지 경로를 제공한다: Excel 템플릿 생성 → 검증 → 벌크 저장, JSON 구조 데이터 → 검증 → 같은 벌크 저장. Excel 형식에 저장 서비스가 종속되지 않는다. 데이터 중복/카테고리 불일치의 정리는 사용자가 추후 수행하며 자동 병합하지 않는다. + +### Assumptions + +- Admin 인증 정책 안에서 현재 API 저장소를 수정한다. 외부 Admin Dashboard 신규 화면은 이번 API 작업 범위가 아니다. +- 최대 1,000행을 동기 처리하며 전체 사전 검증 후 하나의 트랜잭션으로 저장한다. 오류가 있으면 0건 저장한다. +- 이름/유효 enum/저장 가능한 값/참조 ID는 검증하고 중복 후보·새 category·type/category의 의미상 불일치는 경고로 반환한다. 기존 DB 데이터는 변경하지 않는다. +- age/cask/description/image는 선택이다. 도수/용량은 숫자와 명시 단위를 정규화한다. 범위/배치/세트 표현은 원문을 보존하고 경고한다. 명백한 숫자 범위 오류·단위 오류는 거절한다. +- 새로운 스키마·category 테이블·자동 병합·import session·멱등성 저장소·이미지 bytes 업로드는 추가하지 않는다. 반복 POST는 새 등록 요청이다. 이미지는 기존 업로드 viewUrl을 선택으로 전달한다. +- 저장 시 검증 결과의 valid 플래그를 신뢰하지 않고 전체 값을 재검증한다. + +### Success Criteria + +1. 실제 내려받은 Excel을 채워 validate한 normalized 행을 JSON bulk에 보내면 DB와 태그 매핑에 저장된다. +2. Excel 없이 동일 JSON을 validate한 뒤 bulk에 보내도 동일 결과를 얻는다. +3. optional 필드·단위·한글/영문 enum·그룹 자동 도출·중복 경고가 두 경로에서 일관된다. +4. 잘못된 enum/참조/도수/길이, 수식/외부 링크/크기 및 행 한도는 안정적인 오류를 반환하며 invalid bulk는 전혀 저장하지 않는다. +5. 생성 ID를 clientRowId와 대응시켜 반환하며 이미지 이벤트와 감사 기록을 보존한다. +6. GitHub Actions ci_pipeline의 unit/rule/product+admin integration/build가 최종 커밋에서 통과한다. + +### Impact Scope + +mono alcohol DTO/service, admin Excel/controller/OpenAPI/config, 해당 unit/HTTP/OpenAPI tests. 기존 공유 Repository 포트를 재사용하며 필요한 집합 조회만 추가할 수 있다. JPA entity와 migration은 변경하지 않는다. + +## Execution Mode + +- mode: delegated +- scope: plan, implement, test, self-review, commit, push, verify (GitHub Actions) +- authorization: 앞선 수정안에 대한 진행 승인과 2026.09.05 사용자 요청의 두 end-to-end 경로 및 orchestration/GitHub Actions 검증 지시를 반영한다. +- verification: 로컬에서는 컴파일·포맷 등 개발 피드백을 사용하고 최종 전체 검증은 작업 브랜치 workflow_dispatch로 수행한다. 사용자 지시에 따라 local full verify → push 순서는 push → Actions full verify로 대체한다. +- excluded: merge, release, deploy, 운영/개발 DB 쓰기, 외부 Dashboard 수정, PR 생성 +- stop-conditions: 가정 붕괴, verify 3회 반복 실패, scope 밖의 되돌리기 어려운 행동 + +## Public Contract + +- `GET /admin/api/v1/alcohols/excel/template`: 기존 템플릿 생성. +- `POST /admin/api/v1/alcohols/excel/validate`: 기존 응답을 유지하고 각 행에 `normalized` 공통 JSON 입력과 `clientRowId`를 추가한다. +- `POST /admin/api/v1/alcohols/bulk/validate`: `{rows:[AdminAlcoholBulkRowRequest]}`. 상태 변경 없이 행별 normalized/errors/warnings/candidateAlcoholIds와 집계 반환. +- `POST /admin/api/v1/alcohols/bulk`: 같은 body를 재검증 후 전체 저장. 성공 200 data.createdRows 및 data.rows(clientRowId,alcoholId), data.validation. invalid는 400 success=false errors에 검증 결과. +- clientRowId는 호출자 지정 식별자이고 요청 내 유일해야 한다. Excel은 원본 행 번호를 문자열로 사용한다. 수정/정렬 후에도 반환 normalized를 그대로 전달한다. +- row fields: clientRowId, korName, engName, abv, type(String), korCategory, engCategory, categoryGroup(String), regionId(Long), distilleryId(Long), age, cask, description, volume, tastingTagIds(List), imageUrl. +- normalized도 같은 row type이며 canonical enum names 및 %/ml 표기를 사용한다. type을 문자열로 받아 기존 AlcoholType의 unknown→WHISKY fallback을 거치지 않는다. +- errors가 없는 행만 normalized를 반환한다. 파일 내부/DB 중복, 중복 태그 제거, 비표준 범위/세트 표기, 기존 참조에 없는 category는 warning이며 저장 가능하다. +- 실제 category enum 자체가 잘못되거나 한글/영문 category가 비어 있으면 오류다. group 생략 시 정확히 하나인 category reference로 도출하고, non-WHISKY는 OTHER 기본값을 허용한다. 추론할 수 없는 WHISKY group은 오류다. +- Excel은 데이터 시트/헤더를 검사하되 안내문 텍스트·보조 시트 순서·추가 메모 시트는 허용한다. 첫 2행 구조를 유지하며 수식/외부 링크·미지원 데이터 열은 거절한다. + +## 입력 상세 + +| 입력 항목 | 적용 규칙 | +| --- | --- | +| rows | 1~1,000행이며 null 행은 거절한다. | +| clientRowId | 필수 문자열, 공백 정리 후 요청 내 유일하며 최대 255자다. | +| korName, engName | 각각 필수, 앞뒤 공백 정리 후 최대 255자다. | +| abv | 필수, 단일 값은 0~100이며 %로 정규화한다. 40, 40.00, 40%는 40%다. | +| volume | 필수 양수이며 ml로 정규화한다. 700, 700ml, 70cl, 0.7L는 700ml다. | +| type | WHISKY, RUM, VODKA, GIN, TEQUILA, BRANDY, BEER, WINE, ETC 또는 각 한글 표시값이다. 영문 대소문자와 앞뒤 공백을 정리한다. | +| korCategory, engCategory | 각각 필수, 최대 255자다. 새로운 조합과 기존 참조와의 의미상 불일치는 경고한다. | +| categoryGroup | SINGLE_MALT, BLEND, BLENDED_MALT, BOURBON, RYE, OTHER 또는 각 한글 표시값이다. 생략 시 위 계약에 따라 추론한다. | +| regionId, distilleryId | 각각 필수, 실제 존재하는 양수 Long ID다. JSON의 소수를 정수로 잘라서 사용하지 않는다. | +| age, cask | 선택, 공백은 null이며 최대 255자다. 임의의 대체 문자를 강제하지 않는다. | +| description | 선택, 공백은 null이며 MySQL TEXT에 맞춰 UTF-8 65,535바이트 이하다. | +| tastingTagIds | 선택, 최대 1,000개이며 존재하는 양수 Long ID만 허용한다. 중복 ID는 경고 후 한 번만 저장한다. | +| imageUrl | 선택, 최대 255자의 http/https URL이다. 기존 업로드 완료 후 받은 viewUrl을 사용한다. blob URL, 인증정보 포함 URL, 서명된 임시 업로드 URL은 거절한다. | + +도수/용량의 범위·배치·세트·주석은 해석 가능한 형태에 한해 원문을 보존하고 경고한다. 예를 들어 40~46%, 700ml x 2, batch A: 40% / batch B: 43%, 약60(배치마다상이)%, 1L(1000ml)을 허용한다. 숫자의 소수 자릿수를 임의로 반올림하지 않으며 101%, 0ml, 4,0 같은 잘못된 값은 거절한다. + +중복 후보는 한글 이름+증류소+도수+용량으로 판정한다. 이름은 Unicode NFKC와 공백·대소문자를 정리하고 수량 단위를 통일한다. 타입과 카테고리는 후보에서 제외하므로 잘못 분류된 기존 데이터도 나중에 사람이 비교할 수 있다. 후보 ID는 정렬해서 최대 100개를 반환하며 자동 병합·덮어쓰기는 하지 않는다. + +엑셀은 기존 오류 코드와 한글 필드명을 유지하며 공통 검증 결과를 변환한다. JSON은 필드 이름과 공통 오류 코드를 반환한다. 엑셀 응답의 각 rows[].normalized를 모아 JSON의 rows로 보내며, 이미지가 필요하면 업로드 완료한 imageUrl을 해당 행에 추가한다. + +## Tasks + +### Task 1: JSON 검증과 벌크 저장 +- Acceptance: 공통 DTO·서비스·정규화와 전체 저장, warning 정책 및 선택 필드. +- Verification: mono unit tests, Actions unit/rule/integration. +- Files (advisory): mono alcohol bulk DTO/service/normalizer/tests, AdminAlcoholUpsertRequest. +- Depends: 없음 +- Size: M (한 bulk 경로의 DTO 묶음 포함) +- Status: [x] 구현 완료, Actions 검증 대기 + +### Task 2: Excel을 공통 입력으로 연결 +- Acceptance: 기존 템플릿/응답 호환 유지, normalized 추가, 공통 검증 재사용, 과도한 형식 제약 완화와 파일 방어 보강. +- Verification: Excel unit tests, Actions admin integration. +- Files (advisory): AlcoholExcelSchema, AdminAlcoholExcelServiceImpl, validate response, Excel unit tests. +- Depends: Task 1의 공통 DTO 계약 +- Size: M +- Status: [ ] not done + +### Checkpoint: after Tasks 1-2 +- [ ] 공통 JSON 입력 round-trip 및 컴파일 확인 + +### Task 3: Admin endpoint와 두 경로의 HTTP 계약 +- Acceptance: Admin interface 기반 Controller, OpenAPI 및 인증, Excel/JSON→validate→bulk DB 저장/오류/경고 통합 테스트. +- Verification: Actions admin_integration_test 및 OpenAPI. +- Files (advisory): AdminAlcoholBulkController/docs, AdminAlcoholBulkIntegrationTest, application.yml/파일 예외 매핑. +- Depends: Task 1의 공통 DTO 계약 +- Size: M +- Status: [ ] not done + +### Task 4: 통합 리뷰와 CI 검증 +- Acceptance: 변경 범위 검토, 테스트·코드 결함 수정, 최종 SHA의 GitHub Actions 전체 통과. +- Verification: ci_pipeline.yml workflow_dispatch. +- Files (advisory): 본 계획과 필요한 수정 파일. +- Depends: Task 1,2,3 +- Size: S +- Status: [ ] not done + +## Progress Log + +- 2026.09.05: 앞선 읽기 전용 분석과 사용자의 구현 승인을 반영했다. 기존 worktree는 clean이며 source HEAD 30639b1340d698d3543a0c92807d34fdd803aed1이다. CI는 feature branch workflow_dispatch를 지원하며 개발 자동 배포는 main만 대상으로 하므로 CI 실행과 배포를 분리할 수 있다. + +- Task 1: 공통 DTO·정규화·전 주종 참조·전체 트랜잭션 저장·단위 테스트를 구현했다. self-review에서 WHISKY 한정 조회, 숫자 정밀도, 중복 식별 기준, 서명 URL을 보완했다. Java/Kotlin 테스트 컴파일과 포맷 검사 통과, 테스트 실행은 Actions에 대기 중이다. From ed25e49bf88acca3dd11b9918a99ca768335797b Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Sat, 5 Sep 2026 12:34:44 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=EC=97=91=EC=85=80=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=EC=9D=84=20=EA=B3=B5=ED=86=B5=20=EB=B2=8C=ED=81=AC=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=EC=97=90=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminAlcoholExcelValidateResponse.kt | 7 +- .../excel/AdminAlcoholExcelServiceImpl.kt | 580 +++++++----------- .../alcohols/excel/AlcoholExcelSchema.kt | 48 +- .../excel/AdminAlcoholExcelServiceTest.kt | 258 +++++++- ...4\355\201\254 \353\223\261\353\241\235.md" | 5 +- 5 files changed, 503 insertions(+), 395 deletions(-) diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/dto/response/AdminAlcoholExcelValidateResponse.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/dto/response/AdminAlcoholExcelValidateResponse.kt index 28d62ff16..425bd8089 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/dto/response/AdminAlcoholExcelValidateResponse.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/dto/response/AdminAlcoholExcelValidateResponse.kt @@ -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 = "알코올 엑셀 업로드 검증 결과") @@ -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?, @@ -44,7 +47,9 @@ data class AdminAlcoholExcelRowResult( val candidateAlcoholIds: List? = null, val valid: Boolean, val errors: List, - val warnings: List + val warnings: List, + @Schema(description = "오류가 없을 때만 반환하는 공통 벌크 정규화 행") + val normalized: AdminAlcoholBulkRowRequest? = null ) @Schema(name = "AdminAlcoholExcelIssue", description = "행 단위 오류 또는 경고") diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/excel/AdminAlcoholExcelServiceImpl.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/excel/AdminAlcoholExcelServiceImpl.kt index 838d4a0fb..56807d7cd 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/excel/AdminAlcoholExcelServiceImpl.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/excel/AdminAlcoholExcelServiceImpl.kt @@ -6,17 +6,24 @@ import app.bottlenote.alcohols.domain.AlcoholQueryRepository import app.bottlenote.alcohols.domain.DistilleryRepository import app.bottlenote.alcohols.domain.RegionRepository import app.bottlenote.alcohols.domain.TastingTagRepository +import app.bottlenote.alcohols.dto.request.AdminAlcoholBulkRequest +import app.bottlenote.alcohols.dto.request.AdminAlcoholBulkRowRequest +import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkIssue +import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkRowResult import app.bottlenote.alcohols.dto.response.AdminAlcoholExcelIssue import app.bottlenote.alcohols.dto.response.AdminAlcoholExcelRowResult import app.bottlenote.alcohols.dto.response.AdminAlcoholExcelValidateResponse import app.bottlenote.alcohols.dto.response.AdminDistilleryItem import app.bottlenote.alcohols.dto.response.AdminRegionItem +import app.bottlenote.alcohols.dto.response.AlcoholBulkReferenceItem import app.bottlenote.alcohols.dto.response.CategoryItem import app.bottlenote.alcohols.dto.response.TastingTagNodeItem import app.bottlenote.alcohols.excel.AlcoholExcelSchema.Column import app.bottlenote.alcohols.exception.AlcoholException import app.bottlenote.alcohols.exception.AlcoholExceptionCode -import app.bottlenote.alcohols.facade.payload.AlcoholMatchTargetItem +import app.bottlenote.alcohols.service.AdminAlcoholBulkService +import org.apache.poi.ooxml.POIXMLException +import org.apache.poi.openxml4j.exceptions.InvalidFormatException import org.apache.poi.openxml4j.opc.OPCPackage import org.apache.poi.openxml4j.util.ZipSecureFile import org.apache.poi.ss.usermodel.BorderStyle @@ -40,9 +47,8 @@ import org.springframework.stereotype.Service import org.springframework.web.multipart.MultipartFile import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream +import java.io.IOException import java.math.BigDecimal -import java.math.RoundingMode -import java.text.Normalizer import java.util.Locale import java.util.zip.ZipInputStream @@ -51,7 +57,8 @@ class AdminAlcoholExcelServiceImpl( private val regionRepository: RegionRepository, private val distilleryRepository: DistilleryRepository, private val tastingTagRepository: TastingTagRepository, - private val alcoholQueryRepository: AlcoholQueryRepository + private val alcoholQueryRepository: AlcoholQueryRepository, + private val adminAlcoholBulkService: AdminAlcoholBulkService ) : AdminAlcoholExcelService { companion object { private const val REFERENCE_PAGE_SIZE = 1_000 @@ -78,7 +85,7 @@ class AdminAlcoholExcelServiceImpl( val regions = loadRegions() val distilleries = loadDistilleries() val tags = loadTastingTags() - val categories = alcoholQueryRepository.findAllCategoryItems() + val categories = loadBulkReferenceCategories() writeGuideSheet(guideSheet, styles) writeReferenceSheet( @@ -145,49 +152,31 @@ class AdminAlcoholExcelServiceImpl( rejectUnsafeWorkbook(workbook) - val regionsById = loadRegions().associateBy { it.id } - val distilleriesById = loadDistilleries().associateBy { it.id } - val tagsById = loadTastingTags().associateBy { it.id } - val categories = alcoholQueryRepository.findAllCategoryItems() - val categoriesById = categories.associateBy { categoryStableId(it) } - val parsedRows = mutableListOf() - val lastRow = dataSheet.lastRowNum - for (rowIndex in AlcoholExcelSchema.DATA_START_ROW_INDEX..lastRow) { - val row = dataSheet.getRow(rowIndex) ?: continue + for (row in dataSheet) { + if (row.rowNum < AlcoholExcelSchema.DATA_START_ROW_INDEX) continue if (isCompletelyBlank(row)) continue - parsedRows += parseRow(rowIndex, row) + parsedRows += parseRow(row.rowNum, row) + if (parsedRows.size > AlcoholExcelSchema.MAX_DATA_ROWS) { + throw AlcoholException(AlcoholExceptionCode.EXCEL_ROW_LIMIT_EXCEEDED) + } } - if (parsedRows.size > AlcoholExcelSchema.MAX_DATA_ROWS) { - throw AlcoholException(AlcoholExceptionCode.EXCEL_ROW_LIMIT_EXCEEDED) + if (parsedRows.isEmpty()) { + return AdminAlcoholExcelValidateResponse(0, 0, 0, 0, emptyList()) } - val distilleryIds = parsedRows.mapNotNull { it.distilleryId.toLongOrNull() }.distinct() - val existingTargetsByIdentity = - alcoholQueryRepository - .findMatchTargetsByDistilleryIdIn(distilleryIds) - .groupBy(::normalizedIdentityKey) - .mapValues { (_, targets) -> targets.mapNotNull { it.alcoholId() } } - - // 파일 내부 중복은 정규화된 숫자 기준으로 판정한다. - val identityCounts = - parsedRows - .map { it to normalizedIdentityKey(it) } - .groupingBy { it.second } - .eachCount() - val results = - parsedRows.map { parsed -> - validateParsedRow( - parsed = parsed, - regionsById = regionsById, - distilleriesById = distilleriesById, - tagsById = tagsById, - categoriesById = categoriesById, - existingTargetsByIdentity = existingTargetsByIdentity, - identityCounts = identityCounts - ) - } + val adapters = parsedRows.map(::toBulkAdapterRow) + val commonRows = + adminAlcoholBulkService + .validate(AdminAlcoholBulkRequest(adapters.map { it.request })) + .rows() + .associateBy { it.clientRowId() } + val regionsById = loadRegions().associateBy { it.id } + val distilleriesById = loadDistilleries().associateBy { it.id } + val results = adapters.map { adapter -> + adaptResult(adapter, commonRows[adapter.request.clientRowId()], regionsById, distilleriesById) + } return AdminAlcoholExcelValidateResponse( totalRows = results.size, @@ -214,12 +203,23 @@ class AdminAlcoholExcelServiceImpl( } private fun openSecureWorkbook(bytes: ByteArray): Workbook { - preflightZip(bytes) var pkg: OPCPackage? = null return try { + preflightZip(bytes) pkg = OPCPackage.open(ByteArrayInputStream(bytes)) XSSFWorkbook(pkg) - } catch (_: Exception) { + } catch (exception: AlcoholException) { + throw exception + } catch (_: IOException) { + pkg?.close() + throw AlcoholException(AlcoholExceptionCode.EXCEL_INVALID_FILE_TYPE) + } catch (_: InvalidFormatException) { + pkg?.close() + throw AlcoholException(AlcoholExceptionCode.EXCEL_INVALID_FILE_TYPE) + } catch (_: POIXMLException) { + pkg?.close() + throw AlcoholException(AlcoholExceptionCode.EXCEL_INVALID_FILE_TYPE) + } catch (_: IllegalArgumentException) { pkg?.close() throw AlcoholException(AlcoholExceptionCode.EXCEL_INVALID_FILE_TYPE) } @@ -252,29 +252,27 @@ class AdminAlcoholExcelServiceImpl( } private fun validateWorkbookStructure(workbook: Workbook) { - val sheetNames = (0 until workbook.numberOfSheets).map(workbook::getSheetName) - if (sheetNames != AlcoholExcelSchema.SHEET_ORDER) { - throw AlcoholException(AlcoholExceptionCode.EXCEL_SHEET_NOT_FOUND) - } - AlcoholExcelSchema.SHEET_ORDER.forEach { name -> - if (workbook.getSheet(name) == null) throw AlcoholException(AlcoholExceptionCode.EXCEL_SHEET_NOT_FOUND) - } val dataSheet = workbook.getSheet(AlcoholExcelSchema.DATA_SHEET_NAME) + ?: throw AlcoholException(AlcoholExceptionCode.EXCEL_SHEET_NOT_FOUND) val headerRow = dataSheet.getRow(AlcoholExcelSchema.HEADER_ROW_INDEX) ?: throw AlcoholException(AlcoholExceptionCode.EXCEL_HEADER_MISMATCH) - val descriptionRow = - dataSheet.getRow(AlcoholExcelSchema.DESCRIPTION_ROW_INDEX) - ?: throw AlcoholException(AlcoholExceptionCode.EXCEL_DESCRIPTION_MISMATCH) + dataSheet.getRow(AlcoholExcelSchema.DESCRIPTION_ROW_INDEX) + ?: throw AlcoholException(AlcoholExceptionCode.EXCEL_DESCRIPTION_MISMATCH) val headers = AlcoholExcelSchema.HEADERS.indices.map { readRawCell(headerRow.getCell(it)) } if (headers != AlcoholExcelSchema.HEADERS) { if (headers.size != headers.distinct().size) throw AlcoholException(AlcoholExceptionCode.EXCEL_DUPLICATE_HEADER) throw AlcoholException(AlcoholExceptionCode.EXCEL_HEADER_MISMATCH) } - val descriptions = AlcoholExcelSchema.DESCRIPTIONS.indices.map { readRawCell(descriptionRow.getCell(it)) } - if (descriptions != AlcoholExcelSchema.DESCRIPTIONS) { - throw AlcoholException(AlcoholExceptionCode.EXCEL_DESCRIPTION_MISMATCH) + if (headerRow.any { it.columnIndex >= AlcoholExcelSchema.HEADERS.size && readRawCell(it).isNotBlank() }) { + throw AlcoholException(AlcoholExceptionCode.EXCEL_HEADER_MISMATCH) + } + for (row in dataSheet) { + if (row.rowNum < AlcoholExcelSchema.DATA_START_ROW_INDEX) continue + if (row.any { it.columnIndex >= AlcoholExcelSchema.HEADERS.size && readRawCell(it).isNotBlank() }) { + throw AlcoholException(AlcoholExceptionCode.EXCEL_HEADER_MISMATCH) + } } } @@ -284,10 +282,8 @@ class AdminAlcoholExcelServiceImpl( } for (sheetIndex in 0 until workbook.numberOfSheets) { val sheet = workbook.getSheetAt(sheetIndex) - for (rowIndex in 0..sheet.lastRowNum) { - val row = sheet.getRow(rowIndex) ?: continue - for (cellIndex in 0 until row.lastCellNum.coerceAtLeast(0)) { - val cell = row.getCell(cellIndex) ?: continue + for (row in sheet) { + for (cell in row) { if (cell.cellType == CellType.FORMULA || cell.cellType == CellType.ERROR) { throw AlcoholException(AlcoholExceptionCode.EXCEL_FORMULA_NOT_ALLOWED) } @@ -304,7 +300,7 @@ class AdminAlcoholExcelServiceImpl( rowIndex: Int, row: Row ): ParsedRow { - fun cell(column: Column): String = readRawCell(row.getCell(column.index)) + fun cell(column: Column): String = readRawCell(row.getCell(column.index), percentageFormatted = column == Column.ABV) return ParsedRow( rowNumber = rowIndex + 1, korName = cell(Column.KOR_NAME), @@ -323,286 +319,141 @@ class AdminAlcoholExcelServiceImpl( ) } - private fun validateParsedRow( - parsed: ParsedRow, - regionsById: Map, - distilleriesById: Map, - tagsById: Map, - categoriesById: Map, - existingTargetsByIdentity: Map>, - identityCounts: Map - ): AdminAlcoholExcelRowResult { + private fun toBulkAdapterRow(parsed: ParsedRow): BulkAdapterRow { val errors = mutableListOf() - val warnings = mutableListOf() - - fun requireValue( - value: String, - column: Column - ): String? { - if (value.isBlank()) { - errors += issue("REQUIRED_FIELD", column.header, "${column.header} 필드가 누락되었거나 비어 있습니다.") - return null - } - return value - } - - val korName = requireValue(parsed.korName, Column.KOR_NAME) - val engName = requireValue(parsed.engName, Column.ENG_NAME) - val abvRaw = requireValue(parsed.abv, Column.ABV) - val typeRaw = requireValue(parsed.type, Column.TYPE) - val categoryIdRaw = requireValue(parsed.categoryId, Column.CATEGORY_ID) - val categoryGroupRaw = requireValue(parsed.categoryGroup, Column.CATEGORY_GROUP) - val regionIdRaw = requireValue(parsed.regionId, Column.REGION_ID) - val distilleryIdRaw = requireValue(parsed.distilleryId, Column.DISTILLERY_ID) - val age = requireValue(parsed.age, Column.AGE) - val cask = requireValue(parsed.cask, Column.CASK) - val description = requireValue(parsed.description, Column.DESCRIPTION) - val volumeRaw = requireValue(parsed.volume, Column.VOLUME) - - val abvNormalized = abvRaw?.let { parseDecimal(it, Column.ABV, errors) } - val volumeNormalized = volumeRaw?.let { parseDecimal(it, Column.VOLUME, errors) } - val abvDisplay = abvNormalized?.let { formatWithSuffix(it, "%") } - val volumeDisplay = volumeNormalized?.let { formatWithSuffix(it, "ml") } - - var typeName: String? = null - if (typeRaw != null) { - val matchedType = - AlcoholType.entries.firstOrNull { - it.type == typeRaw.trim() || it.name == typeRaw.trim().uppercase(Locale.ROOT) - } - if (matchedType == null) { - errors += issue("INVALID_ENUM_VALUE", Column.TYPE.header, "${Column.TYPE.header} 필드가 잘못 입력되었습니다. 허용된 한글 값을 입력하세요.") - } else { - typeName = matchedType.name - } - } - - var categoryGroupName: String? = null - var matchedCategoryGroup: AlcoholCategoryGroup? = null - if (categoryGroupRaw != null) { - matchedCategoryGroup = - AlcoholCategoryGroup.entries.firstOrNull { - it.description == categoryGroupRaw.trim() || it.name == categoryGroupRaw.trim().uppercase(Locale.ROOT) - } - if (matchedCategoryGroup == null) { - errors += - issue( - "INVALID_ENUM_VALUE", - Column.CATEGORY_GROUP.header, - "${Column.CATEGORY_GROUP.header} 필드가 잘못 입력되었습니다. 허용된 한글 값을 입력하세요." - ) - } else { - categoryGroupName = matchedCategoryGroup.name - } - } - - var korCategory: String? = null - var engCategory: String? = null - if (categoryIdRaw != null) { - val stableId = categoryIdRaw.trim() - if (stableId.isBlank()) { - errors += issue("INVALID_ID", Column.CATEGORY_ID.header, "${Column.CATEGORY_ID.header} 필드가 잘못 입력되었습니다. 참조 시트의 ID를 입력하세요.") - } else { - val category = categoriesById[stableId] - if (category == null) { - errors += issue("CATEGORY_NOT_FOUND", Column.CATEGORY_ID.header, "카테고리 ID를 찾을 수 없습니다: $stableId") - } else { - korCategory = category.korCategory() - engCategory = category.engCategory() - if (matchedCategoryGroup != null && category.categoryGroup() != matchedCategoryGroup) { - errors += - issue( - "CATEGORY_GROUP_MISMATCH", - Column.CATEGORY_GROUP.header, - "카테고리 ID와 카테고리 그룹이 일치하지 않습니다: ID=$stableId, 그룹=${matchedCategoryGroup.description}" - ) - } else if (matchedCategoryGroup == null && category.categoryGroup() != null) { - matchedCategoryGroup = category.categoryGroup() - categoryGroupName = matchedCategoryGroup?.name - } - } - } - } - - var regionId: Long? = null - var regionName: String? = null - if (regionIdRaw != null) { - val parsedId = parseLongId(regionIdRaw, Column.REGION_ID, errors) - if (parsedId != null) { - val region = regionsById[parsedId] - if (region == null) { - errors += issue("REGION_NOT_FOUND", Column.REGION_ID.header, "지역 ID를 찾을 수 없습니다: $parsedId") - } else { - regionId = parsedId - regionName = region.korName() - } - } - } - - var distilleryId: Long? = null - var distilleryName: String? = null - if (distilleryIdRaw != null) { - val parsedId = parseLongId(distilleryIdRaw, Column.DISTILLERY_ID, errors) - if (parsedId != null) { - val distillery = distilleriesById[parsedId] - if (distillery == null) { - errors += issue("DISTILLERY_NOT_FOUND", Column.DISTILLERY_ID.header, "증류소 ID를 찾을 수 없습니다: $parsedId") - } else { - distilleryId = parsedId - distilleryName = distillery.korName() - } - } - } + val categoryParts = parsed.categoryId.trim().split("|").map(String::trim) + val validCategory = categoryParts.size == 3 && categoryParts.all(String::isNotBlank) + if (!validCategory) { + errors += issue("INVALID_ID", Column.CATEGORY_ID.header, "카테고리 ID는 그룹|한글|영문 형식으로 입력해야 합니다.") + } + val categoryGroup = parsed.categoryGroup.ifBlank { categoryParts.getOrNull(0).orEmpty() } + val regionId = parseInputId(parsed.regionId, Column.REGION_ID, errors) + val distilleryId = parseInputId(parsed.distilleryId, Column.DISTILLERY_ID, errors) + val tastingTagIds = parseTastingTagIds(parsed.tastingTagIds, errors) + return BulkAdapterRow( + parsed = parsed, + request = AdminAlcoholBulkRowRequest( + parsed.rowNumber.toString(), + parsed.korName, + parsed.engName, + parsed.abv, + parsed.type, + categoryParts.getOrNull(1), + categoryParts.getOrNull(2), + categoryGroup, + regionId, + distilleryId, + parsed.age.ifBlank { null }, + parsed.cask.ifBlank { null }, + parsed.description.ifBlank { null }, + parsed.volume, + tastingTagIds, + null + ), + errors = errors, + warnings = emptyList() + ) + } - val tastingTagIds = mutableListOf() - if (parsed.tastingTagIds.isNotBlank()) { - val rawIds = - parsed.tastingTagIds - .split("|") - .map { it.trim() } - .filter { it.isNotEmpty() } - val seen = mutableSetOf() - rawIds.forEach { raw -> - val id = parseLongId(raw, Column.TASTING_TAG_IDS, errors) ?: return@forEach - if (!seen.add(id)) { - errors += issue("DUPLICATE_TASTING_TAG", Column.TASTING_TAG_IDS.header, "중복된 테이스팅 태그 ID입니다: $id") - return@forEach - } - val tag = tagsById[id] - if (tag == null) { - errors += issue("TASTING_TAG_NOT_FOUND", Column.TASTING_TAG_IDS.header, "테이스팅 태그 ID를 찾을 수 없습니다: $id") + private fun adaptResult( + adapter: BulkAdapterRow, + common: AdminAlcoholBulkRowResult?, + regionsById: Map, + distilleriesById: Map + ): AdminAlcoholExcelRowResult { + val commonErrors = + common?.errors().orEmpty().map(::adaptIssue).ifEmpty { + if (common == null) { + listOf(issue("BULK_ROW_RESULT_MISSING", null, "공통 검증 결과에서 행을 찾을 수 없습니다.")) } else { - tastingTagIds += id + emptyList() } } - } - - if (identityCounts[normalizedIdentityKey(parsed)]?.let { it > 1 } == true) { - errors += - issue( - "DUPLICATE_IN_FILE", - null, - "파일 내부에 동일한 식별 조합(이름·증류소·도수·용량)이 중복됩니다: ${parsed.korName}/${parsed.distilleryId}/${parsed.abv}/${parsed.volume}" - ) - } - - val candidateIds = - if (korName != null && distilleryId != null && abvNormalized != null && volumeNormalized != null) { - existingTargetsByIdentity[ - IdentityKey( - normalizeIdentity(korName), - distilleryId.toString(), - normalizeNumericIdentity(abvNormalized), - normalizeNumericIdentity(volumeNormalized) - ) - ].orEmpty() - } else { - emptyList() - } - if (candidateIds.isNotEmpty()) { - warnings += - issue( - "DUPLICATE_CANDIDATE", - null, - "이미 등록된 위스키입니다 이름=$korName, 증류소ID=$distilleryId, 도수=$abvDisplay, 용량=$volumeDisplay, 후보ID=${candidateIds.joinToString(",")}" - ) - } - + val commonWarnings = common?.warnings().orEmpty().map(::adaptIssue) + val errors = adapter.errors + commonErrors + val normalized = common?.normalized()?.takeIf { errors.isEmpty() && common.valid() } return AdminAlcoholExcelRowResult( - rowNumber = parsed.rowNumber, - korName = korName ?: parsed.korName.ifBlank { null }, - engName = engName ?: parsed.engName.ifBlank { null }, - abv = abvDisplay ?: abvRaw, - type = typeName ?: typeRaw, - korCategory = korCategory, - engCategory = engCategory, - categoryGroup = categoryGroupName ?: categoryGroupRaw, - region = regionName, - distillery = distilleryName, - age = age ?: parsed.age.ifBlank { null }, - cask = cask ?: parsed.cask.ifBlank { null }, - description = description ?: parsed.description.ifBlank { null }, - volume = volumeDisplay ?: volumeRaw, - tastingTags = parsed.tastingTagIds.ifBlank { null }, - regionId = regionId, - distilleryId = distilleryId, - tastingTagIds = tastingTagIds.takeIf { it.isNotEmpty() }, - candidateAlcoholIds = candidateIds.takeIf { it.isNotEmpty() }, - valid = errors.isEmpty(), + rowNumber = adapter.parsed.rowNumber, + clientRowId = adapter.request.clientRowId(), + korName = normalized?.korName() ?: adapter.parsed.korName.ifBlank { null }, + engName = normalized?.engName() ?: adapter.parsed.engName.ifBlank { null }, + abv = normalized?.abv() ?: adapter.parsed.abv.ifBlank { null }, + type = normalized?.type() ?: adapter.parsed.type.ifBlank { null }, + korCategory = normalized?.korCategory() ?: adapter.request.korCategory(), + engCategory = normalized?.engCategory() ?: adapter.request.engCategory(), + categoryGroup = normalized?.categoryGroup() ?: adapter.request.categoryGroup(), + region = normalized?.regionId()?.let { regionsById[it]?.korName() }, + distillery = normalized?.distilleryId()?.let { distilleriesById[it]?.korName() }, + age = normalized?.age() ?: adapter.request.age(), + cask = normalized?.cask() ?: adapter.request.cask(), + description = normalized?.description() ?: adapter.request.description(), + volume = normalized?.volume() ?: adapter.parsed.volume.ifBlank { null }, + tastingTags = adapter.parsed.tastingTagIds.ifBlank { null }, + regionId = normalized?.regionId() ?: adapter.request.regionId(), + distilleryId = normalized?.distilleryId() ?: adapter.request.distilleryId(), + tastingTagIds = normalized?.tastingTagIds() ?: adapter.request.tastingTagIds(), + candidateAlcoholIds = common?.candidateAlcoholIds()?.takeIf { it.isNotEmpty() }, + valid = errors.isEmpty() && common?.valid() == true, errors = errors, - warnings = warnings + warnings = adapter.warnings + commonWarnings, + normalized = normalized ) } - private fun parseDecimal( - raw: String, - column: Column, - errors: MutableList - ): BigDecimal? { - val cleaned = raw.trim().replace(",", "") - if (!cleaned.matches(Regex("""^\d+(\.\d{1,2})?$"""))) { - errors += - issue( - "INVALID_NUMBER", - column.header, - "${column.header} 필드가 잘못 입력되었습니다. 숫자만 입력하고 소수 2자리까지 허용됩니다." - ) + private fun parseInputId(raw: String, column: Column, errors: MutableList): Long? { + if (raw.isBlank()) return null + if (!raw.trim().matches(Regex("""^\d+$"""))) { + errors += issue("INVALID_ID", column.header, "${column.header} 필드는 Long 범위의 숫자여야 합니다.") return null } - return BigDecimal(cleaned).setScale(2, RoundingMode.UNNECESSARY) + return raw.trim().toLongOrNull() ?: run { + errors += issue("INVALID_ID", column.header, "${column.header} 필드는 Long 범위의 숫자여야 합니다.") + null + } } - private fun parseLongId( - raw: String, - column: Column, - errors: MutableList - ): Long? { - val cleaned = raw.trim() - if (!cleaned.matches(Regex("""^\d+$"""))) { - errors += - issue( - "INVALID_ID", - column.header, - "${column.header} 필드가 잘못 입력되었습니다. 참조 시트의 ID 숫자를 입력하세요." - ) - return null + private fun parseTastingTagIds(raw: String, errors: MutableList): List? { + if (raw.isBlank()) return emptyList() + return raw.split("|").mapNotNull { value -> + parseInputId(value.trim(), Column.TASTING_TAG_IDS, errors) } - return cleaned.toLongOrNull() - ?: run { - errors += - issue( - "INVALID_ID", - column.header, - "${column.header} 필드가 잘못 입력되었습니다. Long 범위의 참조 ID를 입력하세요." - ) - null - } } - private fun formatWithSuffix( - value: BigDecimal, - suffix: String - ): String = value.stripTrailingZeros().toPlainString() + suffix - - private fun stripUnit(value: String?): String = value - .orEmpty() - .trim() - .replace("%", "", ignoreCase = true) - .replace("ml", "", ignoreCase = true) - .trim() - - private fun normalizeNumericIdentity(value: String?): String { - val cleaned = stripUnit(value) - if (cleaned.isBlank()) return "" - return runCatching { - BigDecimal(cleaned).setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString() - }.getOrElse { normalizeIdentity(cleaned) } + private fun adaptIssue(issue: AdminAlcoholBulkIssue): AdminAlcoholExcelIssue = AdminAlcoholExcelIssue(excelIssueCode(issue), excelFieldName(issue.field()), issue.message()) + + private fun excelIssueCode(issue: AdminAlcoholBulkIssue): String = when (issue.code()) { + "REQUIRED" -> "REQUIRED_FIELD" + "INVALID_QUANTITY" -> "INVALID_NUMBER" + "INVALID_ENUM" -> "INVALID_ENUM_VALUE" + "INVALID_REFERENCE" -> + when (issue.field()) { + "regionId" -> "REGION_NOT_FOUND" + "distilleryId" -> "DISTILLERY_NOT_FOUND" + "tastingTagIds" -> "TASTING_TAG_NOT_FOUND" + else -> "INVALID_ID" + } + "DUPLICATE_TAG_REMOVED" -> "DUPLICATE_TASTING_TAG" + "DUPLICATE_REQUEST_ROW" -> "DUPLICATE_IN_FILE" + "DUPLICATE_DB_CANDIDATE" -> "DUPLICATE_CANDIDATE" + else -> issue.code() } - private fun normalizeNumericIdentity(value: BigDecimal?): String { - if (value == null) return "" - return value.setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString() - } + private fun excelFieldName(field: String?): String? = mapOf( + "korName" to Column.KOR_NAME.header, + "engName" to Column.ENG_NAME.header, + "abv" to Column.ABV.header, + "type" to Column.TYPE.header, + "korCategory" to Column.CATEGORY_ID.header, + "engCategory" to Column.CATEGORY_ID.header, + "categoryGroup" to Column.CATEGORY_GROUP.header, + "regionId" to Column.REGION_ID.header, + "distilleryId" to Column.DISTILLERY_ID.header, + "age" to Column.AGE.header, + "cask" to Column.CASK.header, + "description" to Column.DESCRIPTION.header, + "volume" to Column.VOLUME.header, + "tastingTagIds" to Column.TASTING_TAG_IDS.header + )[field] ?: field private fun writeHeaderAndDescription( sheet: Sheet, @@ -658,7 +509,7 @@ class AdminAlcoholExcelServiceImpl( val lines = listOf( "BottleNote 알코올 일괄 등록 템플릿", - "1페이지(이 시트)는 설명·예제·오류 코드입니다. 실제 입력은 마지막 시트 '알코올 데이터'에만 작성하세요.", + "이 시트는 설명·예제·오류 코드입니다. 실제 입력은 '알코올 데이터' 시트에만 작성하세요.", "", "[시트 구성]", "1. 사용 안내: 설명, 예제, 오류 코드", @@ -666,14 +517,16 @@ class AdminAlcoholExcelServiceImpl( "3. 증류소: ID / 한글 이름 / 영문 이름", "4. 테이스팅 태그: ID / 한글 이름 / 영문 이름", "5. 카테고리: ID / 카테고리 그룹 / 한글 카테고리 / 영문 카테고리", - "6. 알코올 데이터: 실제 입력 시트(마지막 고정)", + "6. 알코올 데이터: 실제 입력 시트", "", "[입력 규칙]", - "- 주류 종류, 카테고리 그룹만 한글 enum 값을 입력합니다.", - "- 지역/증류소/테이스팅 태그/카테고리는 참조 시트의 ID를 입력합니다.", - "- 도수와 용량은 숫자만 입력합니다. 소수 2자리까지 허용되며 서버가 % / ml를 붙입니다.", - "- 설명(디스크립션)은 필수입니다.", + "- 주류 종류와 카테고리 그룹은 한글 표시값 또는 enum 이름을 입력합니다.", + "- 카테고리 ID는 그룹|한글|영문 형식입니다. 카테고리 그룹은 비워 두면 ID의 그룹을 자동 사용합니다.", + "- 지역/증류소/테이스팅 태그는 ID를 입력합니다. 참조 시트는 안내용이므로 삭제하거나 순서를 바꿔도 됩니다.", + "- 도수는 % 표기를, 용량은 ml·cl·L 표기를 허용합니다. 숫자 셀의 퍼센트 서식도 지원합니다.", + "- 숙성 연도, 캐스크, 설명은 선택입니다.", "- 테이스팅 태그 ID는 여러 개일 때 | 로 구분합니다. 예: 1|3", + "- 파일 내부 중복과 기존 등록 후보는 경고(WARN)로 반환합니다.", "- 이미지는 이 템플릿에 포함되지 않습니다.", "- 수식 셀과 외부 링크는 허용되지 않습니다.", "", @@ -797,13 +650,17 @@ class AdminAlcoholExcelServiceImpl( private fun isCompletelyBlank(row: Row): Boolean = AlcoholExcelSchema.HEADERS.indices.all { index -> readRawCell(row.getCell(index)).isBlank() } - private fun readRawCell(cell: Cell?): String { + private fun readRawCell(cell: Cell?, percentageFormatted: Boolean = false): String { if (cell == null) return "" return when (cell.cellType) { CellType.STRING -> cell.stringCellValue?.trim().orEmpty() CellType.NUMERIC -> { - val value = cell.numericCellValue - if (value == value.toLong().toDouble()) value.toLong().toString() else value.toString() + val value = BigDecimal.valueOf(cell.numericCellValue).stripTrailingZeros().toPlainString() + if (percentageFormatted && hasPercentageFormat(cell.cellStyle.dataFormatString)) { + BigDecimal(value).multiply(BigDecimal(100)).stripTrailingZeros().toPlainString() + "%" + } else { + value + } } CellType.BOOLEAN -> cell.booleanCellValue.toString() CellType.BLANK -> "" @@ -825,6 +682,33 @@ class AdminAlcoholExcelServiceImpl( return listOf(group, kor, eng).joinToString("|") } + private fun loadBulkReferenceCategories(): List = alcoholQueryRepository + .findAllBulkReferenceItems() + .mapNotNull(::toCategoryItem) + .distinctBy(::categoryStableId) + + private fun toCategoryItem(item: AlcoholBulkReferenceItem): CategoryItem? { + val group = item.categoryGroup() ?: return null + val korCategory = item.korCategory()?.takeIf(String::isNotBlank) ?: return null + val engCategory = item.engCategory()?.takeIf(String::isNotBlank) ?: return null + return CategoryItem(korCategory, engCategory, group) + } + + private fun hasPercentageFormat(format: String?): Boolean { + if (format.isNullOrEmpty()) return false + var quoted = false + var index = 0 + while (index < format.length) { + when (format[index]) { + '"' -> quoted = !quoted + '\\' -> index++ + '%' -> if (!quoted) return true + } + index++ + } + return false + } + private fun loadRegions(): List { val items = mutableListOf() var pageNumber = 0 @@ -858,28 +742,6 @@ class AdminAlcoholExcelServiceImpl( return items } - private fun normalizeIdentity(value: String?): String { - if (value.isNullOrBlank()) return "" - val nfkc = Normalizer.normalize(value.trim(), Normalizer.Form.NFKC) - return nfkc.lowercase(Locale.ROOT).replace(Regex("\\s+"), " ") - } - - private fun normalizedIdentityKey(parsed: ParsedRow): IdentityKey = IdentityKey( - normalizeIdentity(parsed.korName), - normalizeIdIdentity(parsed.distilleryId), - normalizeNumericIdentity(parsed.abv), - normalizeNumericIdentity(parsed.volume) - ) - - private fun normalizeIdIdentity(value: String): String = value.trim().toLongOrNull()?.toString() ?: normalizeIdentity(value) - - private fun normalizedIdentityKey(target: AlcoholMatchTargetItem): IdentityKey = IdentityKey( - normalizeIdentity(target.korName()), - target.distilleryId()?.toString().orEmpty(), - normalizeNumericIdentity(target.abv()), - normalizeNumericIdentity(target.volume()) - ) - private data class ParsedRow( val rowNumber: Int, val korName: String, @@ -897,11 +759,11 @@ class AdminAlcoholExcelServiceImpl( val tastingTagIds: String ) - private data class IdentityKey( - val name: String, - val distilleryId: String, - val abv: String, - val volume: String + private data class BulkAdapterRow( + val parsed: ParsedRow, + val request: AdminAlcoholBulkRowRequest, + val errors: List, + val warnings: List ) private class TemplateStyles( diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/excel/AlcoholExcelSchema.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/excel/AlcoholExcelSchema.kt index 0ec549e74..9f6ac6793 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/excel/AlcoholExcelSchema.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/excel/AlcoholExcelSchema.kt @@ -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 = "사용 안내" @@ -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)" ) @@ -106,26 +109,25 @@ 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", "필수 시트가 없거나 시트명이 올바르지 않습니다."), diff --git a/bottlenote-admin-api/src/test/kotlin/app/bottlenote/alcohols/excel/AdminAlcoholExcelServiceTest.kt b/bottlenote-admin-api/src/test/kotlin/app/bottlenote/alcohols/excel/AdminAlcoholExcelServiceTest.kt index fd99ebe50..41fd84ad1 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/bottlenote/alcohols/excel/AdminAlcoholExcelServiceTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/bottlenote/alcohols/excel/AdminAlcoholExcelServiceTest.kt @@ -6,12 +6,19 @@ import app.bottlenote.alcohols.domain.Alcohol import app.bottlenote.alcohols.domain.Distillery import app.bottlenote.alcohols.domain.Region import app.bottlenote.alcohols.domain.TastingTag +import app.bottlenote.alcohols.dto.request.AdminAlcoholBulkRequest +import app.bottlenote.alcohols.dto.request.AdminAlcoholBulkRowRequest +import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkCreateResponse +import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkIssue +import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkRowResult +import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkValidateResponse import app.bottlenote.alcohols.exception.AlcoholException import app.bottlenote.alcohols.exception.AlcoholExceptionCode import app.bottlenote.alcohols.fixture.InMemoryAlcoholQueryRepository import app.bottlenote.alcohols.fixture.InMemoryDistilleryRepository import app.bottlenote.alcohols.fixture.InMemoryRegionRepository import app.bottlenote.alcohols.fixture.InMemoryTastingTagRepository +import app.bottlenote.alcohols.service.AdminAlcoholBulkService import org.apache.poi.common.usermodel.HyperlinkType import org.apache.poi.ss.usermodel.IndexedColors import org.apache.poi.ss.usermodel.WorkbookFactory @@ -27,6 +34,7 @@ import org.springframework.mock.web.MockMultipartFile import org.springframework.test.util.ReflectionTestUtils import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream +import java.math.BigDecimal import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream @@ -37,6 +45,7 @@ class AdminAlcoholExcelServiceTest { private lateinit var distilleryRepository: InMemoryDistilleryRepository private lateinit var tastingTagRepository: InMemoryTastingTagRepository private lateinit var alcoholQueryRepository: InMemoryAlcoholQueryRepository + private lateinit var bulkService: FakeAdminAlcoholBulkService private lateinit var service: AdminAlcoholExcelService private lateinit var region: Region @@ -50,12 +59,14 @@ class AdminAlcoholExcelServiceTest { distilleryRepository = InMemoryDistilleryRepository() tastingTagRepository = InMemoryTastingTagRepository() alcoholQueryRepository = InMemoryAlcoholQueryRepository() + bulkService = FakeAdminAlcoholBulkService() service = AdminAlcoholExcelServiceImpl( regionRepository, distilleryRepository, tastingTagRepository, - alcoholQueryRepository + alcoholQueryRepository, + bulkService ) region = @@ -91,6 +102,39 @@ class AdminAlcoholExcelServiceTest { @Nested @DisplayName("템플릿 생성") inner class Template { + @Test + @DisplayName("카테고리 참조 시트는 모든 주류 타입의 유효한 카테고리를 중복 없이 제공한다") + fun template_includesAllAlcoholTypeCategories() { + alcoholQueryRepository.save( + Alcohol.builder() + .korName("__rum_category_seed__") + .engName("__rum_category_seed__") + .abv("40%") + .type(AlcoholType.RUM) + .korCategory("럼") + .engCategory("Rum") + .categoryGroup(AlcoholCategoryGroup.OTHER) + .region(region) + .distillery(distillery) + .age("-") + .cask("-") + .description("rum category seed") + .volume("700ml") + .build() + ) + + WorkbookFactory.create(ByteArrayInputStream(service.createTemplateWorkbook())).use { workbook -> + val categorySheet = workbook.getSheet(AlcoholExcelSchema.CATEGORY_SHEET_NAME) + val categoryIds = + (1..categorySheet.lastRowNum).map { rowIndex -> + categorySheet.getRow(rowIndex).getCell(0).stringCellValue + } + + assertThat(categoryIds).contains("SINGLE_MALT|싱글 몰트|Single Malt", "OTHER|럼|Rum") + assertThat(categoryIds).doesNotHaveDuplicates() + } + } + @Test @DisplayName("1페이지 사용 안내·ID 참조 시트·마지막 입력 시트와 헤더 스타일을 가진다") fun template_hasFixedStructureWithoutImageColumn() { @@ -164,6 +208,7 @@ class AdminAlcoholExcelServiceTest { assertThat(result.validRows).isEqualTo(1) val row = result.rows[0] assertThat(row.valid).isTrue() + assertThat(row.clientRowId).isEqualTo("3") assertThat(row.abv).isEqualTo("40%") assertThat(row.volume).isEqualTo("700ml") assertThat(row.regionId).isEqualTo(region.id) @@ -172,11 +217,12 @@ class AdminAlcoholExcelServiceTest { assertThat(row.description).isEqualTo("스페이사이드 대표 싱글몰트") assertThat(row.korCategory).isEqualTo("싱글 몰트") assertThat(row.engCategory).isEqualTo("Single Malt") + assertThat(row.normalized?.clientRowId()).isEqualTo("3") } @Test - @DisplayName("도수/용량에 기호가 있으면 INVALID_NUMBER 다") - fun validate_whenAbvHasSymbol_returnsInvalidNumber() { + @DisplayName("도수와 용량의 단위 표기를 공통 요청에 그대로 전달한다") + fun validate_whenAbvAndVolumeHaveUnits_delegatesToBulkService() { val file = workbookAsMultipart { workbook -> writeDataRow( @@ -200,7 +246,9 @@ class AdminAlcoholExcelServiceTest { } val result = service.validate(file) - assertThat(result.rows[0].errors.map { it.code }).contains("INVALID_NUMBER") + assertThat(result.rows[0].valid).isTrue() + assertThat(bulkService.receivedRequests.single().rows().single().abv()).isEqualTo("40%") + assertThat(bulkService.receivedRequests.single().rows().single().volume()).isEqualTo("700ml") } @Test @@ -233,7 +281,7 @@ class AdminAlcoholExcelServiceTest { } @Test - @DisplayName("파일 내부 중복은 오류, 기존 DB 강한 중복은 warning 이다") + @DisplayName("파일 내부 중복과 기존 후보는 공통 검증의 warning 을 보존한다") fun validate_duplicateInFileIsError_andDbMatchIsWarning() { alcoholQueryRepository.findAll().toList().forEach { alcohol -> if (alcohol.korName == "__category_seed__") { @@ -281,7 +329,7 @@ class AdminAlcoholExcelServiceTest { } val result = service.validate(file) - assertThat(result.rows).allMatch { row -> row.errors.any { it.code == "DUPLICATE_IN_FILE" } } + assertThat(result.rows).allMatch { row -> row.warnings.any { it.code == "DUPLICATE_IN_FILE" } } assertThat(result.rows).allMatch { row -> row.warnings.any { it.code == "DUPLICATE_CANDIDATE" } } assertThat(result.rows[0].warnings[0].message).contains("이미 등록된 위스키입니다") } @@ -323,7 +371,7 @@ class AdminAlcoholExcelServiceTest { } @Test - @DisplayName("40과 40.00은 파일 내부 중복으로 본다") + @DisplayName("40과 40.00은 파일 내부 중복 warning 으로 본다") fun validate_whenAbvScaleDiffers_isDuplicateInFile() { val file = workbookAsMultipart { workbook -> @@ -368,11 +416,11 @@ class AdminAlcoholExcelServiceTest { } val result = service.validate(file) - assertThat(result.rows).allMatch { row -> row.errors.any { it.code == "DUPLICATE_IN_FILE" } } + assertThat(result.rows).allMatch { row -> row.warnings.any { it.code == "DUPLICATE_IN_FILE" } } } @Test - @DisplayName("01과 1 증류소 ID는 파일 내부 중복에서 같은 ID로 본다") + @DisplayName("01과 1 증류소 ID는 파일 내부 중복 warning 에서 같은 ID로 본다") fun validate_whenDistilleryIdHasLeadingZero_isDuplicateInFile() { val file = workbookAsMultipart { workbook -> @@ -388,7 +436,7 @@ class AdminAlcoholExcelServiceTest { val result = service.validate(file) - assertThat(result.rows).allMatch { row -> row.errors.any { it.code == "DUPLICATE_IN_FILE" } } + assertThat(result.rows).allMatch { row -> row.warnings.any { it.code == "DUPLICATE_IN_FILE" } } } @Test @@ -401,6 +449,18 @@ class AdminAlcoholExcelServiceTest { .isEqualTo(AlcoholExceptionCode.EXCEL_INVALID_FILE_TYPE) } + @Test + @DisplayName("손상된 xlsx ZIP은 파일 형식 오류로 반환한다") + fun validate_whenXlsxZipIsCorrupt_returnsInvalidFileType() { + val file = + MockMultipartFile("file", "corrupt.xlsx", AlcoholExcelSchema.XLSX_CONTENT_TYPE, "not-a-zip".toByteArray()) + + assertThatThrownBy { service.validate(file) } + .isInstanceOf(AlcoholException::class.java) + .extracting("exceptionCode") + .isEqualTo(AlcoholExceptionCode.EXCEL_INVALID_FILE_TYPE) + } + @Test @DisplayName("5MB를 초과한 파일은 EXCEL_FILE_TOO_LARGE 예외를 반환한다") fun validate_whenFileExceedsLimit_rejectsFile() { @@ -497,6 +557,112 @@ class AdminAlcoholExcelServiceTest { val result = service.validate(file) assertThat(result.rows.single().errors.map { it.code }).contains("INVALID_ID") + assertThat(result.rows.single().valid).isFalse() + assertThat(result.rows.single().normalized).isNull() + } + + @Test + @DisplayName("카테고리 안정 키의 그룹과 별도 그룹이 다르면 입력한 그룹을 사용하고 warning 을 반환한다") + fun validate_whenCategoryGroupDiffers_usesExplicitGroupWithWarning() { + val file = + workbookAsMultipart { workbook -> + writeDataRow( + workbook, + validRowValues().toMutableList().apply { + set(AlcoholExcelSchema.Column.CATEGORY_GROUP.index, "BLEND") + } + ) + } + + val result = service.validate(file) + + assertThat(result.rows.single().warnings.map { it.code }).contains("CATEGORY_GROUP_MISMATCH") + assertThat(bulkService.receivedRequests.single().rows().single().categoryGroup()).isEqualTo("BLEND") + } + + @Test + @DisplayName("빈 템플릿은 공통 서비스 호출 없이 0행 결과를 반환한다") + fun validate_whenTemplateIsEmpty_returnsZeroRows() { + val result = service.validate(workbookAsMultipart { }) + + assertThat(result.totalRows).isZero() + assertThat(bulkService.receivedRequests).isEmpty() + } + + @Test + @DisplayName("시트 순서와 참조 시트 변경은 허용하지만 데이터 열 추가는 거절한다") + fun validate_whenSheetOrderChanges_acceptsButExtraDataColumnRejects() { + val reordered = + workbookAsMultipart { workbook -> + workbook.setSheetOrder(AlcoholExcelSchema.DATA_SHEET_NAME, 0) + workbook.removeSheetAt(workbook.getSheetIndex(AlcoholExcelSchema.REGION_SHEET_NAME)) + workbook.createSheet("메모") + writeDataRow(workbook, validRowValues()) + } + assertThat(service.validate(reordered).validRows).isEqualTo(1) + + val withExtraColumn = + workbookAsMultipart { workbook -> + writeDataRow(workbook, validRowValues()) + workbook + .getSheet(AlcoholExcelSchema.DATA_SHEET_NAME) + .getRow(2) + .createCell(13) + .setCellValue("지원하지 않음") + } + assertThatThrownBy { service.validate(withExtraColumn) } + .isInstanceOf(AlcoholException::class.java) + .extracting("exceptionCode") + .isEqualTo(AlcoholExceptionCode.EXCEL_HEADER_MISMATCH) + } + + @Test + @DisplayName("퍼센트 서식 숫자 셀은 퍼센트 값으로, 일반 숫자 셀은 원래 값으로 공통 요청에 전달한다") + fun validate_whenNumericPercentCell_convertsOnlyPercentageFormat() { + val file = + workbookAsMultipart { workbook -> + writeDataRow(workbook, validRowValues()) + val row = workbook.getSheet(AlcoholExcelSchema.DATA_SHEET_NAME).getRow(2) + row.getCell(AlcoholExcelSchema.Column.ABV.index).setCellValue(0.4) + val percentageStyle = + workbook.createCellStyle().apply { + dataFormat = workbook.createDataFormat().getFormat("0%") + } + row.getCell(AlcoholExcelSchema.Column.ABV.index).cellStyle = percentageStyle + } + + service.validate(file) + + assertThat(bulkService.receivedRequests.single().rows().single().abv()).isEqualTo("40%") + + val literalPercent = + workbookAsMultipart { workbook -> + writeDataRow(workbook, validRowValues()) + val row = workbook.getSheet(AlcoholExcelSchema.DATA_SHEET_NAME).getRow(2) + row.getCell(AlcoholExcelSchema.Column.ABV.index).setCellValue(40.0) + val literalPercentStyle = + workbook.createCellStyle().apply { + dataFormat = workbook.createDataFormat().getFormat("0\\%") + } + row.getCell(AlcoholExcelSchema.Column.ABV.index).cellStyle = literalPercentStyle + } + val literalResult = service.validate(literalPercent) + + assertThat(bulkService.receivedRequests[1].rows().single().abv()).isEqualTo("40") + assertThat(literalResult.rows.single().abv).isEqualTo("40%") + + val generalNumber = + workbookAsMultipart { workbook -> + writeDataRow(workbook, validRowValues()) + workbook + .getSheet(AlcoholExcelSchema.DATA_SHEET_NAME) + .getRow(2) + .getCell(AlcoholExcelSchema.Column.ABV.index) + .setCellValue(0.4) + } + service.validate(generalNumber) + + assertThat(bulkService.receivedRequests[2].rows().single().abv()).isEqualTo("0.4") } @Test @@ -601,4 +767,76 @@ class AdminAlcoholExcelServiceTest { "700.00", tagOak.id.toString() ) + + private class FakeAdminAlcoholBulkService : AdminAlcoholBulkService { + val receivedRequests = mutableListOf() + + override fun validate(request: AdminAlcoholBulkRequest): AdminAlcoholBulkValidateResponse { + receivedRequests += request + val duplicateCounts = request.rows().groupingBy(::identity).eachCount() + val rows = request.rows().map { row -> + val errors = mutableListOf() + val warnings = mutableListOf() + if (row.regionId() == 999999L) { + errors += AdminAlcoholBulkIssue("REGION_NOT_FOUND", "regionId", "지역 ID를 찾을 수 없습니다.") + } + if ((duplicateCounts[identity(row)] ?: 0) > 1) { + warnings += AdminAlcoholBulkIssue("DUPLICATE_IN_FILE", null, "파일 내부 중복 후보입니다.") + } + if (row.categoryGroup() == "BLEND") { + warnings += AdminAlcoholBulkIssue("CATEGORY_GROUP_MISMATCH", "categoryGroup", "기존 카테고리와 그룹이 다릅니다.") + } + val candidateIds = + if (row.korName() == "글렌피딕 12년" && row.volume().startsWith("700")) listOf(777L) else emptyList() + if (candidateIds.isNotEmpty()) { + warnings += AdminAlcoholBulkIssue("DUPLICATE_CANDIDATE", null, "기존 등록 후보입니다.") + } + val normalized = if (errors.isEmpty()) normalize(row) else null + AdminAlcoholBulkRowResult(row.clientRowId(), errors.isEmpty(), normalized, errors, warnings, candidateIds) + } + return AdminAlcoholBulkValidateResponse( + rows.size, + rows.count { it.valid() }, + rows.count { !it.valid() }, + rows.count { it.warnings().isNotEmpty() }, + rows + ) + } + + override fun create(request: AdminAlcoholBulkRequest): AdminAlcoholBulkCreateResponse { + val validation = validate(request) + return AdminAlcoholBulkCreateResponse(0, emptyList(), validation) + } + + private fun normalize(row: AdminAlcoholBulkRowRequest): AdminAlcoholBulkRowRequest = AdminAlcoholBulkRowRequest( + row.clientRowId(), + row.korName(), + row.engName(), + normalizeNumber(row.abv(), "%"), + if (row.type() == "위스키") "WHISKY" else row.type(), + row.korCategory(), + row.engCategory(), + row.categoryGroup(), + row.regionId(), + row.distilleryId(), + row.age(), + row.cask(), + row.description(), + normalizeNumber(row.volume(), "ml"), + row.tastingTagIds(), + row.imageUrl() + ) + + private fun normalizeNumber(value: String, suffix: String): String { + val number = value.removeSuffix("%").removeSuffix("ml").trim() + return BigDecimal(number).stripTrailingZeros().toPlainString() + suffix + } + + private fun identity(row: AdminAlcoholBulkRowRequest): String = listOf( + row.korName(), + row.distilleryId(), + normalizeNumber(row.abv(), "%"), + normalizeNumber(row.volume(), "ml") + ).joinToString("|") + } } diff --git "a/plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" "b/plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" index bf28323ce..8856b7d5d 100644 --- "a/plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" +++ "b/plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" @@ -88,10 +88,10 @@ mono alcohol DTO/service, admin Excel/controller/OpenAPI/config, 해당 unit/HTT - Files (advisory): AlcoholExcelSchema, AdminAlcoholExcelServiceImpl, validate response, Excel unit tests. - Depends: Task 1의 공통 DTO 계약 - Size: M -- Status: [ ] not done +- Status: [x] 구현 완료, Actions 검증 대기 ### Checkpoint: after Tasks 1-2 -- [ ] 공통 JSON 입력 round-trip 및 컴파일 확인 +- [x] 공통 JSON 입력 계약과 컴파일 확인, 실제 round-trip은 Actions 검증 대기 ### Task 3: Admin endpoint와 두 경로의 HTTP 계약 - Acceptance: Admin interface 기반 Controller, OpenAPI 및 인증, Excel/JSON→validate→bulk DB 저장/오류/경고 통합 테스트. @@ -114,3 +114,4 @@ mono alcohol DTO/service, admin Excel/controller/OpenAPI/config, 해당 unit/HTT - 2026.09.05: 앞선 읽기 전용 분석과 사용자의 구현 승인을 반영했다. 기존 worktree는 clean이며 source HEAD 30639b1340d698d3543a0c92807d34fdd803aed1이다. CI는 feature branch workflow_dispatch를 지원하며 개발 자동 배포는 main만 대상으로 하므로 CI 실행과 배포를 분리할 수 있다. - Task 1: 공통 DTO·정규화·전 주종 참조·전체 트랜잭션 저장·단위 테스트를 구현했다. self-review에서 WHISKY 한정 조회, 숫자 정밀도, 중복 식별 기준, 서명 URL을 보완했다. Java/Kotlin 테스트 컴파일과 포맷 검사 통과, 테스트 실행은 Actions에 대기 중이다. +- Task 2: Excel을 공통 검증 어댑터로 연결했다. 선택 필드·시트 순서·안내문 제약을 완화하고 전 주종 카테고리·퍼센트 서식·파일 파싱 예외를 보완했다. 기존 오류 코드와 응답 필드를 유지하며 clientRowId와 normalized를 추가했다. From 06fc34deb7237f77569eb430ab319958fefb0a97 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Sat, 5 Sep 2026 12:35:02 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EB=B2=8C=ED=81=AC=20API=EC=99=80=20=EC=99=95=EB=B3=B5=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminAlcoholBulkController.kt | 40 ++ .../AdminAlcoholExcelExceptionHandler.kt | 23 + .../docs/AdminAlcoholBulkApiDocs.kt | 75 +++ .../src/main/resources/application.yml | 4 + .../AdminAlcoholBulkIntegrationTest.kt | 562 ++++++++++++++++++ ...coholBulkOpenApiContractIntegrationTest.kt | 101 ++++ .../openapi/OpenApiDocsIntegrationTest.kt | 10 +- ...4\355\201\254 \353\223\261\353\241\235.md" | 4 +- 8 files changed, 813 insertions(+), 6 deletions(-) create mode 100644 bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminAlcoholBulkController.kt create mode 100644 bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminAlcoholExcelExceptionHandler.kt create mode 100644 bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/docs/AdminAlcoholBulkApiDocs.kt create mode 100644 bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholBulkIntegrationTest.kt create mode 100644 bottlenote-admin-api/src/test/kotlin/app/integration/openapi/AlcoholBulkOpenApiContractIntegrationTest.kt diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminAlcoholBulkController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminAlcoholBulkController.kt new file mode 100644 index 000000000..0189b9e6f --- /dev/null +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminAlcoholBulkController.kt @@ -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.ok(adminAlcoholBulkService.validate(request)) + + @PostMapping + @AdminAlcoholBulkApiDocs.CreateBulk + fun create( + @RequestBody @Valid request: AdminAlcoholBulkRequest + ): ResponseEntity { + val result = adminAlcoholBulkService.create(request) + return if (result.validation().invalidRows() > 0) { + ResponseEntity.badRequest().body(GlobalResponse.fail(result.validation())) + } else { + GlobalResponse.ok(result) + } + } +} diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminAlcoholExcelExceptionHandler.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminAlcoholExcelExceptionHandler.kt new file mode 100644 index 000000000..347406047 --- /dev/null +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminAlcoholExcelExceptionHandler.kt @@ -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.error(AlcoholException(AlcoholExceptionCode.EXCEL_FILE_TOO_LARGE)) + + @ExceptionHandler(MultipartException::class, MissingServletRequestPartException::class) + fun handleInvalidFile(): ResponseEntity = GlobalResponse.error(AlcoholException(AlcoholExceptionCode.EXCEL_INVALID_FILE_TYPE)) +} diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/docs/AdminAlcoholBulkApiDocs.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/docs/AdminAlcoholBulkApiDocs.kt new file mode 100644 index 000000000..7086b74c1 --- /dev/null +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/docs/AdminAlcoholBulkApiDocs.kt @@ -0,0 +1,75 @@ +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(schema = Schema(implementation = ValidateEnvelope::class))]) + @ApiResponse(responseCode = "400", description = "잘못된 JSON, 빈 목록 또는 최대 행 수 초과", content = [Content(schema = Schema(implementation = RequestFailureEnvelope::class))]) + @ApiResponse(responseCode = "401", description = "관리자 인증이 없는 경우") + 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(schema = Schema(implementation = CreateEnvelope::class))]) + @ApiResponse(responseCode = "400", description = "행 검증 실패 또는 잘못된 요청 목록", content = [Content(schema = Schema(oneOf = [ValidationFailureEnvelope::class, RequestFailureEnvelope::class]))]) + @ApiResponse(responseCode = "401", description = "관리자 인증이 없는 경우") + annotation class CreateBulk + + @Schema(name = "AlcoholBulkValidateEnvelope") + data class ValidateEnvelope( + val success: Boolean, + val code: Int, + val data: AdminAlcoholBulkValidateResponse, + val errors: List = emptyList(), + val meta: Map = emptyMap() + ) + + @Schema(name = "AlcoholBulkCreateEnvelope") + data class CreateEnvelope( + val success: Boolean, + val code: Int, + val data: AdminAlcoholBulkCreateResponse, + val errors: List = emptyList(), + val meta: Map = emptyMap() + ) + + @Schema(name = "AlcoholBulkValidationFailureEnvelope") + data class ValidationFailureEnvelope( + val success: Boolean, + val code: Int, + val data: List = emptyList(), + val errors: AdminAlcoholBulkValidateResponse, + val meta: Map = emptyMap() + ) + + @Schema(name = "AlcoholBulkRequestFailureEnvelope") + data class RequestFailureEnvelope( + val success: Boolean, + val code: Int, + val data: List = emptyList(), + val errors: List, + val meta: Map = emptyMap() + ) +} diff --git a/bottlenote-admin-api/src/main/resources/application.yml b/bottlenote-admin-api/src/main/resources/application.yml index 7ea20f8a4..863619a54 100644 --- a/bottlenote-admin-api/src/main/resources/application.yml +++ b/bottlenote-admin-api/src/main/resources/application.yml @@ -30,6 +30,10 @@ server: connection-timeout: 5000 spring: + servlet: + multipart: + max-file-size: 5MB + max-request-size: 6MB profiles: include: - datasource diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholBulkIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholBulkIntegrationTest.kt new file mode 100644 index 000000000..f566c65ae --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholBulkIntegrationTest.kt @@ -0,0 +1,562 @@ +package app.integration.alcohols + +import app.IntegrationTestSupport +import app.bottlenote.alcohols.constant.AlcoholCategoryGroup +import app.bottlenote.alcohols.constant.AlcoholType +import app.bottlenote.alcohols.domain.Alcohol +import app.bottlenote.alcohols.domain.Distillery +import app.bottlenote.alcohols.domain.Region +import app.bottlenote.alcohols.excel.AlcoholExcelSchema +import app.bottlenote.alcohols.fixture.AlcoholTestFactory +import app.bottlenote.alcohols.fixture.TastingTagTestFactory +import app.bottlenote.common.file.event.payload.ImageResourceActivatedEvent +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ObjectNode +import org.apache.poi.ss.usermodel.WorkbookFactory +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.ApplicationListener +import org.springframework.context.ConfigurableApplicationContext +import org.springframework.context.PayloadApplicationEvent +import org.springframework.http.MediaType +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.mock.web.MockMultipartFile +import org.springframework.test.web.servlet.assertj.MvcTestResult +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.concurrent.atomic.AtomicInteger + +@Tag("admin_integration") +@DisplayName("[integration] Admin Alcohol Bulk API 통합 테스트") +class AdminAlcoholBulkIntegrationTest : IntegrationTestSupport() { + @Autowired + private lateinit var alcoholTestFactory: AlcoholTestFactory + + @Autowired + private lateinit var tastingTagTestFactory: TastingTagTestFactory + + @Autowired + private lateinit var jdbcTemplate: JdbcTemplate + + @Autowired + private lateinit var applicationContext: ConfigurableApplicationContext + + private lateinit var accessToken: String + + private var adminId: Long = 0 + + @BeforeEach + fun setUp() { + val admin = adminUserTestFactory.persistRootAdmin() + adminId = admin.id + accessToken = getAccessToken(admin) + } + + @Nested + @DisplayName("엑셀 검증 결과를 등록할 때") + inner class ExcelRoundTrip { + @Test + @DisplayName("다운로드한 템플릿의 normalized 행을 벌크 등록하면 실제 필드와 태그가 저장된다") + fun downloadedTemplateCanBeValidatedAndSaved() { + val region = alcoholTestFactory.persistRegion() + val distillery = alcoholTestFactory.persistDistillery() + val tag1 = tastingTagTestFactory.persistTastingTag("바닐라", "Vanilla") + val tag2 = tastingTagTestFactory.persistTastingTag("피트", "Peat") + alcoholTestFactory.persistAlcoholWithCategory("싱글 몰트", "Single Malt") + val template = downloadTemplate() + val workbookBytes = + fillTemplate( + template, + listOf( + "벌크 엑셀 위스키", + "Bulk Excel Whisky", + "46.50", + "위스키", + "SINGLE_MALT|싱글 몰트|Single Malt", + "싱글몰트 위스키", + region.id.toString(), + distillery.id.toString(), + "15", + "Sherry Oak", + "엑셀 왕복 등록 설명", + "750.00", + "${tag1.id}|${tag2.id}" + ) + ) + val validateResult = validateExcel(workbookBytes) + + assertThat(validateResult).hasStatusOk() + val validation = responseData(validateResult) + assertThat(validation.path("validRows").asInt()).isEqualTo(1) + val normalized = validation.at("/rows/0/normalized") + assertThat(normalized.isObject).isTrue() + assertThat(normalized.path("clientRowId").asText()).isEqualTo("3") + + val createResult = postBulk(listOf(normalized)) + + assertThat(createResult).hasStatusOk() + val created = responseData(createResult) + assertThat(created.path("createdRows").asInt()).isEqualTo(1) + assertThat(created.at("/rows/0/clientRowId").asText()).isEqualTo("3") + val alcoholId = created.at("/rows/0/alcoholId").asLong() + assertStoredAlcohol( + alcoholId = alcoholId, + korName = "벌크 엑셀 위스키", + engName = "Bulk Excel Whisky", + abv = "46.5%", + regionId = region.id, + distilleryId = distillery.id, + age = "15", + cask = "Sherry Oak", + description = "엑셀 왕복 등록 설명", + volume = "750ml", + tastingTagIds = listOf(tag1.id, tag2.id) + ) + } + + @Test + @DisplayName("엑셀 업로드 파일이 누락되면 400을 반환한다") + fun missingExcelUploadReturnsBadRequest() { + val result = + mockMvcTester.perform( + multipart("/v1/alcohols/excel/validate") + .header("Authorization", "Bearer $accessToken") + ) + + assertThat(result).hasStatus(400) + assertThat(responseJson(result).path("success").asBoolean()).isFalse() + } + } + + @Nested + @DisplayName("JSON 검증 결과를 등록할 때") + inner class JsonRoundTrip { + @Test + @DisplayName("직접 작성한 JSON의 normalized 행을 벌크 등록하면 정규화된 값이 저장된다") + fun directJsonCanBeValidatedAndSaved() { + val region = alcoholTestFactory.persistRegion() + val distillery = alcoholTestFactory.persistDistillery() + val tag = tastingTagTestFactory.persistTastingTag("오크", "Oak") + alcoholTestFactory.persistAlcoholWithCategory("싱글 몰트", "Single Malt") + val request = + validRow( + clientRowId = "json-1", + korName = "직접 JSON 위스키", + engName = "Direct JSON Whisky", + regionId = region.id, + distilleryId = distillery.id, + tastingTagIds = listOf(tag.id) + ).apply { + this["type"] = "위스키" + this["categoryGroup"] = null + this["abv"] = "40.00" + this["volume"] = "70cl" + this.remove("age") + this.remove("cask") + this.remove("description") + } + val validateResult = postValidate(listOf(mapper.valueToTree(request))) + + assertThat(validateResult).hasStatusOk() + val normalized = responseData(validateResult).at("/rows/0/normalized") + assertThat(normalized.path("type").asText()).isEqualTo("WHISKY") + assertThat(normalized.path("categoryGroup").asText()).isEqualTo("SINGLE_MALT") + assertThat(normalized.path("abv").asText()).isEqualTo("40%") + assertThat(normalized.path("volume").asText()).isEqualTo("700ml") + + val createResult = postBulk(listOf(normalized)) + + assertThat(createResult).hasStatusOk() + val alcoholId = responseData(createResult).at("/rows/0/alcoholId").asLong() + assertStoredAlcohol( + alcoholId = alcoholId, + korName = "직접 JSON 위스키", + engName = "Direct JSON Whisky", + abv = "40%", + regionId = region.id, + distilleryId = distillery.id, + age = null, + cask = null, + description = null, + volume = "700ml", + tastingTagIds = listOf(tag.id) + ) + } + + @Test + @DisplayName("검증된 두 번째 행을 변조하면 재검증에서 거절하고 한 건도 저장하지 않는다") + fun tamperedNormalizedRowDoesNotSaveAnyAlcohol() { + val region = alcoholTestFactory.persistRegion() + val distillery = alcoholTestFactory.persistDistillery() + alcoholTestFactory.persistAlcoholWithCategory("싱글 몰트", "Single Malt") + val requestRows = + listOf( + mapper.valueToTree( + validRow("atomic-1", "원자적 등록 하나", "Atomic One", region.id, distillery.id) + ), + mapper.valueToTree( + validRow("atomic-2", "원자적 등록 둘", "Atomic Two", region.id, distillery.id) + ) + ) + val validateResult = postValidate(requestRows) + assertThat(validateResult).hasStatusOk() + val validation = responseData(validateResult) + assertThat(validation.path("invalidRows").asInt()).isZero() + val first = validation.at("/rows/0/normalized").deepCopy() + val second = validation.at("/rows/1/normalized").deepCopy().put("regionId", UNKNOWN_ID) + val countBefore = alcoholCount() + + val createResult = postBulk(listOf(first, second)) + + assertThat(createResult).hasStatus(400) + val response = responseJson(createResult) + assertThat(response.path("success").asBoolean()).isFalse() + assertThat(response.at("/errors/invalidRows").asInt()).isEqualTo(1) + assertThat(response.at("/errors/rows/1/errors").map { it.path("code").asText() }) + .contains("INVALID_REFERENCE") + assertThat(alcoholCount()).isEqualTo(countBefore) + } + + @Test + @DisplayName("DB 중복 경고가 있는 서로 다른 행은 모두 새 ID로 등록한다") + fun duplicateCandidateWarningsStillCreateDistinctAlcohols() { + val region = alcoholTestFactory.persistRegion() + val distillery = alcoholTestFactory.persistDistillery() + val existing1 = persistDuplicateCandidate("중복 후보 하나", "Duplicate Candidate One", region, distillery) + val existing2 = persistDuplicateCandidate("중복 후보 둘", "Duplicate Candidate Two", region, distillery) + val requestRows = + listOf( + mapper.valueToTree( + validRow("warning-1", existing1.korName, existing1.engName, region.id, distillery.id) + ), + mapper.valueToTree( + validRow("warning-2", existing2.korName, existing2.engName, region.id, distillery.id) + ) + ) + val validateResult = postValidate(requestRows) + assertThat(validateResult).hasStatusOk() + val validation = responseData(validateResult) + assertThat(validation.path("invalidRows").asInt()).isZero() + assertThat(validation.path("warningRows").asInt()).isEqualTo(2) + assertThat(validation.path("rows").map { it.path("warnings").map { warning -> warning.path("code").asText() } }) + .allSatisfy { codes -> assertThat(codes).contains("DUPLICATE_DB_CANDIDATE") } + val normalizedRows = validation.path("rows").map { it.path("normalized") } + val countBefore = alcoholCount() + + val createResult = postBulk(normalizedRows) + + assertThat(createResult).hasStatusOk() + val created = responseData(createResult) + assertThat(created.path("createdRows").asInt()).isEqualTo(2) + val createdIds = created.path("rows").map { it.path("alcoholId").asLong() } + assertThat(createdIds).doesNotHaveDuplicates() + assertThat(createdIds).doesNotContain(existing1.id, existing2.id) + assertThat(created.path("validation").path("warningRows").asInt()).isEqualTo(2) + assertThat(alcoholCount()).isEqualTo(countBefore + 2) + } + + @Test + @DisplayName("두 번째 이미지 이벤트 발행 중 예외가 발생하면 모든 저장을 롤백한다") + fun runtimeFailureDuringSecondRowRollsBackAllAlcohols() { + val region = alcoholTestFactory.persistRegion() + val distillery = alcoholTestFactory.persistDistillery() + alcoholTestFactory.persistAlcoholWithCategory("싱글 몰트", "Single Malt") + val rows = + listOf( + validRow("rollback-1", "롤백 등록 하나", "Rollback One", region.id, distillery.id).apply { + this["imageUrl"] = "https://cdn.bottlenote.com/alcohol/rollback-one.jpg" + }, + validRow("rollback-2", "롤백 등록 둘", "Rollback Two", region.id, distillery.id).apply { + this["imageUrl"] = "https://cdn.bottlenote.com/alcohol/rollback-two.jpg" + } + ) + val countBefore = alcoholCount() + val imageEvents = AtomicInteger() + val listener = + ApplicationListener> { event -> + if (event.payload is ImageResourceActivatedEvent && imageEvents.incrementAndGet() == 2) { + throw IllegalStateException("두 번째 이미지 이벤트 테스트 실패") + } + } + applicationContext.addApplicationListener(listener) + + try { + val result = postJson("/v1/alcohols/bulk", mapOf("rows" to rows)) + + assertThat(result).hasStatus(500) + assertThat(imageEvents.get()).isEqualTo(2) + assertThat(alcoholCount()).isEqualTo(countBefore) + } finally { + applicationContext.removeApplicationListener(listener) + } + } + } + + @Nested + @DisplayName("입력 경계를 검증할 때") + inner class RequestValidation { + @Test + @DisplayName("중복 clientRowId는 각 행의 오류로 반환한다") + fun duplicateClientRowIdsAreRejected() { + val region = alcoholTestFactory.persistRegion() + val distillery = alcoholTestFactory.persistDistillery() + alcoholTestFactory.persistAlcoholWithCategory("싱글 몰트", "Single Malt") + val rows = + listOf( + mapper.valueToTree( + validRow("same-row", "중복 행 하나", "Duplicate Row One", region.id, distillery.id) + ), + mapper.valueToTree( + validRow("same-row", "중복 행 둘", "Duplicate Row Two", region.id, distillery.id) + ) + ) + + val result = postValidate(rows) + + assertThat(result).hasStatusOk() + val validation = responseData(result) + assertThat(validation.path("invalidRows").asInt()).isEqualTo(2) + assertThat(validation.path("rows").map { it.path("errors").map { error -> error.path("code").asText() } }) + .allSatisfy { codes -> assertThat(codes).contains("DUPLICATE_CLIENT_ROW_ID") } + } + + @Test + @DisplayName("빈 rows 요청은 Bean Validation 오류로 400을 반환한다") + fun emptyRowsReturnBadRequest() { + val result = postJson("/v1/alcohols/bulk/validate", mapOf("rows" to emptyList())) + + assertThat(result).hasStatus(400) + assertThat(responseJson(result).path("success").asBoolean()).isFalse() + } + + @Test + @DisplayName("1,000행을 초과한 rows 요청은 Bean Validation 오류로 400을 반환한다") + fun moreThanOneThousandRowsReturnBadRequest() { + val oversizedRows = List(1001) { index -> mapOf("clientRowId" to "row-$index") } + + val result = postJson("/v1/alcohols/bulk/validate", mapOf("rows" to oversizedRows)) + + assertThat(result).hasStatus(400) + assertThat(responseJson(result).path("success").asBoolean()).isFalse() + } + + @Test + @DisplayName("잘못된 enum·참조·수량은 행 오류로 반환하고 normalized를 노출하지 않는다") + fun invalidBusinessFieldsReturnRowErrors() { + val row = + validRow("invalid-1", "잘못된 입력", "Invalid Input", UNKNOWN_ID, UNKNOWN_ID).apply { + this["type"] = "UNKNOWN_TYPE" + this["categoryGroup"] = "UNKNOWN_GROUP" + this["abv"] = "101%" + this["volume"] = "0ml" + this["tastingTagIds"] = listOf(UNKNOWN_ID) + } + + val result = postValidate(listOf(mapper.valueToTree(row))) + + assertThat(result).hasStatusOk() + val rowResult = responseData(result).at("/rows/0") + assertThat(rowResult.path("valid").asBoolean()).isFalse() + assertThat(rowResult.path("normalized").isNull).isTrue() + assertThat(rowResult.path("errors").map { it.path("code").asText() }) + .contains("INVALID_ENUM", "INVALID_QUANTITY", "INVALID_REFERENCE") + } + + @ParameterizedTest(name = "{0}에 소수 ID를 보내면 400을 반환한다") + @ValueSource(strings = ["regionId", "tastingTagIds"]) + @DisplayName("참조 ID에 소수를 보내면 JSON 역직렬화 단계에서 거절한다") + fun decimalReferenceIdsReturnBadRequest(field: String) { + val row = + if (field == "regionId") { + mapOf(field to 1.5) + } else { + mapOf(field to listOf(1.5)) + } + + val result = postJson("/v1/alcohols/bulk/validate", mapOf("rows" to listOf(row))) + + assertThat(result).hasStatus(400) + assertThat(responseJson(result).path("success").asBoolean()).isFalse() + } + + @ParameterizedTest(name = "인증 없이 {0}을 호출하면 401을 반환한다") + @ValueSource(strings = ["/v1/alcohols/bulk/validate", "/v1/alcohols/bulk"]) + @DisplayName("인증이 없으면 벌크 엔드포인트는 401을 반환한다") + fun unauthenticatedRequestsReturnUnauthorized(path: String) { + val result = + mockMvcTester + .post() + .uri(path) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"rows\":[]}") + .exchange() + + assertThat(result).hasStatus(401) + } + } + + private fun downloadTemplate(): ByteArray = mockMvcTester + .get() + .uri("/v1/alcohols/excel/template") + .header("Authorization", "Bearer $accessToken") + .exchange() + .response.contentAsByteArray + + private fun fillTemplate( + template: ByteArray, + values: List + ): ByteArray { + val output = ByteArrayOutputStream() + WorkbookFactory.create(ByteArrayInputStream(template)).use { workbook -> + val sheet = workbook.getSheet(AlcoholExcelSchema.DATA_SHEET_NAME) + val row = sheet.createRow(AlcoholExcelSchema.DATA_START_ROW_INDEX) + values.forEachIndexed { index, value -> row.createCell(index).setCellValue(value) } + workbook.write(output) + } + return output.toByteArray() + } + + private fun validateExcel(workbookBytes: ByteArray): MvcTestResult { + val file = + MockMultipartFile( + "file", + "filled-alcohol-import.xlsx", + AlcoholExcelSchema.XLSX_CONTENT_TYPE, + workbookBytes + ) + return mockMvcTester.perform( + multipart("/v1/alcohols/excel/validate") + .file(file) + .header("Authorization", "Bearer $accessToken") + ) + } + + private fun postValidate(rows: List): MvcTestResult = postJson("/v1/alcohols/bulk/validate", mapOf("rows" to rows)) + + private fun postBulk(rows: List): MvcTestResult = postJson("/v1/alcohols/bulk", mapOf("rows" to rows)) + + private fun postJson( + path: String, + body: Any + ): MvcTestResult = mockMvcTester + .post() + .uri(path) + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(body)) + .exchange() + + private fun responseJson(result: MvcTestResult): JsonNode = mapper.readTree(result.response.contentAsByteArray) + + private fun responseData(result: MvcTestResult): JsonNode = responseJson(result).path("data") + + private fun validRow( + clientRowId: String, + korName: String, + engName: String, + regionId: Long, + distilleryId: Long, + tastingTagIds: List = emptyList() + ): MutableMap = mutableMapOf( + "clientRowId" to clientRowId, + "korName" to korName, + "engName" to engName, + "abv" to "40%", + "type" to AlcoholType.WHISKY.name, + "korCategory" to "싱글 몰트", + "engCategory" to "Single Malt", + "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, + "regionId" to regionId, + "distilleryId" to distilleryId, + "age" to "12", + "cask" to "Oak", + "description" to "벌크 등록 통합 테스트", + "volume" to "700ml", + "tastingTagIds" to tastingTagIds + ) + + private fun persistDuplicateCandidate( + korName: String, + engName: String, + region: Region, + distillery: Distillery + ): Alcohol = alcoholTestFactory.persistAlcohol( + Alcohol.builder() + .korName(korName) + .engName(engName) + .abv("40%") + .type(AlcoholType.WHISKY) + .korCategory("싱글 몰트") + .engCategory("Single Malt") + .categoryGroup(AlcoholCategoryGroup.SINGLE_MALT) + .region(region) + .distillery(distillery) + .age("12") + .cask("Oak") + .description("기존 중복 후보") + .volume("700ml") + ) + + private fun assertStoredAlcohol( + alcoholId: Long, + korName: String, + engName: String, + abv: String, + regionId: Long, + distilleryId: Long, + age: String?, + cask: String?, + description: String?, + volume: String, + tastingTagIds: List + ) { + val stored = + jdbcTemplate.queryForMap( + """ + SELECT id, kor_name, eng_name, abv, type, kor_category, eng_category, + category_group, region_id, distillery_id, age, cask, description, volume, + create_principal_id, create_principal_type + FROM alcohols + WHERE id = ? + """.trimIndent(), + alcoholId + ) + assertThat((stored["id"] as Number).toLong()).isEqualTo(alcoholId) + assertThat(stored["kor_name"]).isEqualTo(korName) + assertThat(stored["eng_name"]).isEqualTo(engName) + assertThat(stored["abv"]).isEqualTo(abv) + assertThat(stored["type"]).isEqualTo("WHISKY") + assertThat(stored["kor_category"]).isEqualTo("싱글 몰트") + assertThat(stored["eng_category"]).isEqualTo("Single Malt") + assertThat(stored["category_group"]).isEqualTo("SINGLE_MALT") + assertThat((stored["region_id"] as Number).toLong()).isEqualTo(regionId) + assertThat((stored["distillery_id"] as Number).toLong()).isEqualTo(distilleryId) + assertThat(stored["age"]).isEqualTo(age) + assertThat(stored["cask"]).isEqualTo(cask) + assertThat(stored["description"]).isEqualTo(description) + assertThat(stored["volume"]).isEqualTo(volume) + assertThat((stored["create_principal_id"] as Number).toLong()).isEqualTo(adminId) + assertThat(stored["create_principal_type"]).isEqualTo("ADMIN") + assertThat( + jdbcTemplate.queryForList( + "SELECT tasting_tag_id FROM alcohols_tasting_tags WHERE alcohol_id = ? ORDER BY tasting_tag_id", + Long::class.java, + alcoholId + ) + ).containsExactlyElementsOf(tastingTagIds.sorted()) + } + + private fun alcoholCount(): Long = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM alcohols", Long::class.java) ?: 0L + + companion object { + private const val UNKNOWN_ID = 999_999_999L + } +} diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/openapi/AlcoholBulkOpenApiContractIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/openapi/AlcoholBulkOpenApiContractIntegrationTest.kt new file mode 100644 index 000000000..6d3b5a29d --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/openapi/AlcoholBulkOpenApiContractIntegrationTest.kt @@ -0,0 +1,101 @@ +package app.integration.openapi + +import com.fasterxml.jackson.databind.JsonNode +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Value + +@Tag("admin_integration") +@DisplayName("[integration] Admin Alcohol Bulk OpenAPI 계약") +class AlcoholBulkOpenApiContractIntegrationTest : OpenApiSpecTestSupport() { + @Value("\${server.servlet.context-path}") + private lateinit var contextPath: String + + @Test + @DisplayName("벌크 검증과 등록 엔드포인트가 Admin API prefix와 JSON 요청 본문으로 문서화된다") + fun bulkEndpointsDocumentAdminPrefixAndJsonBody() { + val spec = fetchSpec() + val operations = bulkOperations(spec) + + assertThat(contextPath).isEqualTo("/admin/api") + assertThat(operations.map { it.endpoint() }) + .containsExactlyInAnyOrder("POST /v1/alcohols/bulk/validate", "POST /v1/alcohols/bulk") + operations.forEach { operation -> + val requestSchema = operation.definition.at("/requestBody/content/application~1json/schema") + assertThat(requestSchema.path("\$ref").asText()).endsWith("/AdminAlcoholBulkRequest") + } + val requestSchema = spec.at("/components/schemas/AdminAlcoholBulkRequest") + assertThat(propertyNamesOf(requestSchema)).containsExactly("rows") + assertThat(requestSchema.at("/properties/rows/maxItems").asInt()).isEqualTo(1000) + assertThat(requestSchema.at("/properties/rows/minItems").asInt()).isEqualTo(1) + assertThat(requestSchema.at("/properties/rows/items/\$ref").asText()).endsWith("/AdminAlcoholBulkRowRequest") + } + + @Test + @DisplayName("벌크 검증과 등록은 200·400·401 응답과 bearerAuth 요구를 문서화한다") + fun bulkEndpointsDocumentResponsesAndAuthentication() { + val operations = bulkOperations(fetchSpec()) + + operations.forEach { operation -> + assertThat(childNamesOf(operation.definition.path("responses"))) + .contains("200", "400", "401") + assertThat(operation.security().any { item -> item.has("bearerAuth") }).isTrue() + } + } + + @Test + @DisplayName("벌크 성공 및 검증 실패 응답이 생성 ID와 validation 구조를 노출한다") + fun bulkResponseSchemasExposeCreatedRowsAndValidation() { + val spec = fetchSpec() + val validate = bulkOperations(spec).first { it.endpoint().endsWith("/validate") } + val create = bulkOperations(spec).first { it.endpoint() == "POST /v1/alcohols/bulk" } + val validateData = referencedSchema(spec, validate.successSchema().at("/properties/data")) + val createData = referencedSchema(spec, create.successSchema().at("/properties/data")) + + assertThat(propertyNamesOf(validateData)) + .contains("totalRows", "validRows", "invalidRows", "warningRows", "rows") + assertThat(propertyNamesOf(createData)).contains("createdRows", "rows", "validation") + val createdRowSchema = referencedSchema(spec, createData.at("/properties/rows/items")) + assertThat(propertyNamesOf(createdRowSchema)).contains("clientRowId", "alcoholId") + val failureSchema = create.definition.at("/responses/400/content/application~1json/schema") + val failureRefs = failureSchema.path("oneOf").map { it.path("\$ref").asText() } + assertThat(failureRefs) + .anyMatch { it.endsWith("/AlcoholBulkValidationFailureEnvelope") } + .anyMatch { it.endsWith("/AlcoholBulkRequestFailureEnvelope") } + val validationFailure = referencedSchema( + spec, + failureSchema.path("oneOf").first { node -> + node.path("\$ref").asText().endsWith("/AlcoholBulkValidationFailureEnvelope") + } + ) + val requestFailure = referencedSchema( + spec, + failureSchema.path("oneOf").first { node -> + node.path("\$ref").asText().endsWith("/AlcoholBulkRequestFailureEnvelope") + } + ) + val validationErrors = referencedSchema(spec, validationFailure.at("/properties/errors")) + assertThat(propertyNamesOf(validationErrors)).contains("invalidRows", "rows") + assertThat(requestFailure.at("/properties/errors/type").asText()).isEqualTo("array") + assertThat(requestFailure.at("/properties/errors/items/\$ref").asText()).endsWith("/Error") + val validateRequestFailure = validate.definition.at("/responses/400/content/application~1json/schema") + assertThat(validateRequestFailure.path("\$ref").asText()).endsWith("/AlcoholBulkRequestFailureEnvelope") + } + + private fun bulkOperations(spec: JsonNode): List = operationsOf(spec).filter { it.endpoint() in BULK_ENDPOINTS } + + private fun referencedSchema( + spec: JsonNode, + schema: JsonNode + ): JsonNode { + val ref = schema.path("\$ref").asText() + return if (ref.isBlank()) schema else spec.at(ref.removePrefix("#")) + } + + companion object { + private val BULK_ENDPOINTS = + setOf("POST /v1/alcohols/bulk/validate", "POST /v1/alcohols/bulk") + } +} diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/openapi/OpenApiDocsIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/openapi/OpenApiDocsIntegrationTest.kt index b9f72d6df..a78359c9e 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/openapi/OpenApiDocsIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/openapi/OpenApiDocsIntegrationTest.kt @@ -11,10 +11,10 @@ class OpenApiDocsIntegrationTest : OpenApiSpecTestSupport() { private val envelopeFields = listOf("success", "code", "data", "errors", "meta") - // Admin의 84 operation은 대부분 GlobalResponse 공통 형식을 쓴다. + // Admin의 86 operation은 대부분 GlobalResponse 공통 형식을 쓴다. // 템플릿 다운로드(GET /v1/alcohols/excel/template)는 XLSX binary 응답이라 예외다. - // 기존 82 + excel template/validate 2 = 84 - private val expectedOperationCount = 84 + // 기존 84 + bulk validate/create 2 = 86 + private val expectedOperationCount = 86 private val binaryDownloadOperations = setOf("GET /v1/alcohols/excel/template") @Test @@ -47,8 +47,8 @@ class OpenApiDocsIntegrationTest : OpenApiSpecTestSupport() { } @Test - @DisplayName("문서에는 84개 operation이 누락 없이 포함된다") - fun openApiSpecContains84Operations() { + @DisplayName("문서에는 86개 operation이 누락 없이 포함된다") + fun openApiSpecContains86Operations() { val operations = operationsOf(fetchSpec()) assertThat(operations) diff --git "a/plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" "b/plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" index 8856b7d5d..f7ee9006d 100644 --- "a/plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" +++ "b/plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" @@ -99,7 +99,7 @@ mono alcohol DTO/service, admin Excel/controller/OpenAPI/config, 해당 unit/HTT - Files (advisory): AdminAlcoholBulkController/docs, AdminAlcoholBulkIntegrationTest, application.yml/파일 예외 매핑. - Depends: Task 1의 공통 DTO 계약 - Size: M -- Status: [ ] not done +- Status: [x] 구현 완료, Actions 검증 대기 ### Task 4: 통합 리뷰와 CI 검증 - Acceptance: 변경 범위 검토, 테스트·코드 결함 수정, 최종 SHA의 GitHub Actions 전체 통과. @@ -115,3 +115,5 @@ mono alcohol DTO/service, admin Excel/controller/OpenAPI/config, 해당 unit/HTT - Task 1: 공통 DTO·정규화·전 주종 참조·전체 트랜잭션 저장·단위 테스트를 구현했다. self-review에서 WHISKY 한정 조회, 숫자 정밀도, 중복 식별 기준, 서명 URL을 보완했다. Java/Kotlin 테스트 컴파일과 포맷 검사 통과, 테스트 실행은 Actions에 대기 중이다. - Task 2: Excel을 공통 검증 어댑터로 연결했다. 선택 필드·시트 순서·안내문 제약을 완화하고 전 주종 카테고리·퍼센트 서식·파일 파싱 예외를 보완했다. 기존 오류 코드와 응답 필드를 유지하며 clientRowId와 normalized를 추가했다. +- Task 3: Admin bulk validate/create HTTP 엔드포인트, OpenAPI 성공/400 두 형식, 업로드 크기·누락 예외 처리를 추가했다. Excel/JSON 왕복 저장, 실제 필드·태그·감사 주체, 재검증과 중간 예외 롤백, 인증·입력 경계 및 OpenAPI 통합 테스트를 작성했다. 최종 테스트 컴파일/포맷 검사는 통과했고 전체 실행은 feature branch CI에 대기 중이다. +- Task 4 리뷰: 공통 서비스의 enum/참조/저장 길이, 단위·중복 정책, 모듈 경계, 인증, 트랜잭션을 대조했다. 발견한 타입 필터·퍼센트 서식·400 응답 문서 결함을 수정했다. 스키마와 환경 시크릿은 변경하지 않았다. 기존 미포맷 파일은 작업 범위에서 제외했다. From a56f43913444439bec0d179ee955ca1b7d03cfc3 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Sat, 5 Sep 2026 12:42:00 +0900 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20=EB=B2=8C=ED=81=AC=20API=20=EC=95=84?= =?UTF-8?q?=ED=82=A4=ED=85=8D=EC=B2=98=20=EA=B7=9C=EC=B9=99=EA=B3=BC=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EA=B3=84=EC=95=BD=20=EC=A0=95=ED=95=A9?= =?UTF-8?q?=EC=84=B1=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../excel/AdminAlcoholExcelServiceImpl.kt | 10 ++--- .../docs/AdminAlcoholBulkApiDocs.kt | 6 ++- .../excel/AdminAlcoholExcelServiceTest.kt | 26 +++++++------ .../AdminAlcoholBulkIntegrationTest.kt | 8 ++-- ...coholBulkOpenApiContractIntegrationTest.kt | 13 ++----- .../request/AdminAlcoholBulkRowRequest.java | 1 + .../dto/response/AdminAlcoholBulkIssue.java | 3 -- .../response/AdminAlcoholBulkIssueItem.java | 3 ++ ...sult.java => AdminAlcoholBulkRowItem.java} | 6 +-- .../AdminAlcoholBulkValidateResponse.java | 2 +- .../AlcoholBulkIdDeserializer.java | 2 +- .../service/AlcoholBulkInputNormalizer.java | 16 ++++---- .../DefaultAdminAlcoholBulkService.java | 38 +++++++++---------- .../AlcoholBulkInputNormalizerTest.java | 20 +++++----- .../DefaultAdminAlcoholBulkServiceTest.java | 30 +++++++-------- ...4\355\201\254 \353\223\261\353\241\235.md" | 1 + 16 files changed, 94 insertions(+), 91 deletions(-) delete mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkIssue.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkIssueItem.java rename bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/{AdminAlcoholBulkRowResult.java => AdminAlcoholBulkRowItem.java} (67%) rename bottlenote-mono/src/main/java/app/bottlenote/alcohols/{dto/request => serializer}/AlcoholBulkIdDeserializer.java (95%) diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/excel/AdminAlcoholExcelServiceImpl.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/excel/AdminAlcoholExcelServiceImpl.kt index 56807d7cd..703b1e424 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/excel/AdminAlcoholExcelServiceImpl.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/excel/AdminAlcoholExcelServiceImpl.kt @@ -8,8 +8,8 @@ import app.bottlenote.alcohols.domain.RegionRepository import app.bottlenote.alcohols.domain.TastingTagRepository import app.bottlenote.alcohols.dto.request.AdminAlcoholBulkRequest import app.bottlenote.alcohols.dto.request.AdminAlcoholBulkRowRequest -import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkIssue -import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkRowResult +import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkIssueItem +import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkRowItem import app.bottlenote.alcohols.dto.response.AdminAlcoholExcelIssue import app.bottlenote.alcohols.dto.response.AdminAlcoholExcelRowResult import app.bottlenote.alcohols.dto.response.AdminAlcoholExcelValidateResponse @@ -357,7 +357,7 @@ class AdminAlcoholExcelServiceImpl( private fun adaptResult( adapter: BulkAdapterRow, - common: AdminAlcoholBulkRowResult?, + common: AdminAlcoholBulkRowItem?, regionsById: Map, distilleriesById: Map ): AdminAlcoholExcelRowResult { @@ -419,9 +419,9 @@ class AdminAlcoholExcelServiceImpl( } } - private fun adaptIssue(issue: AdminAlcoholBulkIssue): AdminAlcoholExcelIssue = AdminAlcoholExcelIssue(excelIssueCode(issue), excelFieldName(issue.field()), issue.message()) + private fun adaptIssue(issue: AdminAlcoholBulkIssueItem): AdminAlcoholExcelIssue = AdminAlcoholExcelIssue(excelIssueCode(issue), excelFieldName(issue.field()), issue.message()) - private fun excelIssueCode(issue: AdminAlcoholBulkIssue): String = when (issue.code()) { + private fun excelIssueCode(issue: AdminAlcoholBulkIssueItem): String = when (issue.code()) { "REQUIRED" -> "REQUIRED_FIELD" "INVALID_QUANTITY" -> "INVALID_NUMBER" "INVALID_ENUM" -> "INVALID_ENUM_VALUE" diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/docs/AdminAlcoholBulkApiDocs.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/docs/AdminAlcoholBulkApiDocs.kt index 7086b74c1..620402117 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/docs/AdminAlcoholBulkApiDocs.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/docs/AdminAlcoholBulkApiDocs.kt @@ -23,7 +23,8 @@ object AdminAlcoholBulkApiDocs { ) @ApiResponse(responseCode = "200", description = "행별 오류·경고·정규화 결과", content = [Content(schema = Schema(implementation = ValidateEnvelope::class))]) @ApiResponse(responseCode = "400", description = "잘못된 JSON, 빈 목록 또는 최대 행 수 초과", content = [Content(schema = Schema(implementation = RequestFailureEnvelope::class))]) - @ApiResponse(responseCode = "401", description = "관리자 인증이 없는 경우") + @ApiResponse(responseCode = "401", description = "관리자 인증 토큰이 유효하지 않은 경우") + @ApiResponse(responseCode = "403", description = "관리자 인증 없이 보호된 API에 접근한 경우") annotation class ValidateBulk @Target(AnnotationTarget.FUNCTION) @@ -34,7 +35,8 @@ object AdminAlcoholBulkApiDocs { ) @ApiResponse(responseCode = "200", description = "등록 건수와 clientRowId별 생성 ID", content = [Content(schema = Schema(implementation = CreateEnvelope::class))]) @ApiResponse(responseCode = "400", description = "행 검증 실패 또는 잘못된 요청 목록", content = [Content(schema = Schema(oneOf = [ValidationFailureEnvelope::class, RequestFailureEnvelope::class]))]) - @ApiResponse(responseCode = "401", description = "관리자 인증이 없는 경우") + @ApiResponse(responseCode = "401", description = "관리자 인증 토큰이 유효하지 않은 경우") + @ApiResponse(responseCode = "403", description = "관리자 인증 없이 보호된 API에 접근한 경우") annotation class CreateBulk @Schema(name = "AlcoholBulkValidateEnvelope") diff --git a/bottlenote-admin-api/src/test/kotlin/app/bottlenote/alcohols/excel/AdminAlcoholExcelServiceTest.kt b/bottlenote-admin-api/src/test/kotlin/app/bottlenote/alcohols/excel/AdminAlcoholExcelServiceTest.kt index 41fd84ad1..4fb143449 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/bottlenote/alcohols/excel/AdminAlcoholExcelServiceTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/bottlenote/alcohols/excel/AdminAlcoholExcelServiceTest.kt @@ -9,8 +9,8 @@ import app.bottlenote.alcohols.domain.TastingTag import app.bottlenote.alcohols.dto.request.AdminAlcoholBulkRequest import app.bottlenote.alcohols.dto.request.AdminAlcoholBulkRowRequest import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkCreateResponse -import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkIssue -import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkRowResult +import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkIssueItem +import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkRowItem import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkValidateResponse import app.bottlenote.alcohols.exception.AlcoholException import app.bottlenote.alcohols.exception.AlcoholExceptionCode @@ -171,7 +171,7 @@ class AdminAlcoholExcelServiceTest { }.joinToString("\n") assertThat(guideText).contains("오류 코드") assertThat(guideText).contains("DUPLICATE_CANDIDATE") - assertThat(guideText).contains("이미 등록된 위스키입니다") + assertThat(guideText).contains("이미 등록된 알코올 후보입니다") } } } @@ -282,7 +282,7 @@ class AdminAlcoholExcelServiceTest { @Test @DisplayName("파일 내부 중복과 기존 후보는 공통 검증의 warning 을 보존한다") - fun validate_duplicateInFileIsError_andDbMatchIsWarning() { + fun validate_duplicateInFileAndDbMatchAreWarnings() { alcoholQueryRepository.findAll().toList().forEach { alcohol -> if (alcohol.korName == "__category_seed__") { ReflectionTestUtils.setField(alcohol, "deletedAt", java.time.LocalDateTime.now()) @@ -331,7 +331,9 @@ class AdminAlcoholExcelServiceTest { val result = service.validate(file) assertThat(result.rows).allMatch { row -> row.warnings.any { it.code == "DUPLICATE_IN_FILE" } } assertThat(result.rows).allMatch { row -> row.warnings.any { it.code == "DUPLICATE_CANDIDATE" } } - assertThat(result.rows[0].warnings[0].message).contains("이미 등록된 위스키입니다") + assertThat(result.rows).allMatch { it.valid && it.errors.isEmpty() } + assertThat(result.rows[0].warnings.first { it.code == "DUPLICATE_CANDIDATE" }.message) + .isEqualTo("기존 등록 후보입니다.") } @Test @@ -775,24 +777,24 @@ class AdminAlcoholExcelServiceTest { receivedRequests += request val duplicateCounts = request.rows().groupingBy(::identity).eachCount() val rows = request.rows().map { row -> - val errors = mutableListOf() - val warnings = mutableListOf() + val errors = mutableListOf() + val warnings = mutableListOf() if (row.regionId() == 999999L) { - errors += AdminAlcoholBulkIssue("REGION_NOT_FOUND", "regionId", "지역 ID를 찾을 수 없습니다.") + errors += AdminAlcoholBulkIssueItem("INVALID_REFERENCE", "regionId", "지역 ID를 찾을 수 없습니다.") } if ((duplicateCounts[identity(row)] ?: 0) > 1) { - warnings += AdminAlcoholBulkIssue("DUPLICATE_IN_FILE", null, "파일 내부 중복 후보입니다.") + warnings += AdminAlcoholBulkIssueItem("DUPLICATE_REQUEST_ROW", null, "파일 내부 중복 후보입니다.") } if (row.categoryGroup() == "BLEND") { - warnings += AdminAlcoholBulkIssue("CATEGORY_GROUP_MISMATCH", "categoryGroup", "기존 카테고리와 그룹이 다릅니다.") + warnings += AdminAlcoholBulkIssueItem("CATEGORY_GROUP_MISMATCH", "categoryGroup", "기존 카테고리와 그룹이 다릅니다.") } val candidateIds = if (row.korName() == "글렌피딕 12년" && row.volume().startsWith("700")) listOf(777L) else emptyList() if (candidateIds.isNotEmpty()) { - warnings += AdminAlcoholBulkIssue("DUPLICATE_CANDIDATE", null, "기존 등록 후보입니다.") + warnings += AdminAlcoholBulkIssueItem("DUPLICATE_DB_CANDIDATE", null, "기존 등록 후보입니다.") } val normalized = if (errors.isEmpty()) normalize(row) else null - AdminAlcoholBulkRowResult(row.clientRowId(), errors.isEmpty(), normalized, errors, warnings, candidateIds) + AdminAlcoholBulkRowItem(row.clientRowId(), errors.isEmpty(), normalized, errors, warnings, candidateIds) } return AdminAlcoholBulkValidateResponse( rows.size, diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholBulkIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholBulkIntegrationTest.kt index f566c65ae..aa663749f 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholBulkIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholBulkIntegrationTest.kt @@ -387,10 +387,10 @@ class AdminAlcoholBulkIntegrationTest : IntegrationTestSupport() { assertThat(responseJson(result).path("success").asBoolean()).isFalse() } - @ParameterizedTest(name = "인증 없이 {0}을 호출하면 401을 반환한다") + @ParameterizedTest(name = "인증 없이 {0}을 호출하면 403을 반환한다") @ValueSource(strings = ["/v1/alcohols/bulk/validate", "/v1/alcohols/bulk"]) - @DisplayName("인증이 없으면 벌크 엔드포인트는 401을 반환한다") - fun unauthenticatedRequestsReturnUnauthorized(path: String) { + @DisplayName("인증이 없으면 벌크 엔드포인트는 403을 반환한다") + fun unauthenticatedRequestsReturnForbidden(path: String) { val result = mockMvcTester .post() @@ -399,7 +399,7 @@ class AdminAlcoholBulkIntegrationTest : IntegrationTestSupport() { .content("{\"rows\":[]}") .exchange() - assertThat(result).hasStatus(401) + assertThat(result).hasStatus(403) } } diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/openapi/AlcoholBulkOpenApiContractIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/openapi/AlcoholBulkOpenApiContractIntegrationTest.kt index 6d3b5a29d..da37f6c0e 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/openapi/AlcoholBulkOpenApiContractIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/openapi/AlcoholBulkOpenApiContractIntegrationTest.kt @@ -5,21 +5,16 @@ import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Value @Tag("admin_integration") @DisplayName("[integration] Admin Alcohol Bulk OpenAPI 계약") class AlcoholBulkOpenApiContractIntegrationTest : OpenApiSpecTestSupport() { - @Value("\${server.servlet.context-path}") - private lateinit var contextPath: String - @Test - @DisplayName("벌크 검증과 등록 엔드포인트가 Admin API prefix와 JSON 요청 본문으로 문서화된다") - fun bulkEndpointsDocumentAdminPrefixAndJsonBody() { + @DisplayName("벌크 검증과 등록 엔드포인트가 v1 경로와 JSON 요청 본문으로 문서화된다") + fun bulkEndpointsDocumentVersionedPathsAndJsonBody() { val spec = fetchSpec() val operations = bulkOperations(spec) - assertThat(contextPath).isEqualTo("/admin/api") assertThat(operations.map { it.endpoint() }) .containsExactlyInAnyOrder("POST /v1/alcohols/bulk/validate", "POST /v1/alcohols/bulk") operations.forEach { operation -> @@ -34,13 +29,13 @@ class AlcoholBulkOpenApiContractIntegrationTest : OpenApiSpecTestSupport() { } @Test - @DisplayName("벌크 검증과 등록은 200·400·401 응답과 bearerAuth 요구를 문서화한다") + @DisplayName("벌크 검증과 등록은 200·400·401·403 응답과 bearerAuth 요구를 문서화한다") fun bulkEndpointsDocumentResponsesAndAuthentication() { val operations = bulkOperations(fetchSpec()) operations.forEach { operation -> assertThat(childNamesOf(operation.definition.path("responses"))) - .contains("200", "400", "401") + .contains("200", "400", "401", "403") assertThat(operation.security().any { item -> item.has("bearerAuth") }).isTrue() } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholBulkRowRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholBulkRowRequest.java index b6b60501c..01cb617aa 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholBulkRowRequest.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholBulkRowRequest.java @@ -1,5 +1,6 @@ package app.bottlenote.alcohols.dto.request; +import app.bottlenote.alcohols.serializer.AlcoholBulkIdDeserializer; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import java.util.List; diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkIssue.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkIssue.java deleted file mode 100644 index 4c7899f2f..000000000 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkIssue.java +++ /dev/null @@ -1,3 +0,0 @@ -package app.bottlenote.alcohols.dto.response; - -public record AdminAlcoholBulkIssue(String code, String field, String message) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkIssueItem.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkIssueItem.java new file mode 100644 index 000000000..2ec941e3f --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkIssueItem.java @@ -0,0 +1,3 @@ +package app.bottlenote.alcohols.dto.response; + +public record AdminAlcoholBulkIssueItem(String code, String field, String message) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkRowResult.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkRowItem.java similarity index 67% rename from bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkRowResult.java rename to bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkRowItem.java index 9a3035ca2..840b3a458 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkRowResult.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkRowItem.java @@ -3,10 +3,10 @@ import app.bottlenote.alcohols.dto.request.AdminAlcoholBulkRowRequest; import java.util.List; -public record AdminAlcoholBulkRowResult( +public record AdminAlcoholBulkRowItem( String clientRowId, boolean valid, AdminAlcoholBulkRowRequest normalized, - List errors, - List warnings, + List errors, + List warnings, List candidateAlcoholIds) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkValidateResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkValidateResponse.java index cc62b35ff..edc4fc0ee 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkValidateResponse.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholBulkValidateResponse.java @@ -7,4 +7,4 @@ public record AdminAlcoholBulkValidateResponse( int validRows, int invalidRows, int warningRows, - List rows) {} + List rows) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AlcoholBulkIdDeserializer.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/serializer/AlcoholBulkIdDeserializer.java similarity index 95% rename from bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AlcoholBulkIdDeserializer.java rename to bottlenote-mono/src/main/java/app/bottlenote/alcohols/serializer/AlcoholBulkIdDeserializer.java index acacf0809..9c103947d 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AlcoholBulkIdDeserializer.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/serializer/AlcoholBulkIdDeserializer.java @@ -1,4 +1,4 @@ -package app.bottlenote.alcohols.dto.request; +package app.bottlenote.alcohols.serializer; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonToken; diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholBulkInputNormalizer.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholBulkInputNormalizer.java index 181de7975..d23bd23b1 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholBulkInputNormalizer.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholBulkInputNormalizer.java @@ -1,6 +1,6 @@ package app.bottlenote.alcohols.service; -import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkIssue; +import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkIssueItem; import java.math.BigDecimal; import java.text.Normalizer; import java.util.List; @@ -44,15 +44,15 @@ static String key(String value) { static String quantity( String raw, String field, - List errors, - List warnings) { + List errors, + List warnings) { String value = clean(raw); if (value == null) { - errors.add(new AdminAlcoholBulkIssue("REQUIRED", field, "필수 입력값입니다.")); + errors.add(new AdminAlcoholBulkIssueItem("REQUIRED", field, "필수 입력값입니다.")); return null; } if (value.length() > 255) { - errors.add(new AdminAlcoholBulkIssue("TOO_LONG", field, "255자를 초과할 수 없습니다.")); + errors.add(new AdminAlcoholBulkIssueItem("TOO_LONG", field, "255자를 초과할 수 없습니다.")); return null; } Matcher scalar = SCALAR.matcher(value); @@ -62,16 +62,16 @@ static String quantity( String normalized = number.stripTrailingZeros().toPlainString() + (field.equals("abv") ? "%" : "ml"); if (normalized.length() <= 255) return normalized; - errors.add(new AdminAlcoholBulkIssue("TOO_LONG", field, "정규화한 값이 255자를 초과합니다.")); + errors.add(new AdminAlcoholBulkIssueItem("TOO_LONG", field, "정규화한 값이 255자를 초과합니다.")); return null; } } else if (isAnnotated(value, field) || isComposite(value, field)) { warnings.add( - new AdminAlcoholBulkIssue("NON_SCALAR_VALUE", field, "범위·배치·세트·주석 표현을 원문으로 보존합니다.")); + new AdminAlcoholBulkIssueItem("NON_SCALAR_VALUE", field, "범위·배치·세트·주석 표현을 원문으로 보존합니다.")); return value; } errors.add( - new AdminAlcoholBulkIssue( + new AdminAlcoholBulkIssueItem( "INVALID_QUANTITY", field, field.equals("abv") diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholBulkService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholBulkService.java index aee7551ae..eb10ef6f6 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholBulkService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholBulkService.java @@ -19,8 +19,8 @@ import app.bottlenote.alcohols.dto.request.AdminAlcoholBulkRowRequest; import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkCreateResponse; import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkCreateResponse.CreatedRow; -import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkIssue; -import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkRowResult; +import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkIssueItem; +import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkRowItem; import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkValidateResponse; import app.bottlenote.alcohols.dto.response.AlcoholBulkReferenceItem; import app.bottlenote.alcohols.exception.AlcoholException; @@ -49,7 +49,6 @@ @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class DefaultAdminAlcoholBulkService implements AdminAlcoholBulkService { private static final int MAX_TAGS = 1000; private final AlcoholQueryRepository alcoholRepository; @@ -60,6 +59,7 @@ public class DefaultAdminAlcoholBulkService implements AdminAlcoholBulkService { private final ApplicationEventPublisher eventPublisher; @Override + @Transactional(readOnly = true) public AdminAlcoholBulkValidateResponse validate(AdminAlcoholBulkRequest request) { checkRequest(request); return validateRows(request.rows(), loadReferences(request.rows())); @@ -75,7 +75,7 @@ public AdminAlcoholBulkCreateResponse create(AdminAlcoholBulkRequest request) { return new AdminAlcoholBulkCreateResponse(0, List.of(), validation); } List created = new ArrayList<>(); - for (AdminAlcoholBulkRowResult result : validation.rows()) { + for (AdminAlcoholBulkRowItem result : validation.rows()) { AdminAlcoholBulkRowRequest row = result.normalized(); Alcohol saved = alcoholRepository.save( @@ -182,25 +182,25 @@ private AdminAlcoholBulkValidateResponse validateRows( identities.merge( identity(row.korName(), row.distilleryId(), row.abv(), row.volume()), 1, Integer::sum); } - List results = new ArrayList<>(); + List results = new ArrayList<>(); for (AdminAlcoholBulkRowRequest row : rows) { results.add(validateRow(row, refs, clientIds, identities)); } - int valid = (int) results.stream().filter(AdminAlcoholBulkRowResult::valid).count(); + int valid = (int) results.stream().filter(AdminAlcoholBulkRowItem::valid).count(); int warning = (int) results.stream().filter(row -> !row.warnings().isEmpty()).count(); return new AdminAlcoholBulkValidateResponse( rows.size(), valid, rows.size() - valid, warning, List.copyOf(results)); } - private AdminAlcoholBulkRowResult validateRow( + private AdminAlcoholBulkRowItem validateRow( AdminAlcoholBulkRowRequest row, References refs, Map clientIds, Map identities) { - List errors = new ArrayList<>(); - List warnings = new ArrayList<>(); + List errors = new ArrayList<>(); + List warnings = new ArrayList<>(); if (row == null) { - return new AdminAlcoholBulkRowResult( + return new AdminAlcoholBulkRowItem( null, false, null, @@ -270,7 +270,7 @@ private AdminAlcoholBulkRowResult validateRow( tags, image) : null; - return new AdminAlcoholBulkRowResult( + return new AdminAlcoholBulkRowItem( clientId, errors.isEmpty(), normalized, @@ -283,8 +283,8 @@ private AlcoholCategoryGroup resolveGroup( AdminAlcoholBulkRowRequest row, AlcoholType type, Map> categories, - List errors, - List warnings) { + List errors, + List warnings) { Set known = categories.getOrDefault(categoryKey(row.korCategory(), row.engCategory()), Set.of()); String raw = clean(row.categoryGroup()); @@ -324,8 +324,8 @@ private AlcoholCategoryGroup resolveGroup( private List tags( List input, Map known, - List errors, - List warnings) { + List errors, + List warnings) { if (input == null) return List.of(); if (input.size() > MAX_TAGS) { errors.add(issue("TOO_MANY_TAGS", "tastingTagIds", "태그는 행마다 최대 1,000개까지 입력할 수 있습니다.")); @@ -358,7 +358,7 @@ private static AlcoholType parseType(String raw) { } private static String text( - String raw, String field, boolean required, List errors) { + String raw, String field, boolean required, List errors) { String value = clean(raw); if (required && value == null) errors.add(issue("REQUIRED", field, "필수 입력값입니다.")); if (value != null && value.length() > 255) @@ -393,7 +393,7 @@ private static boolean signedQuery(String rawQuery) { } private static void checkReference( - Long id, String field, Map known, List errors) { + Long id, String field, Map known, List errors) { if (id == null || id <= 0 || !known.containsKey(id)) { errors.add(issue("INVALID_REFERENCE", field, "존재하는 참조 ID를 입력해 주세요.")); } @@ -425,8 +425,8 @@ private static CategoryKey categoryKey(String korCategory, String engCategory) { return new CategoryKey(key(korCategory), key(engCategory)); } - private static AdminAlcoholBulkIssue issue(String code, String field, String message) { - return new AdminAlcoholBulkIssue(code, field, message); + private static AdminAlcoholBulkIssueItem issue(String code, String field, String message) { + return new AdminAlcoholBulkIssueItem(code, field, message); } private record IdentityKey(String name, Long distilleryId, String abv, String volume) {} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/AlcoholBulkInputNormalizerTest.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/AlcoholBulkInputNormalizerTest.java index 30e74344f..f0f4219ab 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/AlcoholBulkInputNormalizerTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/AlcoholBulkInputNormalizerTest.java @@ -2,7 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; -import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkIssue; +import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkIssueItem; import java.util.ArrayList; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; @@ -26,8 +26,8 @@ class AlcoholBulkInputNormalizerTest { }) @DisplayName("숫자와 단위가 유효할 때 표준 단위로 정규화한다") void 수치를_정규화한다(String field, String input, String expected) { - var errors = new ArrayList(); - var warnings = new ArrayList(); + var errors = new ArrayList(); + var warnings = new ArrayList(); assertThat(AlcoholBulkInputNormalizer.quantity(input, field, errors, warnings)) .isEqualTo(expected); assertThat(errors).isEmpty(); @@ -68,10 +68,12 @@ class AlcoholBulkInputNormalizerTest { }) @DisplayName("값이나 단위가 잘못됐을 때 오류를 반환한다") void 잘못된_수치를_거절한다(String field, String input) { - var errors = new ArrayList(); - var warnings = new ArrayList(); + var errors = new ArrayList(); + var warnings = new ArrayList(); assertThat(AlcoholBulkInputNormalizer.quantity(input, field, errors, warnings)).isNull(); - assertThat(errors).extracting(AdminAlcoholBulkIssue::code).containsExactly("INVALID_QUANTITY"); + assertThat(errors) + .extracting(AdminAlcoholBulkIssueItem::code) + .containsExactly("INVALID_QUANTITY"); assertThat(warnings).isEmpty(); } @@ -83,13 +85,13 @@ class AlcoholBulkInputNormalizerTest { }) @DisplayName("범위와 배치 및 세트 표현이 유효할 때 원문과 경고를 반환한다") void 복합_표현을_보존한다(String field, String input) { - var errors = new ArrayList(); - var warnings = new ArrayList(); + var errors = new ArrayList(); + var warnings = new ArrayList(); assertThat(AlcoholBulkInputNormalizer.quantity(input, field, errors, warnings)) .isEqualTo(input); assertThat(errors).isEmpty(); assertThat(warnings) - .extracting(AdminAlcoholBulkIssue::code) + .extracting(AdminAlcoholBulkIssueItem::code) .containsExactly("NON_SCALAR_VALUE"); } } diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholBulkServiceTest.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholBulkServiceTest.java index d7c03769f..a4e75f36a 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholBulkServiceTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholBulkServiceTest.java @@ -11,8 +11,8 @@ import app.bottlenote.alcohols.domain.TastingTag; import app.bottlenote.alcohols.dto.request.AdminAlcoholBulkRequest; import app.bottlenote.alcohols.dto.request.AdminAlcoholBulkRowRequest; -import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkIssue; -import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkRowResult; +import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkIssueItem; +import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkRowItem; import app.bottlenote.alcohols.dto.response.AlcoholBulkReferenceItem; import app.bottlenote.alcohols.exception.AlcoholException; import app.bottlenote.alcohols.fixture.InMemoryAlcoholQueryRepository; @@ -96,7 +96,7 @@ void setUp() { assertThat(response.createdRows()).isEqualTo(1); assertThat(mappings.count()).isEqualTo(1); assertThat(response.validation().rows().getFirst().warnings()) - .extracting(AdminAlcoholBulkIssue::code) + .extracting(AdminAlcoholBulkIssueItem::code) .contains("DUPLICATE_TAG_REMOVED"); assertThat(events) .containsExactly( @@ -145,7 +145,7 @@ void setUp() { row -> { assertThat(row.normalized()).isNull(); assertThat(row.errors()) - .extracting(AdminAlcoholBulkIssue::code) + .extracting(AdminAlcoholBulkIssueItem::code) .contains("DUPLICATE_CLIENT_ROW_ID"); }); } @@ -165,7 +165,7 @@ void setUp() { assertThat(result.validation().rows().getFirst().candidateAlcoholIds()) .containsExactly(existing.getId()); assertThat(result.validation().rows().getFirst().warnings()) - .extracting(AdminAlcoholBulkIssue::code) + .extracting(AdminAlcoholBulkIssueItem::code) .contains("DUPLICATE_REQUEST_ROW", "DUPLICATE_DB_CANDIDATE"); } @@ -184,12 +184,12 @@ void setUp() { Row row = new Row(); row.group = null; assertThat(validate(row).errors()) - .extracting(AdminAlcoholBulkIssue::code) + .extracting(AdminAlcoholBulkIssueItem::code) .contains("CATEGORY_GROUP_REQUIRED"); existing(AlcoholCategoryGroup.SINGLE_MALT); existing(AlcoholCategoryGroup.BLEND); assertThat(validate(row).errors()) - .extracting(AdminAlcoholBulkIssue::code) + .extracting(AdminAlcoholBulkIssueItem::code) .contains("CATEGORY_GROUP_REQUIRED"); } @@ -215,7 +215,7 @@ void setUp() { assertThat(result.valid()).isTrue(); assertThat(result.normalized().categoryGroup()).isEqualTo("BLEND"); assertThat(result.warnings()) - .extracting(AdminAlcoholBulkIssue::code) + .extracting(AdminAlcoholBulkIssueItem::code) .contains("CATEGORY_GROUP_MISMATCH", "TYPE_GROUP_MISMATCH", "TYPE_CATEGORY_MISMATCH"); } @@ -239,7 +239,7 @@ void setUp() { Row row = new Row(); row.image = image; assertThat(validate(row).errors()) - .extracting(AdminAlcoholBulkIssue::code) + .extracting(AdminAlcoholBulkIssueItem::code) .contains("INVALID_URL"); } @@ -257,7 +257,7 @@ void setUp() { row.regionId = null; row.distilleryId = -1L; assertThat(validate(row).errors()) - .extracting(AdminAlcoholBulkIssue::field) + .extracting(AdminAlcoholBulkIssueItem::field) .contains( "korName", "engName", @@ -276,11 +276,11 @@ void setUp() { Row row = new Row(); row.tagIds = Arrays.asList(1L, null, 999L); assertThat(validate(row).errors()) - .extracting(AdminAlcoholBulkIssue::code) + .extracting(AdminAlcoholBulkIssueItem::code) .contains("INVALID_REFERENCE"); row.tagIds = Collections.nCopies(1001, 1L); assertThat(validate(row).errors()) - .extracting(AdminAlcoholBulkIssue::code) + .extracting(AdminAlcoholBulkIssueItem::code) .contains("TOO_MANY_TAGS"); } @@ -372,7 +372,7 @@ void setUp() { var result = validate(row); assertThat(result.candidateAlcoholIds()).hasSize(100); assertThat(result.warnings()) - .extracting(AdminAlcoholBulkIssue::code) + .extracting(AdminAlcoholBulkIssueItem::code) .contains("CANDIDATES_TRUNCATED"); } @@ -393,12 +393,12 @@ void setUp() { value -> { assertThat(value.candidateAlcoholIds()).isEmpty(); assertThat(value.warnings()) - .extracting(AdminAlcoholBulkIssue::code) + .extracting(AdminAlcoholBulkIssueItem::code) .doesNotContain("DUPLICATE_REQUEST_ROW", "DUPLICATE_DB_CANDIDATE"); }); } - private AdminAlcoholBulkRowResult validate(Row row) { + private AdminAlcoholBulkRowItem validate(Row row) { return service.validate(request(row)).rows().getFirst(); } diff --git "a/plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" "b/plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" index f7ee9006d..17e7c2815 100644 --- "a/plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" +++ "b/plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" @@ -117,3 +117,4 @@ mono alcohol DTO/service, admin Excel/controller/OpenAPI/config, 해당 unit/HTT - Task 2: Excel을 공통 검증 어댑터로 연결했다. 선택 필드·시트 순서·안내문 제약을 완화하고 전 주종 카테고리·퍼센트 서식·파일 파싱 예외를 보완했다. 기존 오류 코드와 응답 필드를 유지하며 clientRowId와 normalized를 추가했다. - Task 3: Admin bulk validate/create HTTP 엔드포인트, OpenAPI 성공/400 두 형식, 업로드 크기·누락 예외 처리를 추가했다. Excel/JSON 왕복 저장, 실제 필드·태그·감사 주체, 재검증과 중간 예외 롤백, 인증·입력 경계 및 OpenAPI 통합 테스트를 작성했다. 최종 테스트 컴파일/포맷 검사는 통과했고 전체 실행은 feature branch CI에 대기 중이다. - Task 4 리뷰: 공통 서비스의 enum/참조/저장 길이, 단위·중복 정책, 모듈 경계, 인증, 트랜잭션을 대조했다. 발견한 타입 필터·퍼센트 서식·400 응답 문서 결함을 수정했다. 스키마와 환경 시크릿은 변경하지 않았다. 기존 미포맷 파일은 작업 범위에서 제외했다. +- Actions 1차(33942278683, 06fc34deb): Excel/JSON 검증→실제 저장, 태그·감사 주체, 재검증 실패 시 0건 저장, 중간 예외 롤백은 통과했다. DTO 이름/serializer 위치/메서드 트랜잭션 규칙 3개 테스트, 이전 안내문을 예상한 Excel 단위 테스트 2개, OpenAPI 테스트 환경 설정 3개, 인증 없음의 실제 403을 401로 예상한 테스트 2개가 실패했다. 규칙은 유지하고 구현 이름·패키지와 메서드 선언, 공통 계약에 맞춘 테스트 및 문서를 수정한다. From 359e058a674df722c6949613e3bd26c56521b6d5 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Sat, 5 Sep 2026 12:49:25 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20=EB=B2=8C=ED=81=AC=EC=99=80=20?= =?UTF-8?q?=EC=97=91=EC=85=80=20OpenAPI=20=EC=9D=91=EB=8B=B5=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/AdminAlcoholBulkApiDocs.kt | 26 +++---------------- .../presentation/docs/AdminAlcoholsApiDocs.kt | 11 +------- ...coholBulkOpenApiContractIntegrationTest.kt | 11 ++++++++ .../dto/request/AdminAlcoholBulkRequest.java | 2 +- ...4\355\201\254 \353\223\261\353\241\235.md" | 1 + 5 files changed, 18 insertions(+), 33 deletions(-) diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/docs/AdminAlcoholBulkApiDocs.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/docs/AdminAlcoholBulkApiDocs.kt index 620402117..d10ab7b91 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/docs/AdminAlcoholBulkApiDocs.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/docs/AdminAlcoholBulkApiDocs.kt @@ -21,8 +21,8 @@ object AdminAlcoholBulkApiDocs { 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(schema = Schema(implementation = ValidateEnvelope::class))]) - @ApiResponse(responseCode = "400", description = "잘못된 JSON, 빈 목록 또는 최대 행 수 초과", content = [Content(schema = Schema(implementation = RequestFailureEnvelope::class))]) + @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 @@ -33,30 +33,12 @@ object AdminAlcoholBulkApiDocs { summary = "알코올 목록을 일괄 등록한다", description = "엑셀 검증 결과 또는 직접 작성한 JSON rows를 다시 검증한 뒤 하나의 트랜잭션으로 등록한다. 오류가 있으면 전혀 저장하지 않고 400 errors에 검증 결과를 반환한다. 경고만 있으면 모두 등록하며 중복 병합은 하지 않는다. 반복 POST는 별도 등록 요청이므로 자동 재시도하지 않는다. 이미지는 선택이며 기존 업로드의 viewUrl을 사용한다." ) - @ApiResponse(responseCode = "200", description = "등록 건수와 clientRowId별 생성 ID", content = [Content(schema = Schema(implementation = CreateEnvelope::class))]) - @ApiResponse(responseCode = "400", description = "행 검증 실패 또는 잘못된 요청 목록", content = [Content(schema = Schema(oneOf = [ValidationFailureEnvelope::class, RequestFailureEnvelope::class]))]) + @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 = "AlcoholBulkValidateEnvelope") - data class ValidateEnvelope( - val success: Boolean, - val code: Int, - val data: AdminAlcoholBulkValidateResponse, - val errors: List = emptyList(), - val meta: Map = emptyMap() - ) - - @Schema(name = "AlcoholBulkCreateEnvelope") - data class CreateEnvelope( - val success: Boolean, - val code: Int, - val data: AdminAlcoholBulkCreateResponse, - val errors: List = emptyList(), - val meta: Map = emptyMap() - ) - @Schema(name = "AlcoholBulkValidationFailureEnvelope") data class ValidationFailureEnvelope( val success: Boolean, diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/docs/AdminAlcoholsApiDocs.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/docs/AdminAlcoholsApiDocs.kt index 7324b4f94..76ecac04f 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/docs/AdminAlcoholsApiDocs.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/docs/AdminAlcoholsApiDocs.kt @@ -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 @@ -164,15 +164,6 @@ object AdminAlcoholsApiDocs { val meta: Map = emptyMap() ) - @Schema(name = "AlcoholExcelValidateEnvelope") - data class AlcoholExcelValidateEnvelope( - val success: Boolean, - val code: Int, - val data: AdminAlcoholExcelValidateResponse, - val errors: List = emptyList(), - val meta: Map = emptyMap() - ) - @Schema(name = "CategoryReferenceMap") data class CategoryReferenceMap( val SINGLE_MALT: List, diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/openapi/AlcoholBulkOpenApiContractIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/openapi/AlcoholBulkOpenApiContractIntegrationTest.kt index da37f6c0e..4b17f83ba 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/openapi/AlcoholBulkOpenApiContractIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/openapi/AlcoholBulkOpenApiContractIntegrationTest.kt @@ -79,6 +79,17 @@ class AlcoholBulkOpenApiContractIntegrationTest : OpenApiSpecTestSupport() { assertThat(validateRequestFailure.path("\$ref").asText()).endsWith("/AlcoholBulkRequestFailureEnvelope") } + @Test + @DisplayName("엑셀 검증 응답은 공통 형식의 data에 normalized 행을 직접 노출한다") + fun excelValidationDocumentsNormalizedRowsWithoutDoubleEnvelope() { + val spec = fetchSpec() + val operation = operationsOf(spec).first { it.endpoint() == "POST /v1/alcohols/excel/validate" } + val validation = referencedSchema(spec, operation.successSchema().at("/properties/data")) + assertThat(propertyNamesOf(validation)).contains("totalRows", "validRows", "invalidRows", "rows") + val row = referencedSchema(spec, validation.at("/properties/rows/items")) + assertThat(propertyNamesOf(row)).contains("clientRowId", "normalized", "errors", "warnings") + } + private fun bulkOperations(spec: JsonNode): List = operationsOf(spec).filter { it.endpoint() in BULK_ENDPOINTS } private fun referencedSchema( diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholBulkRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholBulkRequest.java index 3aa123b1b..f3b8f6834 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholBulkRequest.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholBulkRequest.java @@ -8,7 +8,7 @@ public record AdminAlcoholBulkRequest( @NotEmpty(message = "ALCOHOL_BULK_ROWS_REQUIRED") - @Size(max = MAX_ROWS, message = "ALCOHOL_BULK_ROWS_MAX_SIZE") + @Size(min = 1, max = MAX_ROWS, message = "ALCOHOL_BULK_ROWS_MAX_SIZE") List<@NotNull(message = "ALCOHOL_BULK_ROW_REQUIRED") @Valid AdminAlcoholBulkRowRequest> rows) { public static final int MAX_ROWS = 1000; diff --git "a/plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" "b/plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" index 17e7c2815..7f5bbef30 100644 --- "a/plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" +++ "b/plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" @@ -118,3 +118,4 @@ mono alcohol DTO/service, admin Excel/controller/OpenAPI/config, 해당 unit/HTT - Task 3: Admin bulk validate/create HTTP 엔드포인트, OpenAPI 성공/400 두 형식, 업로드 크기·누락 예외 처리를 추가했다. Excel/JSON 왕복 저장, 실제 필드·태그·감사 주체, 재검증과 중간 예외 롤백, 인증·입력 경계 및 OpenAPI 통합 테스트를 작성했다. 최종 테스트 컴파일/포맷 검사는 통과했고 전체 실행은 feature branch CI에 대기 중이다. - Task 4 리뷰: 공통 서비스의 enum/참조/저장 길이, 단위·중복 정책, 모듈 경계, 인증, 트랜잭션을 대조했다. 발견한 타입 필터·퍼센트 서식·400 응답 문서 결함을 수정했다. 스키마와 환경 시크릿은 변경하지 않았다. 기존 미포맷 파일은 작업 범위에서 제외했다. - Actions 1차(33942278683, 06fc34deb): Excel/JSON 검증→실제 저장, 태그·감사 주체, 재검증 실패 시 0건 저장, 중간 예외 롤백은 통과했다. DTO 이름/serializer 위치/메서드 트랜잭션 규칙 3개 테스트, 이전 안내문을 예상한 Excel 단위 테스트 2개, OpenAPI 테스트 환경 설정 3개, 인증 없음의 실제 403을 401로 예상한 테스트 2개가 실패했다. 규칙은 유지하고 구현 이름·패키지와 메서드 선언, 공통 계약에 맞춘 테스트 및 문서를 수정한다. +- Actions 2차(33942586977, a56f43913): 단위·Batch·아키텍처·Product 통합 및 벌크 HTTP 14개가 통과했다. 남은 OpenAPI 실패 2개는 성공 응답 envelope 중첩과 rows 최소 개수 0 표기였다. 공통 문서 처리기의 입력을 실제 data DTO로 바꾸고 @Size(min=1)를 명시했다. 같은 문제를 가진 Excel validate 문서도 수정하고 normalized 경로 회귀 검사를 추가했다. 400 응답의 application/json도 명시한다. From dcead9e71464a8c196d8fe48ded7ead484bcbc2e Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Sat, 5 Sep 2026 12:56:08 +0900 Subject: [PATCH 6/7] =?UTF-8?q?docs:=20=EC=95=8C=EC=BD=94=EC=98=AC=20?= =?UTF-8?q?=EB=B2=8C=ED=81=AC=20=EB=93=B1=EB=A1=9D=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...4\355\201\254 \353\223\261\353\241\235.md" | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git "a/plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" "b/plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" index 7f5bbef30..1bdfa361b 100644 --- "a/plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" +++ "b/plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" @@ -20,7 +20,7 @@ 3. optional 필드·단위·한글/영문 enum·그룹 자동 도출·중복 경고가 두 경로에서 일관된다. 4. 잘못된 enum/참조/도수/길이, 수식/외부 링크/크기 및 행 한도는 안정적인 오류를 반환하며 invalid bulk는 전혀 저장하지 않는다. 5. 생성 ID를 clientRowId와 대응시켜 반환하며 이미지 이벤트와 감사 기록을 보존한다. -6. GitHub Actions ci_pipeline의 unit/rule/product+admin integration/build가 최종 커밋에서 통과한다. +6. GitHub Actions ci_pipeline의 unit/rule/product+admin integration/build가 최종 구현 커밋에서 통과한다. ### Impact Scope @@ -80,7 +80,7 @@ mono alcohol DTO/service, admin Excel/controller/OpenAPI/config, 해당 unit/HTT - Files (advisory): mono alcohol bulk DTO/service/normalizer/tests, AdminAlcoholUpsertRequest. - Depends: 없음 - Size: M (한 bulk 경로의 DTO 묶음 포함) -- Status: [x] 구현 완료, Actions 검증 대기 +- Status: [x] 구현 및 Actions 검증 완료 ### Task 2: Excel을 공통 입력으로 연결 - Acceptance: 기존 템플릿/응답 호환 유지, normalized 추가, 공통 검증 재사용, 과도한 형식 제약 완화와 파일 방어 보강. @@ -88,10 +88,10 @@ mono alcohol DTO/service, admin Excel/controller/OpenAPI/config, 해당 unit/HTT - Files (advisory): AlcoholExcelSchema, AdminAlcoholExcelServiceImpl, validate response, Excel unit tests. - Depends: Task 1의 공통 DTO 계약 - Size: M -- Status: [x] 구현 완료, Actions 검증 대기 +- Status: [x] 구현 및 Actions 검증 완료 ### Checkpoint: after Tasks 1-2 -- [x] 공통 JSON 입력 계약과 컴파일 확인, 실제 round-trip은 Actions 검증 대기 +- [x] 공통 JSON 입력 계약·컴파일 및 두 경로의 실제 round-trip 확인 ### Task 3: Admin endpoint와 두 경로의 HTTP 계약 - Acceptance: Admin interface 기반 Controller, OpenAPI 및 인증, Excel/JSON→validate→bulk DB 저장/오류/경고 통합 테스트. @@ -99,15 +99,15 @@ mono alcohol DTO/service, admin Excel/controller/OpenAPI/config, 해당 unit/HTT - Files (advisory): AdminAlcoholBulkController/docs, AdminAlcoholBulkIntegrationTest, application.yml/파일 예외 매핑. - Depends: Task 1의 공통 DTO 계약 - Size: M -- Status: [x] 구현 완료, Actions 검증 대기 +- Status: [x] 구현 및 Actions 검증 완료 ### Task 4: 통합 리뷰와 CI 검증 -- Acceptance: 변경 범위 검토, 테스트·코드 결함 수정, 최종 SHA의 GitHub Actions 전체 통과. +- Acceptance: 변경 범위 검토, 테스트·코드 결함 수정, 최종 구현 SHA의 GitHub Actions 전체 통과. - Verification: ci_pipeline.yml workflow_dispatch. - Files (advisory): 본 계획과 필요한 수정 파일. - Depends: Task 1,2,3 - Size: S -- Status: [ ] not done +- Status: [x] 통합 리뷰와 Actions 검증 완료 ## Progress Log @@ -119,3 +119,26 @@ mono alcohol DTO/service, admin Excel/controller/OpenAPI/config, 해당 unit/HTT - Task 4 리뷰: 공통 서비스의 enum/참조/저장 길이, 단위·중복 정책, 모듈 경계, 인증, 트랜잭션을 대조했다. 발견한 타입 필터·퍼센트 서식·400 응답 문서 결함을 수정했다. 스키마와 환경 시크릿은 변경하지 않았다. 기존 미포맷 파일은 작업 범위에서 제외했다. - Actions 1차(33942278683, 06fc34deb): Excel/JSON 검증→실제 저장, 태그·감사 주체, 재검증 실패 시 0건 저장, 중간 예외 롤백은 통과했다. DTO 이름/serializer 위치/메서드 트랜잭션 규칙 3개 테스트, 이전 안내문을 예상한 Excel 단위 테스트 2개, OpenAPI 테스트 환경 설정 3개, 인증 없음의 실제 403을 401로 예상한 테스트 2개가 실패했다. 규칙은 유지하고 구현 이름·패키지와 메서드 선언, 공통 계약에 맞춘 테스트 및 문서를 수정한다. - Actions 2차(33942586977, a56f43913): 단위·Batch·아키텍처·Product 통합 및 벌크 HTTP 14개가 통과했다. 남은 OpenAPI 실패 2개는 성공 응답 envelope 중첩과 rows 최소 개수 0 표기였다. 공통 문서 처리기의 입력을 실제 data DTO로 바꾸고 @Size(min=1)를 명시했다. 같은 문제를 가진 Excel validate 문서도 수정하고 normalized 경로 회귀 검사를 추가했다. 400 응답의 application/json도 명시한다. + +## 최종 검증 결과 + +GitHub Actions [ci pipeline 33942921473](https://github.com/bottle-note/bottle-note-api-server/actions/runs/33942921473)이 구현 커밋 `359e058a674df722c6949613e3bd26c56521b6d5`에서 전체 성공했다. 아래 결과는 다운로드한 JUnit XML을 직접 집계했다. 이후 변경은 이 검증 결과를 기록한 문서뿐이다. + +| 검증 | 실행 명령 | 결과 | +| --- | --- | --- | +| 전체 단위 테스트 | `./gradlew unit_test` | 1,017개 성공 | +| Batch 인기도 테스트 | `./gradlew :bottlenote-batch:batch_test --tests "app.batch.bottlenote.job.popularity.*"` | 106개 성공 | +| 아키텍처 규칙 | `./gradlew check_rule_test` | 66개 성공 | +| Product 통합 테스트 | `./gradlew integration_test` | 369개 성공 | +| Admin 통합 테스트 | `./gradlew admin_integration_test` | 298개 성공 | +| 최종 빌드 | `./gradlew build -x test --build-cache --parallel` | 성공 | + +전체 1,856개이며 실패·오류·건너뛴 테스트는 각각 0개다. 이 중 벌크 정규화/서비스 88개, Excel 단위 23개, 벌크 HTTP 14개, 벌크·Excel OpenAPI 4개를 확인했다. + +- Excel: 실제 API에서 다운로드한 템플릿을 채워 업로드하고, normalized 행을 벌크 저장하여 DB 필드·태그·생성 ID·관리자 감사 주체를 검증했다. +- JSON: 직접 작성한 입력을 검증하고 같은 벌크 저장 API에 전달하여 단위 정규화와 선택 필드 null 저장을 검증했다. +- 오류/경고: 검증 결과 변조 시 전체 0건 저장, 두 번째 행 저장 중 예외 시 전체 롤백, DB 중복 경고가 있어도 새 ID로 등록, enum·참조·수량 오류 및 소수 ID 거절을 검증했다. +- 문서/인증: 실제 v1 경로, 1~1,000행, 생성 ID와 정규화 결과, 400 응답 두 형식 및 무인증 403을 검증했다. +- 배포와 운영/개발 DB 쓰기는 실행하지 않았다. Dashboard가 기존 행별 저장을 사용한다면 새 normalized/bulk 계약으로 연결하는 작업은 별도다. 이미지 bytes 업로드와 멱등성 저장소는 이번 범위에 없으며 반복 POST는 새 등록이다. + +Task 1~4를 완료했다. 공유 시크릿과 DB 스키마는 변경하지 않았으며, 로컬 최종 diff/status 점검에서 작업 범위 밖 변경을 제외했다. From f7135f8bf114767ee29360d90a2a54a17d2ab84f Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Sat, 5 Sep 2026 13:46:30 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=EC=97=91=EC=85=80=20=ED=96=89=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=EA=B3=BC=20=EB=8F=84=EC=88=98=20=EC=84=9C?= =?UTF-8?q?=EC=8B=9D=20=EB=B0=8F=20=EB=B2=8C=ED=81=AC=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../excel/AdminAlcoholExcelServiceImpl.kt | 64 +++++++++++++++-- .../alcohols/excel/AlcoholExcelSchema.kt | 4 +- .../excel/AdminAlcoholExcelServiceTest.kt | 68 +++++++++++++++++++ .../AdminAlcoholBulkIntegrationTest.kt | 28 ++++++++ .../domain/AlcoholQueryRepository.java | 5 +- .../dto/response/AlcoholBulkCategoryItem.java | 7 ++ .../exception/AlcoholExceptionCode.java | 3 +- .../repository/JpaAlcoholQueryRepository.java | 15 +++- .../DefaultAdminAlcoholBulkService.java | 9 ++- .../DefaultAdminAlcoholBulkServiceTest.java | 42 +++++++++++- .../InMemoryAlcoholQueryRepository.java | 12 +++- ...4\355\201\254 \353\223\261\353\241\235.md" | 13 ++++ 12 files changed, 255 insertions(+), 15 deletions(-) create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AlcoholBulkCategoryItem.java diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/excel/AdminAlcoholExcelServiceImpl.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/excel/AdminAlcoholExcelServiceImpl.kt index 703b1e424..8c4a6f5c2 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/excel/AdminAlcoholExcelServiceImpl.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/excel/AdminAlcoholExcelServiceImpl.kt @@ -15,7 +15,7 @@ import app.bottlenote.alcohols.dto.response.AdminAlcoholExcelRowResult import app.bottlenote.alcohols.dto.response.AdminAlcoholExcelValidateResponse import app.bottlenote.alcohols.dto.response.AdminDistilleryItem import app.bottlenote.alcohols.dto.response.AdminRegionItem -import app.bottlenote.alcohols.dto.response.AlcoholBulkReferenceItem +import app.bottlenote.alcohols.dto.response.AlcoholBulkCategoryItem import app.bottlenote.alcohols.dto.response.CategoryItem import app.bottlenote.alcohols.dto.response.TastingTagNodeItem import app.bottlenote.alcohols.excel.AlcoholExcelSchema.Column @@ -26,6 +26,7 @@ import org.apache.poi.ooxml.POIXMLException import org.apache.poi.openxml4j.exceptions.InvalidFormatException import org.apache.poi.openxml4j.opc.OPCPackage import org.apache.poi.openxml4j.util.ZipSecureFile +import org.apache.poi.ss.format.CellFormatPart import org.apache.poi.ss.usermodel.BorderStyle import org.apache.poi.ss.usermodel.Cell import org.apache.poi.ss.usermodel.CellStyle @@ -62,6 +63,8 @@ class AdminAlcoholExcelServiceImpl( ) : AdminAlcoholExcelService { companion object { private const val REFERENCE_PAGE_SIZE = 1_000 + private const val DESCRIPTION_MARKER = "AlcoholImportDescriptionRow" + private const val DESCRIPTION_REFERENCE = "'알코올 데이터'!\$A\$2:\$M\$2" private const val MAX_ZIP_ENTRIES = 200 private const val MAX_TOTAL_UNCOMPRESSED_BYTES = 50L * 1024 * 1024 @@ -121,6 +124,10 @@ class AdminAlcoholExcelServiceImpl( styles ) writeHeaderAndDescription(dataSheet, styles) + workbook.createName().apply { + nameName = DESCRIPTION_MARKER + refersToFormula = DESCRIPTION_REFERENCE + } addDropdownValidations( workbook = workbook, dataSheet = dataSheet, @@ -257,8 +264,18 @@ class AdminAlcoholExcelServiceImpl( val headerRow = dataSheet.getRow(AlcoholExcelSchema.HEADER_ROW_INDEX) ?: throw AlcoholException(AlcoholExceptionCode.EXCEL_HEADER_MISMATCH) - dataSheet.getRow(AlcoholExcelSchema.DESCRIPTION_ROW_INDEX) + val descriptionRow = dataSheet.getRow(AlcoholExcelSchema.DESCRIPTION_ROW_INDEX) ?: throw AlcoholException(AlcoholExceptionCode.EXCEL_DESCRIPTION_MISMATCH) + val marker = workbook.getName(DESCRIPTION_MARKER) + val recognizableLegacyDescription = AlcoholExcelSchema.DESCRIPTIONS.indices.any { + readRawCell(descriptionRow.getCell(it)) == AlcoholExcelSchema.DESCRIPTIONS[it] + } || + isCompletelyBlank(descriptionRow) + if ((marker != null && marker.refersToFormula != DESCRIPTION_REFERENCE) || + (marker == null && !recognizableLegacyDescription) + ) { + throw AlcoholException(AlcoholExceptionCode.EXCEL_DESCRIPTION_MISMATCH) + } val headers = AlcoholExcelSchema.HEADERS.indices.map { readRawCell(headerRow.getCell(it)) } if (headers != AlcoholExcelSchema.HEADERS) { @@ -520,6 +537,7 @@ class AdminAlcoholExcelServiceImpl( "6. 알코올 데이터: 실제 입력 시트", "", "[입력 규칙]", + "- 1행은 헤더, 2행은 설명입니다. 두 행은 삭제하지 말고 데이터는 3행부터 입력합니다.", "- 주류 종류와 카테고리 그룹은 한글 표시값 또는 enum 이름을 입력합니다.", "- 카테고리 ID는 그룹|한글|영문 형식입니다. 카테고리 그룹은 비워 두면 ID의 그룹을 자동 사용합니다.", "- 지역/증류소/테이스팅 태그는 ID를 입력합니다. 참조 시트는 안내용이므로 삭제하거나 순서를 바꿔도 됩니다.", @@ -656,7 +674,7 @@ class AdminAlcoholExcelServiceImpl( CellType.STRING -> cell.stringCellValue?.trim().orEmpty() CellType.NUMERIC -> { val value = BigDecimal.valueOf(cell.numericCellValue).stripTrailingZeros().toPlainString() - if (percentageFormatted && hasPercentageFormat(cell.cellStyle.dataFormatString)) { + if (percentageFormatted && hasPercentageFormat(applicableNumberFormat(cell.cellStyle.dataFormatString, cell.numericCellValue))) { BigDecimal(value).multiply(BigDecimal(100)).stripTrailingZeros().toPlainString() + "%" } else { value @@ -683,17 +701,52 @@ class AdminAlcoholExcelServiceImpl( } private fun loadBulkReferenceCategories(): List = alcoholQueryRepository - .findAllBulkReferenceItems() + .findBulkCategoryItems() .mapNotNull(::toCategoryItem) .distinctBy(::categoryStableId) - private fun toCategoryItem(item: AlcoholBulkReferenceItem): CategoryItem? { + private fun toCategoryItem(item: AlcoholBulkCategoryItem): CategoryItem? { val group = item.categoryGroup() ?: return null val korCategory = item.korCategory()?.takeIf(String::isNotBlank) ?: return null val engCategory = item.engCategory()?.takeIf(String::isNotBlank) ?: return null return CategoryItem(korCategory, engCategory, group) } + private fun applicableNumberFormat(format: String?, value: Double): String? { + if (format.isNullOrEmpty()) return format + val sections = mutableListOf() + var start = 0 + var quoted = false + var index = 0 + while (index < format.length) { + when (format[index]) { + '"' -> quoted = !quoted + '\\' -> index++ + '_', '*' -> if (!quoted) index++ + ';' -> if (!quoted) { + sections += format.substring(start, index) + start = index + 1 + } + } + index++ + } + sections += format.substring(start) + fun applies(section: String, fallback: Boolean): Boolean { + val parsed = CellFormatPart.FORMAT_PAT.matcher(section) + return if (parsed.matches() && parsed.group(CellFormatPart.CONDITION_OPERATOR_GROUP) != null) { + CellFormatPart(section).applies(value) + } else { + fallback + } + } + val first = sections[0] + if (applies(first, sections.size == 1 || if (sections.size == 2) value >= 0 else value > 0)) return first + if (sections.size == 1) return null + val second = sections[1] + if (applies(second, sections.size == 2 || value < 0)) return second + return sections.getOrNull(2) + } + private fun hasPercentageFormat(format: String?): Boolean { if (format.isNullOrEmpty()) return false var quoted = false @@ -702,6 +755,7 @@ class AdminAlcoholExcelServiceImpl( when (format[index]) { '"' -> quoted = !quoted '\\' -> index++ + '_', '*' -> if (!quoted) index++ '%' -> if (!quoted) return true } index++ diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/excel/AlcoholExcelSchema.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/excel/AlcoholExcelSchema.kt index 9f6ac6793..7db35fb92 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/excel/AlcoholExcelSchema.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/excel/AlcoholExcelSchema.kt @@ -12,7 +12,7 @@ package app.bottlenote.alcohols.excel * 5. 카테고리 * 6. 알코올 데이터 (실제 입력 시트) * - * 검증할 때는 알코올 데이터 시트와 첫 행의 정확한 13개 헤더만 필수다. + * 검증할 때는 알코올 데이터 시트, 첫 행의 정확한 13개 헤더와 두 번째 설명 행이 필수다. * 안내·참조 시트의 순서와 추가 메모 시트는 허용한다. * * 매핑 규칙: @@ -132,7 +132,7 @@ object AlcoholExcelSchema { 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", "외부 링크는 허용되지 않습니다."), diff --git a/bottlenote-admin-api/src/test/kotlin/app/bottlenote/alcohols/excel/AdminAlcoholExcelServiceTest.kt b/bottlenote-admin-api/src/test/kotlin/app/bottlenote/alcohols/excel/AdminAlcoholExcelServiceTest.kt index 4fb143449..d7352e309 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/bottlenote/alcohols/excel/AdminAlcoholExcelServiceTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/bottlenote/alcohols/excel/AdminAlcoholExcelServiceTest.kt @@ -30,6 +30,9 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource import org.springframework.mock.web.MockMultipartFile import org.springframework.test.util.ReflectionTestUtils import java.io.ByteArrayInputStream @@ -618,6 +621,71 @@ class AdminAlcoholExcelServiceTest { .isEqualTo(AlcoholExceptionCode.EXCEL_HEADER_MISMATCH) } + @ParameterizedTest + @ValueSource(ints = [1, 2]) + @DisplayName("설명 행을 삭제하면 첫 데이터를 누락시키지 않고 파일 오류를 반환한다") + fun validate_whenDescriptionRowDeleted_rejectsWithoutDroppingData(count: Int) { + for (legacy in listOf(false, true)) { + val file = workbookAsMultipart { workbook -> + if (legacy) workbook.removeName(workbook.getName("AlcoholImportDescriptionRow")) + repeat(count) { writeDataRow(workbook, validRowValues(), it + 2) } + val sheet = workbook.getSheet(AlcoholExcelSchema.DATA_SHEET_NAME) + sheet.removeRow(sheet.getRow(1)) + sheet.shiftRows(2, count + 1, -1) + } + assertThatThrownBy { service.validate(file) } + .isInstanceOf(AlcoholException::class.java) + .extracting("exceptionCode") + .isEqualTo(AlcoholExceptionCode.EXCEL_DESCRIPTION_MISMATCH) + } + assertThat(bulkService.receivedRequests).isEmpty() + } + + @Test + @DisplayName("설명 문구를 편집하거나 기존 템플릿을 사용해도 데이터 행을 검증한다") + fun validate_whenDescriptionEditedOrLegacy_acceptsData() { + val edited = workbookAsMultipart { workbook -> + val row = workbook.getSheet(AlcoholExcelSchema.DATA_SHEET_NAME).getRow(1) + row.forEach { it.setCellValue("자유롭게 수정한 안내 문구") } + writeDataRow(workbook, validRowValues()) + } + assertThat(service.validate(edited).totalRows).isEqualTo(1) + val legacy = workbookAsMultipart { workbook -> + workbook.removeName(workbook.getName("AlcoholImportDescriptionRow")) + writeDataRow(workbook, validRowValues()) + } + assertThat(service.validate(legacy).totalRows).isEqualTo(1) + } + + @ParameterizedTest + @CsvSource( + "40, 0.0;0.0%, 40", + "0.4, 0%;0.0, 40%", + "-0.4, 0.0;0%, -40%", + "0, 0%;0.0;0.0, 0", + "0, 0.0;0.0;0%, 0%", + "40, [<1]0%;0.0, 40", + "0.4, [<1]0%;0.0, 40%", + "40, [<1]0%;[>100]0%;0.0, 40", + "40, 0_% , 40", + "40, 0*% , 40", + "-0.4, \"[<1]\"0%;0.0, -0.4", + "40, 0.0\"; %\";0%, 40" + ) + @DisplayName("숫자에 실제 적용되는 서식 구간의 퍼센트만 도수 변환에 사용한다") + fun validate_whenNumberFormatHasSections_selectsApplicableSection(value: Double, format: String, expected: String) { + val file = workbookAsMultipart { workbook -> + writeDataRow(workbook, validRowValues()) + val cell = workbook.getSheet(AlcoholExcelSchema.DATA_SHEET_NAME).getRow(2).getCell(2) + cell.setCellValue(value) + cell.cellStyle = workbook.createCellStyle().apply { + dataFormat = workbook.createDataFormat().getFormat(format) + } + } + service.validate(file) + assertThat(bulkService.receivedRequests.single().rows().single().abv()).isEqualTo(expected) + } + @Test @DisplayName("퍼센트 서식 숫자 셀은 퍼센트 값으로, 일반 숫자 셀은 원래 값으로 공통 요청에 전달한다") fun validate_whenNumericPercentCell_convertsOnlyPercentageFormat() { diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholBulkIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholBulkIntegrationTest.kt index aa663749f..6778354e9 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholBulkIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholBulkIntegrationTest.kt @@ -4,6 +4,7 @@ import app.IntegrationTestSupport import app.bottlenote.alcohols.constant.AlcoholCategoryGroup import app.bottlenote.alcohols.constant.AlcoholType import app.bottlenote.alcohols.domain.Alcohol +import app.bottlenote.alcohols.domain.AlcoholQueryRepository import app.bottlenote.alcohols.domain.Distillery import app.bottlenote.alcohols.domain.Region import app.bottlenote.alcohols.excel.AlcoholExcelSchema @@ -43,6 +44,9 @@ class AdminAlcoholBulkIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var tastingTagTestFactory: TastingTagTestFactory + @Autowired + private lateinit var alcoholQueryRepository: AlcoholQueryRepository + @Autowired private lateinit var jdbcTemplate: JdbcTemplate @@ -60,6 +64,30 @@ class AdminAlcoholBulkIntegrationTest : IntegrationTestSupport() { accessToken = getAccessToken(admin) } + @Test + @DisplayName("카테고리는 모든 주종에서 중복 없이 조회하고 후보는 지정 증류소의 미삭제 주류만 조회한다") + fun bulkReferencesAreScopedAndCategoriesAreDistinct() { + val region = alcoholTestFactory.persistRegion() + val selected = alcoholTestFactory.persistDistillery() + val other = alcoholTestFactory.persistDistillery() + fun persist(distillery: Distillery, category: String, type: AlcoholType): Alcohol = alcoholTestFactory.persistAlcohol( + Alcohol.builder().type(type).korCategory(category).engCategory(category) + .categoryGroup(AlcoholCategoryGroup.OTHER).region(region).distillery(distillery) + ) + val included = persist(selected, "review rum", AlcoholType.RUM) + persist(selected, "review rum", AlcoholType.RUM) + val excluded = persist(other, "review wine", AlcoholType.WINE) + val deleted = persist(selected, "review deleted", AlcoholType.RUM) + jdbcTemplate.update("UPDATE alcohols SET deleted_at = NOW() WHERE id = ?", deleted.id) + + val candidates = alcoholQueryRepository.findBulkReferenceItemsByDistilleryIds(listOf(selected.id)) + assertThat(candidates.map { it.alcoholId() }).contains(included.id).doesNotContain(excluded.id, deleted.id) + assertThat(candidates.map { it.distilleryId() }).containsOnly(selected.id) + val categories = alcoholQueryRepository.findBulkCategoryItems() + assertThat(categories.count { it.korCategory() == "review rum" }).isEqualTo(1) + assertThat(categories.map { it.korCategory() }).contains("review wine").doesNotContain("review deleted") + } + @Nested @DisplayName("엑셀 검증 결과를 등록할 때") inner class ExcelRoundTrip { diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java index 063a42b59..e2cea332e 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java @@ -6,6 +6,7 @@ import app.bottlenote.alcohols.dto.dsl.ExploreStandardCriteria; import app.bottlenote.alcohols.dto.request.AdminAlcoholSearchRequest; import app.bottlenote.alcohols.dto.response.AdminAlcoholItem; +import app.bottlenote.alcohols.dto.response.AlcoholBulkCategoryItem; import app.bottlenote.alcohols.dto.response.AlcoholBulkReferenceItem; import app.bottlenote.alcohols.dto.response.AlcoholDetailItem; import app.bottlenote.alcohols.dto.response.AlcoholLookupItem; @@ -38,7 +39,9 @@ public interface AlcoholQueryRepository { List findAllLookupItems(); - List findAllBulkReferenceItems(); + List findBulkCategoryItems(); + + List findBulkReferenceItemsByDistilleryIds(List distilleryIds); List findAllMatchTargets(); diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AlcoholBulkCategoryItem.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AlcoholBulkCategoryItem.java new file mode 100644 index 000000000..d50a0c9ee --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AlcoholBulkCategoryItem.java @@ -0,0 +1,7 @@ +package app.bottlenote.alcohols.dto.response; + +import app.bottlenote.alcohols.constant.AlcoholCategoryGroup; +import app.bottlenote.alcohols.constant.AlcoholType; + +public record AlcoholBulkCategoryItem( + String korCategory, String engCategory, AlcoholCategoryGroup categoryGroup, AlcoholType type) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/exception/AlcoholExceptionCode.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/exception/AlcoholExceptionCode.java index e496f0023..6d6451105 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/exception/AlcoholExceptionCode.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/exception/AlcoholExceptionCode.java @@ -41,7 +41,8 @@ public enum AlcoholExceptionCode implements ExceptionCode { EXCEL_FILE_TOO_LARGE(HttpStatus.BAD_REQUEST, "엑셀 파일 크기는 5MiB를 초과할 수 없습니다."), EXCEL_SHEET_NOT_FOUND(HttpStatus.BAD_REQUEST, "필수 시트가 없거나 시트명이 올바르지 않습니다."), EXCEL_HEADER_MISMATCH(HttpStatus.BAD_REQUEST, "엑셀 헤더(1행)가 고정 템플릿과 일치하지 않습니다."), - EXCEL_DESCRIPTION_MISMATCH(HttpStatus.BAD_REQUEST, "엑셀 설명(2행)이 고정 템플릿과 일치하지 않습니다."), + EXCEL_DESCRIPTION_MISMATCH( + HttpStatus.BAD_REQUEST, "엑셀 설명 행(2행)을 확인할 수 없습니다. 템플릿을 다시 내려받아 데이터는 3행부터 입력해 주세요."), EXCEL_DUPLICATE_HEADER(HttpStatus.BAD_REQUEST, "엑셀 헤더에 중복된 필드명이 있습니다."), EXCEL_FORMULA_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "수식 셀은 허용되지 않습니다."), EXCEL_EXTERNAL_LINK_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "외부 링크는 허용되지 않습니다."), diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholQueryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholQueryRepository.java index d7d4162c2..649edbe06 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholQueryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholQueryRepository.java @@ -3,6 +3,7 @@ import app.bottlenote.alcohols.constant.AlcoholType; import app.bottlenote.alcohols.domain.Alcohol; import app.bottlenote.alcohols.domain.AlcoholQueryRepository; +import app.bottlenote.alcohols.dto.response.AlcoholBulkCategoryItem; import app.bottlenote.alcohols.dto.response.AlcoholBulkReferenceItem; import app.bottlenote.alcohols.dto.response.CategoryItem; import java.util.List; @@ -13,14 +14,24 @@ public interface JpaAlcoholQueryRepository extends AlcoholQueryRepository, JpaRepository, CustomAlcoholQueryRepository { + @Override + @Query( + """ + select distinct new app.bottlenote.alcohols.dto.response.AlcoholBulkCategoryItem( + a.korCategory, a.engCategory, a.categoryGroup, a.type) + from alcohol a where a.deletedAt is null + """) + List findBulkCategoryItems(); + @Override @Query( """ select new app.bottlenote.alcohols.dto.response.AlcoholBulkReferenceItem( a.id, a.korName, a.engName, a.korCategory, a.engCategory, a.categoryGroup, a.type, a.distillery.id, a.abv, a.volume) - from alcohol a where a.deletedAt is null order by a.id + from alcohol a where a.deletedAt is null and a.distillery.id in :distilleryIds order by a.id """) - List findAllBulkReferenceItems(); + List findBulkReferenceItemsByDistilleryIds( + @Param("distilleryIds") List distilleryIds); @Override @Query("select distinct a from alcohol a left join fetch a.region where a.id in :ids") diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholBulkService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholBulkService.java index eb10ef6f6..cda35187a 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholBulkService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholBulkService.java @@ -22,6 +22,7 @@ import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkIssueItem; import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkRowItem; import app.bottlenote.alcohols.dto.response.AdminAlcoholBulkValidateResponse; +import app.bottlenote.alcohols.dto.response.AlcoholBulkCategoryItem; import app.bottlenote.alcohols.dto.response.AlcoholBulkReferenceItem; import app.bottlenote.alcohols.exception.AlcoholException; import app.bottlenote.alcohols.exception.AlcoholExceptionCode; @@ -144,7 +145,7 @@ private References loadReferences(List rows) { Map> categories = new HashMap<>(); Map> categoryTypes = new HashMap<>(); Map> candidates = new HashMap<>(); - for (AlcoholBulkReferenceItem item : alcoholRepository.findAllBulkReferenceItems()) { + for (AlcoholBulkCategoryItem item : alcoholRepository.findBulkCategoryItems()) { if (item.categoryGroup() != null) { categories .computeIfAbsent( @@ -157,6 +158,12 @@ private References loadReferences(List rows) { categoryKey(item.korCategory(), item.engCategory()), ignored -> new HashSet<>()) .add(item.type()); } + } + List existing = + distilleryIds.isEmpty() + ? List.of() + : alcoholRepository.findBulkReferenceItemsByDistilleryIds(List.copyOf(distilleryIds)); + for (AlcoholBulkReferenceItem item : existing) { addCandidate( candidates, identity(item.korName(), item.distilleryId(), item.abv(), item.volume()), diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholBulkServiceTest.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholBulkServiceTest.java index a4e75f36a..7a06c2f25 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholBulkServiceTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/DefaultAdminAlcoholBulkServiceTest.java @@ -338,6 +338,41 @@ void setUp() { assertThat(alcohols.bulkReads).isEqualTo(1); } + @Test + @DisplayName("중복 후보 조회는 요청한 증류소로 제한하고 다른 증류소의 카테고리는 유지한다") + void 중복_후보_조회_범위를_제한한다() { + Distillery other = + distilleries.save(Distillery.builder().korName("다른 증류소").engName("Other").build()); + alcohols.save( + Alcohol.builder() + .korName("참조용") + .engName("Reference") + .type(AlcoholType.WHISKY) + .korCategory("싱글몰트") + .engCategory("Single Malts") + .categoryGroup(AlcoholCategoryGroup.SINGLE_MALT) + .distillery(other) + .abv("40") + .volume("700") + .build()); + Row row = new Row(); + row.group = null; + var result = validate(row); + assertThat(result.valid()).isTrue(); + assertThat(result.normalized().categoryGroup()).isEqualTo("SINGLE_MALT"); + assertThat(result.candidateAlcoholIds()).isEmpty(); + assertThat(alcohols.requestedDistilleryIds).containsExactly(1L); + } + + @Test + @DisplayName("유효한 증류소 ID가 없으면 중복 후보 조회를 생략한다") + void 증류소_없으면_후보를_조회하지_않는다() { + Row row = new Row(); + row.distilleryId = null; + assertThat(validate(row).valid()).isFalse(); + assertThat(alcohols.bulkReads).isZero(); + } + @Test @DisplayName("비위스키 기존 데이터가 있을 때 그룹 도출과 중복 후보에 반영한다") void 비위스키_기존_데이터도_참조한다() { @@ -462,11 +497,14 @@ AdminAlcoholBulkRowRequest build() { private static final class CountingAlcoholRepository extends InMemoryAlcoholQueryRepository { int bulkReads; + List requestedDistilleryIds = List.of(); @Override - public List findAllBulkReferenceItems() { + public List findBulkReferenceItemsByDistilleryIds( + List distilleryIds) { bulkReads++; - return super.findAllBulkReferenceItems(); + requestedDistilleryIds = List.copyOf(distilleryIds); + return super.findBulkReferenceItemsByDistilleryIds(distilleryIds); } } diff --git a/bottlenote-test-support/src/main/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java b/bottlenote-test-support/src/main/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java index 3e85e3dbd..c134713d9 100644 --- a/bottlenote-test-support/src/main/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java +++ b/bottlenote-test-support/src/main/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java @@ -7,6 +7,7 @@ import app.bottlenote.alcohols.dto.request.AdminAlcoholSearchRequest; import app.bottlenote.alcohols.dto.response.AdminAlcoholItem; import app.bottlenote.alcohols.dto.response.AlcoholBulkReferenceItem; +import app.bottlenote.alcohols.dto.response.AlcoholBulkCategoryItem; import app.bottlenote.alcohols.dto.response.AlcoholDetailItem; import app.bottlenote.alcohols.dto.response.AlcoholLookupItem; import app.bottlenote.alcohols.dto.response.CategoryItem; @@ -132,8 +133,17 @@ public List findAllLookupItems() { } @Override - public List findAllBulkReferenceItems() { + public List findBulkCategoryItems() { return alcohols.values().stream() + .filter(a -> !a.isDeleted()) + .map(a -> new AlcoholBulkCategoryItem(a.getKorCategory(), a.getEngCategory(), a.getCategoryGroup(), a.getType())) + .distinct().toList(); + } + + @Override + public List findBulkReferenceItemsByDistilleryIds(List distilleryIds) { + return alcohols.values().stream() + .filter(a -> a.getDistillery() != null && distilleryIds.contains(a.getDistillery().getId())) .filter(a -> !a.isDeleted()) .sorted(Comparator.comparing(Alcohol::getId)) .map( diff --git "a/plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" "b/plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" index 1bdfa361b..6c6688fc0 100644 --- "a/plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" +++ "b/plan/2026.09.05 \354\225\214\354\275\224\354\230\254 \353\262\214\355\201\254 \353\223\261\353\241\235.md" @@ -142,3 +142,16 @@ GitHub Actions [ci pipeline 33942921473](https://github.com/bottle-note/bottle-n - 배포와 운영/개발 DB 쓰기는 실행하지 않았다. Dashboard가 기존 행별 저장을 사용한다면 새 normalized/bulk 계약으로 연결하는 작업은 별도다. 이미지 bytes 업로드와 멱등성 저장소는 이번 범위에 없으며 반복 POST는 새 등록이다. Task 1~4를 완료했다. 공유 시크릿과 DB 스키마는 변경하지 않았으며, 로컬 최종 diff/status 점검에서 작업 범위 밖 변경을 제외했다. + + +## 자체 리뷰 후속 수정 + +2026.09.05 사용자의 수정 요청으로 리뷰의 P1 1건과 P2 2건을 수정한다. + +- [x] 새 Excel 템플릿의 설명 행에 defined name을 기록하여 행 삭제·이동을 식별한다. 기존 템플릿은 알려진 설명 문구 또는 빈 설명 행으로 식별하며 식별할 수 없으면 재다운로드 안내 오류를 반환한다. 새 템플릿의 설명 문구 편집은 허용한다. +- [x] 양수·음수·0 및 조건부 숫자 서식에서 실제 적용 구간만 백분율 변환에 사용한다. 원래 숫자의 정밀도는 유지한다. +- [x] 모든 주종의 카테고리는 DISTINCT 4필드 조회로 분리하고, 중복 후보는 요청 증류소 ID로 제한한다. 유효 증류소 ID가 없으면 후보를 조회하지 않는다. +- [x] 설명 행 삭제·기존 템플릿·설명 편집·숫자 서식·조회 범위 회귀 테스트를 추가한다. 실제 JPA에서 DISTINCT·주종·증류소·삭제 조건을 검증하는 통합 테스트를 추가한다. +- [ ] GitHub Actions에서 최종 코드의 전체 검증 결과를 확인한다. + +로컬 개발 검증: Excel 서비스 38건과 벌크 서비스 40건, 총 78건 통과(실패·오류·건너뜀 0건). 실제 DB 쿼리 검증은 GitHub Actions에서 수행한다. 스키마·API DTO 계약과 중복 경고 저장 정책은 유지한다.