Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
| 2026.09.07 | [통계 시계열은 표준 포맷 하나로 내리고 열린 버킷은 읽기 시점에 롤다운한다](<2026.09.07 통계 시계열은 표준 포맷 하나로 내리고 열린 버킷은 읽기 시점에 롤다운한다.md>) |
| 2026.10.12 | [위스키 부가 정보는 속성 원장 하나로 기록하고 맛 그룹은 태그 사전으로 둔다](<2026.10.12 위스키 부가 정보는 속성 원장 하나로 기록하고 맛 그룹은 태그 사전으로 둔다.md>) |
| 2026.10.16 | [주류 속성은 값과 조건을 가진 공통 사전과 수동 매핑으로 관리한다](<2026.10.16 주류 속성은 값과 조건을 가진 공통 사전과 수동 매핑으로 관리한다.md>) |
| 2026.10.24 | [사용자 시나리오는 무상태로 평가하고 답변은 브라우저에 보관한다](<2026.10.24 사용자 시나리오는 무상태로 평가하고 답변은 브라우저에 보관한다.md>) |

### 인증·보안

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import app.bottlenote.alcohols.dto.dsl.AlcoholCandidateCriteria;
import app.bottlenote.alcohols.dto.response.AdminAlcoholCandidateResponse.CandidateGroup;
import app.bottlenote.alcohols.dto.response.AdminAlcoholCandidateResponse.CandidateTag;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;

Expand All @@ -15,6 +16,13 @@ public interface AlcoholCandidateRepository {

List<Alcohol> findPage(AlcoholCandidateCriteria query);

List<BigDecimal> findQuartiles(AlcoholCandidateCriteria query, BigDecimal min, BigDecimal max);

List<Long> countAlternatives(
AlcoholCandidateCriteria base, List<AlcoholCandidateCriteria> alternatives);

List<Alcohol> findAfter(AlcoholCandidateCriteria query, Long afterId, int limit);

List<AlcoholAttributeMapping> findMappings(Long groupId, List<Long> alcoholIds);

List<TagRow> findTags(List<Long> alcoholIds);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ public void rejectUnknown(String key, JsonNode value) {
throw ScenarioException.invalid("답변에 미지원 필드가 있습니다: " + key);
}

public record Selection(ScenarioAnswer answer, boolean minExclusive, String label) {}

public record ScenarioContribution(
Long questionId,
AlcoholQuestionSource source,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,53 @@
package app.bottlenote.alcohols.dto.dsl;

import app.bottlenote.alcohols.constant.AlcoholCategoryGroup;
import app.bottlenote.alcohols.dto.request.AdminAlcoholCandidateRequest.Condition;
import app.bottlenote.alcohols.dto.request.AdminAlcoholCandidateRequest.NextQuestion;
import app.bottlenote.alcohols.constant.AlcoholQuestionSource;
import app.bottlenote.alcohols.constant.AttributeSearchOperator;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.util.List;

public record AlcoholCandidateCriteria(
Long groupId,
String keyword,
List<Condition> include,
List<Condition> exclude,
List<ConditionCriteria> include,
List<ConditionCriteria> exclude,
List<BaseFilterCriteria> filters,
NextQuestion nextQuestion,
NextQuestionCriteria nextQuestion,
int page,
int size) {
public record BaseFilterCriteria(
List<AlcoholCategoryGroup> categories,
BigDecimal abvFrom,
BigDecimal abvTo,
List<Long> tastingTagIds,
boolean requiresTasting) {}
boolean requiresTasting,
boolean abvFromExclusive) {
public BaseFilterCriteria(
List<AlcoholCategoryGroup> categories,
BigDecimal abvFrom,
BigDecimal abvTo,
List<Long> tastingTagIds,
boolean requiresTasting) {
this(categories, abvFrom, abvTo, tastingTagIds, requiresTasting, false);
}
}

public record ConditionCriteria(
@NotNull @Positive Long attributeId,
@NotNull AttributeSearchOperator operator,
@Size(max = 255) String value) {}

public record NextQuestionCriteria(
@NotNull AlcoholQuestionSource source,
@Positive Long attributeId,
@Size(max = 20) List<@NotNull @Valid RangeCriteria> ranges) {
public NextQuestionCriteria {
ranges = ranges == null ? List.of() : List.copyOf(ranges);
}
}

public record RangeCriteria(
@NotBlank @Size(max = 64) String key, BigDecimal from, BigDecimal to) {}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
package app.bottlenote.alcohols.dto.request;

import app.bottlenote.alcohols.constant.AlcoholCategoryGroup;
import app.bottlenote.alcohols.constant.AlcoholQuestionSource;
import app.bottlenote.alcohols.constant.AttributeSearchOperator;
import app.bottlenote.alcohols.dto.dsl.AlcoholCandidateCriteria.*;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
Expand All @@ -11,13 +10,13 @@
/** 그룹 후보 필터. include는 AND, exclude는 하나라도 만족하면 술 전체 제외. */
public record AdminAlcoholCandidateRequest(
@Size(max = 200) String keyword,
@Size(max = 20) List<@NotNull @Valid Condition> include,
@Size(max = 20) List<@NotNull @Valid Condition> exclude,
@Size(max = 20) List<@NotNull @Valid ConditionCriteria> include,
@Size(max = 20) List<@NotNull @Valid ConditionCriteria> exclude,
@Size(max = 6) List<@NotNull AlcoholCategoryGroup> categoryGroups,
@DecimalMin("0") @DecimalMax("100") BigDecimal abvFrom,
@DecimalMin("0") @DecimalMax("100") BigDecimal abvTo,
@Size(max = 20) List<@NotBlank @Size(max = 64) String> tastingGroupCodes,
@Valid NextQuestion nextQuestion,
@Valid NextQuestionCriteria nextQuestion,
@Min(0) Integer page,
@Min(1) @Max(100) Integer size) {

Expand All @@ -30,20 +29,4 @@ public record AdminAlcoholCandidateRequest(
keyword = keyword == null || keyword.isBlank() ? null : keyword.trim();
page = page == null ? 0 : page;
}

public record Condition(
@NotNull @Positive Long attributeId,
@NotNull AttributeSearchOperator operator,
@Size(max = 255) String value) {}

public record NextQuestion(
@NotNull AlcoholQuestionSource source,
@Positive Long attributeId,
@Size(max = 20) List<@NotNull @Valid Range> ranges) {
public NextQuestion {
ranges = ranges == null ? List.of() : List.copyOf(ranges);
}
}

public record Range(@NotBlank @Size(max = 64) String key, BigDecimal from, BigDecimal to) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package app.bottlenote.alcohols.dto.request;

import com.fasterxml.jackson.annotation.JsonCreator;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.List;

public record ScenarioEvaluateRequest(
@NotNull @Positive Long versionId,
@NotNull @Size(max = 100) List<@NotNull @Valid ScenarioSubmittedAnswer> answers,
@NotNull Mode mode,
@Positive Long viewQuestionId,
@Size(max = 4096) String cursor,
@Min(1) @Max(100) Integer size) {
public ScenarioEvaluateRequest {
size = size == null ? 20 : size;
}

public enum Mode {
QUESTION,
RESULT;

@JsonCreator
public static Mode parse(String value) {
return value == null ? null : valueOf(value);
}
}

public record ScenarioSubmittedAnswer(
@NotNull @Positive Long questionId, @NotBlank @Size(max = 2048) String optionId) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package app.bottlenote.alcohols.dto.response;

import app.bottlenote.alcohols.constant.ScenarioTypes.AnswerKind;
import app.bottlenote.global.pagination.KeysetPageResponse;
import java.math.BigDecimal;
import java.util.List;

public record ScenarioEvaluationResponse(
Long scenarioId,
String title,
Long versionId,
State state,
Long nextQuestionId,
List<Long> path,
List<ScenarioAnswerView> answers,
ScenarioQuestion question,
long candidateCount,
KeysetPageResponse<List<ScenarioCandidate>> candidates) {
public enum State {
QUESTION,
USER_FINISHED,
SCENARIO_END
}

public record ScenarioAnswerView(
Long questionId, String optionId, String label, boolean applied) {}

public record ScenarioQuestion(Long questionId, String title, List<ScenarioOption> options) {}

public record ScenarioOption(
String optionId,
String label,
AnswerKind kind,
BigDecimal min,
BigDecimal max,
boolean minExclusive,
long candidateCount,
boolean selectable) {}

public record ScenarioCandidate(
Long alcoholId,
String korName,
String engName,
String imageUrl,
String korCategory,
String abv) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,18 @@
import app.bottlenote.alcohols.constant.AttributeSearchOperator;
import app.bottlenote.alcohols.domain.*;
import app.bottlenote.alcohols.dto.dsl.AlcoholCandidateCriteria;
import app.bottlenote.alcohols.dto.request.AdminAlcoholCandidateRequest.Condition;
import app.bottlenote.alcohols.dto.dsl.AlcoholCandidateCriteria.ConditionCriteria;
import app.bottlenote.alcohols.dto.response.AdminAlcoholCandidateResponse.CandidateGroup;
import app.bottlenote.alcohols.dto.response.AdminAlcoholCandidateResponse.CandidateTag;
import app.bottlenote.common.annotation.JpaRepositoryImpl;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.Expression;
import com.querydsl.core.types.Projections;
import com.querydsl.core.types.dsl.*;
import com.querydsl.jpa.JPAExpressions;
import com.querydsl.jpa.impl.JPAQueryFactory;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -73,6 +75,69 @@ public List<Alcohol> findPage(AlcoholCandidateCriteria query) {
.fetch();
}

@Override
public List<BigDecimal> findQuartiles(
AlcoholCandidateCriteria query, BigDecimal min, BigDecimal max) {
var next = query.nextQuestion();
var number =
next.source() == AlcoholQuestionSource.ABV
? abvNumber()
: Expressions.numberTemplate(
BigDecimal.class,
"cast({0} as BigDecimal(24,6))",
alcoholAttributeMapping.attributeValue);
var values =
factory.select(number).from(alcohol).where(predicate(query), number.between(min, max));
if (next.source() == AlcoholQuestionSource.ATTRIBUTE) {
values
.join(alcoholAttributeMapping)
.on(
alcoholAttributeMapping.alcoholId.eq(alcohol.id),
alcoholAttributeMapping.attributeId.eq(next.attributeId()));
query.include().stream()
.filter(
c -> c.attributeId().equals(next.attributeId()) && c.operator().isNumericComparison())
.forEach(c -> values.where(numericValue(alcoholAttributeMapping, c)));
}
Long count = values.clone().select(number.count()).fetchOne();
if (count == null || count == 0) return List.of();
List<BigDecimal> cuts = new ArrayList<>();
for (int quartile = 1; quartile < 4; quartile++) {
cuts.add(
values
.clone()
.orderBy(number.asc())
.offset((count * quartile - 1) / 4)
.limit(1)
.fetchOne());
}
return List.copyOf(cuts);
}

@Override
public List<Long> countAlternatives(
AlcoholCandidateCriteria base, List<AlcoholCandidateCriteria> alternatives) {
if (alternatives.isEmpty()) return List.of();
var counts =
alternatives.stream()
.map(q -> new CaseBuilder().when(predicate(q)).then(1L).otherwise(0L).sumLong())
.toList();
var row =
factory
.select(counts.toArray(Expression<?>[]::new))
.from(alcohol)
.where(predicate(base))
.fetchOne();
return counts.stream().map(c -> row == null || row.get(c) == null ? 0L : row.get(c)).toList();
}

@Override
public List<Alcohol> findAfter(AlcoholCandidateCriteria query, Long afterId, int limit) {
var where = predicate(query);
if (afterId != null) where.and(alcohol.id.gt(afterId));
return factory.selectFrom(alcohol).where(where).orderBy(alcohol.id.asc()).limit(limit).fetch();
}

@Override
public List<AlcoholAttributeMapping> findMappings(Long groupId, List<Long> ids) {
if (ids.isEmpty()) return List.of();
Expand Down Expand Up @@ -202,7 +267,7 @@ private BooleanBuilder predicate(AlcoholCandidateCriteria query) {
var ranges =
r.include().stream()
.filter(c -> c.operator().isNumericComparison())
.collect(java.util.stream.Collectors.groupingBy(Condition::attributeId));
.collect(java.util.stream.Collectors.groupingBy(ConditionCriteria::attributeId));
for (var conditions : ranges.values()) {
var m = new QAlcoholAttributeMapping("candidateRangeMapping");
var range =
Expand All @@ -219,7 +284,11 @@ private BooleanBuilder predicate(AlcoholCandidateCriteria query) {
if (filter.abvFrom() != null || filter.abvTo() != null) {
var number = abvNumber();
where.and(number.between(BigDecimal.ZERO, new BigDecimal("100")));
if (filter.abvFrom() != null) where.and(number.goe(filter.abvFrom()));
if (filter.abvFrom() != null)
where.and(
filter.abvFromExclusive()
? number.gt(filter.abvFrom())
: number.goe(filter.abvFrom()));
if (filter.abvTo() != null) where.and(number.loe(filter.abvTo()));
}
if (filter.requiresTasting()) {
Expand All @@ -245,7 +314,7 @@ private NumberExpression<BigDecimal> abvNumber() {
"^[0-9]+([.][0-9]+)?[[:space:]]*%?$");
}

private BooleanExpression condition(Condition c) {
private BooleanExpression condition(ConditionCriteria c) {
var m = new QAlcoholAttributeMapping("candidateConditionMapping");
var base =
JPAExpressions.selectOne()
Expand Down Expand Up @@ -276,7 +345,8 @@ private BooleanExpression condition(Condition c) {
return base.where(value).exists();
}

private BooleanExpression numericValue(QAlcoholAttributeMapping mapping, Condition condition) {
private BooleanExpression numericValue(
QAlcoholAttributeMapping mapping, ConditionCriteria condition) {
var n =
Expressions.numberTemplate(
BigDecimal.class, "cast({0} as BigDecimal(24,6))", mapping.attributeValue);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
import app.bottlenote.alcohols.constant.AlcoholQuestionSource;
import app.bottlenote.alcohols.domain.*;
import app.bottlenote.alcohols.dto.dsl.AlcoholCandidateCriteria;
import app.bottlenote.alcohols.dto.dsl.AlcoholCandidateCriteria.*;
import app.bottlenote.alcohols.dto.request.AdminAlcoholAttributeConditionRequest;
import app.bottlenote.alcohols.dto.request.AdminAlcoholCandidateRequest;
import app.bottlenote.alcohols.dto.request.AdminAlcoholCandidateRequest.*;
import app.bottlenote.alcohols.dto.request.AdminAlcoholCompoundSearchRequest;
import app.bottlenote.alcohols.dto.request.AdminAttributedAlcoholSearchRequest;
import app.bottlenote.alcohols.dto.response.AdminAlcoholAttributeFacetsResponse;
Expand Down Expand Up @@ -353,13 +353,13 @@ private AdminAlcoholCandidateRequest normalize(
r.size());
}

private List<Condition> normalizeConditions(
List<Condition> conditions, Map<Long, AlcoholAttribute> definitions) {
private List<ConditionCriteria> normalizeConditions(
List<ConditionCriteria> conditions, Map<Long, AlcoholAttribute> definitions) {
return conditions.stream()
.map(
c -> {
var attribute = requireDefinition(c.attributeId(), definitions);
return new Condition(
return new ConditionCriteria(
c.attributeId(),
c.operator(),
attribute.normalizeSearchValue(c.operator(), c.value()));
Expand Down
Loading
Loading