Skip to content

Commit 80ae963

Browse files
Merge pull request #194 from SSASINSA/refactor/193_sprint_fix
feat: 행사별 옵션 최대 깊이 통일(#193)
2 parents 683fca9 + 21a4787 commit 80ae963

19 files changed

Lines changed: 292 additions & 63 deletions

build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ dependencies {
4242
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
4343
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
4444
implementation 'org.springframework.boot:spring-boot-starter-validation'
45+
implementation 'org.flywaydb:flyway-core'
46+
implementation 'org.flywaydb:flyway-mysql'
4547
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.4'
4648
implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
4749
implementation 'com.openhtmltopdf:openhtmltopdf-pdfbox:1.0.10'

src/main/java/com/ssasinsa/wearagain/domain/event/docs/EventApiDocs.java

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ private EventApiDocs() {
3333
summary = "관리자 행사 등록",
3434
description = """
3535
관리자 백오피스에서 행사 기본 정보, 이미지 배열, 옵션 트리를 등록합니다.
36-
이미지 URL과 옵션 구조는 사전에 검증되며, 저장 결과로 생성된 ID와 구조를 반환합니다.
36+
`optionDepth`(1~3)을 필수로 받고 depth에 맞지 않는 children 구조를 보내면 400을 반환합니다.
3737
""",
3838
requestExample = EventExamples.ADMIN_EVENT_CREATE_REQUEST,
3939
responseSchema = EventCreateResponse.class,
@@ -79,7 +79,7 @@ private EventApiDocs() {
7979
@ApiDoc(
8080
summary = "관리자 행사 상세 조회",
8181
description = """
82-
관리자 전용 상세 정보(이미지, 옵션 트리, 신청 목록 및 통계)와 행사 담당 관리자 정보를 반환합니다.
82+
관리자 전용 상세 정보(이미지, optionDepth, 옵션 트리, 신청 목록 및 통계)와 행사 담당 관리자 정보를 반환합니다.
8383
존재하지 않는 행사 ID 요청 시 404 에러를 반환합니다.
8484
""",
8585
responseSchema = EventAdminDetailResponse.class,
@@ -95,7 +95,8 @@ private EventApiDocs() {
9595
summary = "관리자 행사 수정",
9696
description = """
9797
행사 기본 정보, 이미지, 옵션 트리를 부분 갱신합니다.
98-
`null` 필드는 변경하지 않으며, 빈 배열을 전달하면 해당 목록을 모두 제거합니다.
98+
`optionDepth`가 null이면 기존 값을 유지하고, 값이 있으면 새 depth 규칙을 검증합니다.
99+
배열을 빈 값으로 전달하면 해당 목록을 모두 제거합니다.
99100
""",
100101
requestExample = EventExamples.ADMIN_EVENT_UPDATE_REQUEST,
101102
responseSchema = EventAdminDetailResponse.class,
@@ -216,8 +217,8 @@ private EventApiDocs() {
216217
@ApiDoc(
217218
summary = "사용자 행사 상세 조회",
218219
description = """
219-
단일 행사의 상세 정보와 이미지, 옵션 트리를 조회합니다.
220-
DRAFT/ARCHIVED 상태의 행사는 노출되지 않습니다.
220+
단일 행사의 상세 정보와 이미지, optionDepth, 옵션 트리를 조회합니다.
221+
옵션이 없으면 optionDepth는 0으로 내려가며, DRAFT/ARCHIVED 상태의 행사는 노출되지 않습니다.
221222
""",
222223
responseSchema = EventDetailResponse.class,
223224
responseExample = EventExamples.USER_EVENT_DETAIL_RESPONSE

src/main/java/com/ssasinsa/wearagain/domain/event/docs/EventExamples.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ private EventExamples() {
1414
"location": "서울시 마포구 연남동 223-14 2F",
1515
"startDate": "2025-11-10",
1616
"endDate": "2025-11-30",
17+
"optionDepth": 3,
1718
"images": [
1819
{
1920
"url": "https://cdn.wearagain.kr/events/123/main.jpg",
@@ -60,6 +61,7 @@ private EventExamples() {
6061
"organizerAdminName": "홍길동",
6162
"startDate": "2025-11-10",
6263
"endDate": "2025-11-30",
64+
"optionDepth": 3,
6365
"status": "DRAFT",
6466
"images": [
6567
{
@@ -274,6 +276,7 @@ private EventExamples() {
274276
"displayOrder": 1
275277
}
276278
],
279+
"optionDepth": 3,
277280
"options": [
278281
{
279282
"optionId": 2001,

src/main/java/com/ssasinsa/wearagain/domain/event/dto/admin/EventAdminCreateRequest.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import io.swagger.v3.oas.annotations.media.Schema;
44
import jakarta.validation.Valid;
5+
import jakarta.validation.constraints.Max;
6+
import jakarta.validation.constraints.Min;
57
import jakarta.validation.constraints.NotBlank;
68
import jakarta.validation.constraints.NotEmpty;
79
import jakarta.validation.constraints.NotNull;
@@ -43,6 +45,12 @@ public record EventAdminCreateRequest(
4345
@NotNull
4446
LocalDate endDate,
4547

48+
@Schema(description = "행사 옵션 최대 깊이 (1~3)", example = "2")
49+
@NotNull(message = "optionDepth는 필수 값입니다.")
50+
@Min(value = 1, message = "optionDepth는 1 이상이어야 합니다.")
51+
@Max(value = 3, message = "optionDepth는 3 이하로 설정해야 합니다.")
52+
Integer optionDepth,
53+
4654
@Schema(description = "행사 이미지 목록")
4755
@NotEmpty
4856
@Size(max = 10)

src/main/java/com/ssasinsa/wearagain/domain/event/dto/admin/EventAdminDetailResponse.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ public record EventAdminDetailResponse(
7575
@Schema(description = "이미지 목록")
7676
List<EventAdminImageResponse> images,
7777

78+
@Schema(description = "행사 옵션 최대 깊이", example = "3")
79+
int optionDepth,
80+
7881
@Schema(description = "옵션 트리")
7982
List<EventAdminOptionResponse> options,
8083

src/main/java/com/ssasinsa/wearagain/domain/event/dto/admin/EventAdminUpdateRequest.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import com.ssasinsa.wearagain.domain.event.entity.EventStatus;
44
import io.swagger.v3.oas.annotations.media.Schema;
55
import jakarta.validation.Valid;
6+
import jakarta.validation.constraints.Max;
7+
import jakarta.validation.constraints.Min;
68
import jakarta.validation.constraints.Positive;
79
import jakarta.validation.constraints.Size;
810
import java.time.LocalDate;
@@ -36,6 +38,11 @@ public record EventAdminUpdateRequest(
3638
@Schema(description = "행사 종료일", example = "2025-12-01")
3739
LocalDate endDate,
3840

41+
@Schema(description = "행사 옵션 최대 깊이 (1~3)", example = "2")
42+
@Min(value = 1, message = "optionDepth는 1 이상이어야 합니다.")
43+
@Max(value = 3, message = "optionDepth는 3 이하로 설정해야 합니다.")
44+
Integer optionDepth,
45+
3946
@Schema(description = "행사 상태", example = "OPEN")
4047
EventStatus status,
4148

src/main/java/com/ssasinsa/wearagain/domain/event/dto/response/EventCreateResponse.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ public record EventCreateResponse(
4949
@Schema(description = "행사 상태", example = "DRAFT")
5050
String status,
5151

52+
@Schema(description = "행사 옵션 최대 깊이", example = "2")
53+
int optionDepth,
54+
5255
@Schema(description = "행사 이미지 목록")
5356
List<EventCreateImageResponse> images,
5457

src/main/java/com/ssasinsa/wearagain/domain/event/dto/response/EventDetailResponse.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public record EventDetailResponse(
3636
@Schema(description = "이미지 목록")
3737
List<EventDetailImageResponse> images,
3838

39-
@Schema(description = "옵션 최대 깊이", example = "2")
39+
@Schema(description = "옵션 최대 깊이 (옵션이 없으면 0)", example = "2")
4040
int optionDepth,
4141

4242
@Schema(description = "옵션 트리")

src/main/java/com/ssasinsa/wearagain/domain/event/entity/Event.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ public class Event extends BaseTimeEntity {
8080
@BatchSize(size = 50)
8181
private List<EventImage> images = new ArrayList<>();
8282

83+
@Column(name = "option_depth", nullable = false)
84+
@Default
85+
private Integer optionDepth = 1;
86+
8387
@Column(name = "usage_guide", columnDefinition = "TEXT")
8488
private String usageGuide;
8589

@@ -108,7 +112,8 @@ public static Event create(
108112
EventStatus status,
109113
AdminUser organizerAdmin,
110114
String usageGuide,
111-
String precautions
115+
String precautions,
116+
Integer optionDepth
112117
) {
113118
Event event = Event.builder()
114119
.title(title)
@@ -120,6 +125,7 @@ public static Event create(
120125
.status(status == null ? EventStatus.DRAFT : status)
121126
.usageGuide(usageGuide)
122127
.precautions(precautions)
128+
.optionDepth(optionDepth == null ? 1 : optionDepth)
123129
.build();
124130

125131
return event;
@@ -193,6 +199,10 @@ public void updateStaffCode(String staffCode, LocalDateTime issuedAt) {
193199
this.staffCodeIssuedAt = issuedAt;
194200
}
195201

202+
public void updateOptionDepth(int optionDepth) {
203+
this.optionDepth = optionDepth;
204+
}
205+
196206
public void markScissorGrantCompleted(LocalDateTime completedAt) {
197207
this.scissorGranted = true;
198208
this.scissorGrantedAt = completedAt;

src/main/java/com/ssasinsa/wearagain/domain/event/service/EventAdminServiceImpl.java

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,8 @@ public EventCreateResponse createEvent(EventAdminCreateRequest request, Long adm
125125
status,
126126
organizer,
127127
normalizeText(request.usageGuide()),
128-
normalizeText(request.precautions())
128+
normalizeText(request.precautions()),
129+
request.optionDepth()
129130
);
130131

131132
List<EventAdminCreateImageRequest> createImages = request.images();
@@ -143,6 +144,7 @@ public EventCreateResponse createEvent(EventAdminCreateRequest request, Long adm
143144
event.assignImages(images);
144145

145146
List<EventAdminOptionRequest> optionRequests = convertCreateOptions(request.options());
147+
validateOptionDepthStructureForRequests(request.optionDepth(), optionRequests);
146148
List<EventOption> options = buildEventOptions(event, optionRequests);
147149
event.assignOptions(options);
148150

@@ -279,6 +281,7 @@ public EventAdminDetailResponse getEventDetail(Long eventId, Long adminId, Admin
279281
toOffset(event.getCreatedAt()),
280282
toOffset(event.getUpdatedAt()),
281283
images,
284+
event.getOptionDepth(),
282285
options,
283286
applications,
284287
impactAnalytics
@@ -318,14 +321,25 @@ public EventAdminDetailResponse updateEvent(Long eventId, EventAdminUpdateReques
318321
event.changeStatus(request.status());
319322
}
320323

324+
int targetOptionDepth = request.optionDepth() != null
325+
? request.optionDepth()
326+
: (event.getOptionDepth() == null ? 1 : event.getOptionDepth());
327+
321328
if (request.images() != null) {
322329
List<EventImage> images = buildEventImages(event, request.images());
323330
event.assignImages(images);
324331
}
325332

326333
if (request.options() != null) {
334+
validateOptionDepthStructureForRequests(targetOptionDepth, request.options());
327335
List<EventOption> options = buildEventOptions(event, request.options());
328336
event.assignOptions(options);
337+
} else if (request.optionDepth() != null) {
338+
validateOptionDepthStructureForEntities(targetOptionDepth, event.getOptions());
339+
}
340+
341+
if (request.optionDepth() != null) {
342+
event.updateOptionDepth(targetOptionDepth);
329343
}
330344

331345
if (requiresApproval(role)) {
@@ -516,6 +530,7 @@ private EventCreateResponse mapToCreateResponse(Event event) {
516530
event.getStartDate(),
517531
event.getEndDate(),
518532
event.getStatus().name(),
533+
event.getOptionDepth(),
519534
imageResponses,
520535
optionResponses,
521536
toOffset(event.getCreatedAt())
@@ -646,6 +661,96 @@ private List<EventOption> buildEventOptions(Event event, List<EventAdminOptionRe
646661
return options;
647662
}
648663

664+
private void validateOptionDepthStructureForRequests(int optionDepth, List<EventAdminOptionRequest> requests) {
665+
ensureOptionDepthRange(optionDepth);
666+
if (CollectionUtils.isEmpty(requests)) {
667+
throw new EventException(EventErrorCode.INVALID_OPTION_STRUCTURE);
668+
}
669+
boolean[] depthCovered = new boolean[optionDepth];
670+
Deque<OptionDepthContext<EventAdminOptionRequest>> stack = new ArrayDeque<>();
671+
for (EventAdminOptionRequest request : requests) {
672+
stack.push(new OptionDepthContext<>(request, 1));
673+
}
674+
while (!stack.isEmpty()) {
675+
OptionDepthContext<EventAdminOptionRequest> context = stack.pop();
676+
int depth = context.depth();
677+
if (depth > optionDepth) {
678+
throw new EventException(EventErrorCode.OPTION_DEPTH_LIMIT_EXCEEDED);
679+
}
680+
depthCovered[depth - 1] = true;
681+
682+
List<EventAdminOptionRequest> children = context.value().children();
683+
boolean hasChildren = !CollectionUtils.isEmpty(children);
684+
if (depth < optionDepth && !hasChildren) {
685+
throw new EventException(EventErrorCode.INVALID_OPTION_STRUCTURE);
686+
}
687+
if (depth == optionDepth && hasChildren) {
688+
throw new EventException(EventErrorCode.OPTION_DEPTH_LIMIT_EXCEEDED);
689+
}
690+
if (hasChildren) {
691+
for (EventAdminOptionRequest child : children) {
692+
stack.push(new OptionDepthContext<>(child, depth + 1));
693+
}
694+
}
695+
}
696+
ensureAllDepthsCovered(depthCovered);
697+
}
698+
699+
private void validateOptionDepthStructureForEntities(int optionDepth, List<EventOption> options) {
700+
ensureOptionDepthRange(optionDepth);
701+
List<EventOption> roots = toDistinctOptions(options)
702+
.stream()
703+
.filter(option -> option.getParentOption() == null)
704+
.toList();
705+
if (CollectionUtils.isEmpty(roots)) {
706+
throw new EventException(EventErrorCode.INVALID_OPTION_STRUCTURE);
707+
}
708+
boolean[] depthCovered = new boolean[optionDepth];
709+
Deque<OptionDepthContext<EventOption>> stack = new ArrayDeque<>();
710+
roots.forEach(root -> stack.push(new OptionDepthContext<>(root, 1)));
711+
712+
while (!stack.isEmpty()) {
713+
OptionDepthContext<EventOption> context = stack.pop();
714+
int depth = context.depth();
715+
if (depth > optionDepth) {
716+
throw new EventException(EventErrorCode.OPTION_DEPTH_LIMIT_EXCEEDED);
717+
}
718+
depthCovered[depth - 1] = true;
719+
720+
List<EventOption> children = toDistinctOptions(context.value().getChildOptions());
721+
boolean hasChildren = !CollectionUtils.isEmpty(children);
722+
if (depth < optionDepth && !hasChildren) {
723+
throw new EventException(EventErrorCode.INVALID_OPTION_STRUCTURE);
724+
}
725+
if (depth == optionDepth && hasChildren) {
726+
throw new EventException(EventErrorCode.OPTION_DEPTH_LIMIT_EXCEEDED);
727+
}
728+
if (hasChildren) {
729+
for (EventOption child : children) {
730+
stack.push(new OptionDepthContext<>(child, depth + 1));
731+
}
732+
}
733+
}
734+
ensureAllDepthsCovered(depthCovered);
735+
}
736+
737+
private void ensureOptionDepthRange(int optionDepth) {
738+
if (optionDepth < 1) {
739+
throw new EventException(EventErrorCode.INVALID_OPTION_STRUCTURE);
740+
}
741+
if (optionDepth > MAX_OPTION_DEPTH) {
742+
throw new EventException(EventErrorCode.OPTION_DEPTH_LIMIT_EXCEEDED);
743+
}
744+
}
745+
746+
private void ensureAllDepthsCovered(boolean[] depthCovered) {
747+
for (boolean covered : depthCovered) {
748+
if (!covered) {
749+
throw new EventException(EventErrorCode.INVALID_OPTION_STRUCTURE);
750+
}
751+
}
752+
}
753+
649754
private EventOption createOption(
650755
Event event,
651756
EventOption parent,
@@ -682,6 +787,9 @@ private EventOption createOption(
682787
return option;
683788
}
684789

790+
private record OptionDepthContext<T>(T value, int depth) {
791+
}
792+
685793
private static class OptionRequestValidator {
686794

687795
private static void validateOption(EventAdminOptionRequest request, int depth) {

0 commit comments

Comments
 (0)