Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feat] 관리자 이벤트 조회 기능 구현 (#32) #36

Merged
merged 8 commits into from
Aug 7, 2024
4 changes: 4 additions & 0 deletions src/main/java/hyundai/softeer/orange/config/AppConfig.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package hyundai.softeer.orange.config;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
Expand All @@ -15,6 +16,9 @@ public ObjectMapper objectMapper() {
objectMapper.registerModule(new JavaTimeModule());
// timestamp를 문자열로 전달
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
// serialization 시 값 없는 필드 = null을 노출하지 않도록 제외. 문제가 되는 경우 구체적인 dto로 이동할 예정.
// 참고: https://www.baeldung.com/jackson-ignore-null-fields
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 EventDto는 이벤트 종류에 따라 다른 객체를 추가적으로 포함하는 형태로 구현되어 있습니다. 예를 들어 선착순 이벤트라면 fcfs를, 추첨 이벤트라면 draw 객체를 포함합니다. 이때, EventDto를 보낼 때 지정되지 않은 필드가 null로 표현되어 클라이언트 측에서는 필요 없는 필드가 null로 지정되어 페이로드를 분석하기 불편한 상황입니다. 따라서 지정되지 않은 필드를 직렬화 과정에서 생략하도록 만들어 클라이언트 입장에서 필요 없는 필드를 보지 않게 만들었습니다.

return objectMapper;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
package hyundai.softeer.orange.event.common;

import java.util.Set;

public class EventConst {
public static final String REDIS_KEY_PREFIX = "@event_key:";

// 검색 기능 관련 상수들
public static final int EVENT_DEFAULT_PAGE = 0;
public static final int EVENT_DEFAULT_SIZE = 5;
public static final Set<String> sortableFields = Set.of("eventId", "name", "startTime", "endTime", "eventType");
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이벤트와 관련된 상수를 분리하여 여러 곳에서 사용되더라도 한번에 수정할 수 있도록 했습니다.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package hyundai.softeer.orange.event.common.component.query;

import java.util.HashMap;
import java.util.Map;

public class EventSearchQueryParser {
public static Map<String, String> parse(String searchQuery) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 프로젝트에서 이벤트 검색 시 정렬 쿼리는 field1:(asc|desc)?,field(asc|desc) 형식으로 표현되고 있습니다. 이때, 정렬 쿼리가 프로젝트 내 다른 api에서도 사용될 수 있다고 생각하여 별도의 클래스로 분리했습니다. 정렬 이외의 기능에도 사용할 수 있도록 asc / desc를 직접 검사하는 대신 순수하게 key - value 분리하여 제공하도록 구현했습니다.

Map<String, String> map = new HashMap<>();
// searchQuery가 null이면 빈 map 반환
if (searchQuery == null) return map;

String[] queries = searchQuery.split(",");

for (String query : queries) {
if (query.isBlank()) continue;
String[] pair = query.split(":");
if(pair.length <= 0 || pair.length > 2) continue; // 값이 없거나 넘치면 무시.
String key = pair[0].trim();
String value;
if(pair.length > 1) value = pair[1].trim();
else value = "";

map.put(key, value);
}
return map;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
import hyundai.softeer.orange.core.auth.Auth;
import hyundai.softeer.orange.core.auth.AuthRole;
import hyundai.softeer.orange.event.common.service.EventService;
import hyundai.softeer.orange.event.dto.BriefEventDto;
import hyundai.softeer.orange.event.dto.EventDto;
import hyundai.softeer.orange.event.dto.EventFrameCreateRequest;
import hyundai.softeer.orange.event.dto.group.EventEditGroup;
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 jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
Expand All @@ -16,62 +19,95 @@
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
* 이벤트 관련 CRUD를 다루는 API
*/
@Slf4j
@RequiredArgsConstructor
@RequestMapping("/api/v1/event")
@RequestMapping("/api/v1/events")
@RestController
public class EventController {
private final EventService eventService;
/**
*
* @param search 검색어
* @param sort 정렬 기준. (eventId|name|startTime|endTime|eventType)(:(asc|desc))? 패턴이 ,로 나뉘는 형태. ex) eventId,name:asc,startTime:desc
* @param page 페이지 번호
* @param size 한번에 검색하는 이벤트 개수
* @return 요청한 이벤트 리스트
*/
@Auth({AuthRole.admin})
@GetMapping
@Operation(summary = "이벤트 리스트 획득", description = "관리자가 이벤트 목록을 검색한다. 검색어, sort 기준 등을 정의할 수 있다.", responses = {
@ApiResponse(responseCode = "200", description = "성공적으로 이벤트 목록을 반환한다"),
@ApiResponse(responseCode = "5xx", description = "서버 내부적 에러"),
@ApiResponse(responseCode = "4xx", description = "클라이언트 에러 (보통 page / size 값을 잘못 지정. 숫자가 아닌 경우 등) ")
})
Comment on lines +43 to +47
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추후에 응답 객체까지 지정해주시면 감사하겠습니다:)

public ResponseEntity<List<BriefEventDto>> getEvents(
@RequestParam(required = false) String search,
@RequestParam(required = false) String sort,
@RequestParam(required = false) Integer page,
@RequestParam(required = false) Integer size) {
List<BriefEventDto> events = eventService.searchEvents(search, sort, page, size);
return ResponseEntity.ok(events);
}

@Auth({AuthRole.admin})
@PostMapping
@Operation(summary = "이벤트 생성", description = "관리자가 이벤트를 새롭게 등록한다", responses = {
@ApiResponse(responseCode = "201", description = "이벤트 생성 성공"),
@ApiResponse(responseCode = "4xx", description = "유저 측 실수로 이벤트 생성 실패")
})
public ResponseEntity<?> createEvent(@Validated @RequestBody EventDto eventDto) {
public ResponseEntity<Void> createEvent(@Validated @RequestBody EventDto eventDto) {
eventService.createEvent(eventDto);
return ResponseEntity.status(HttpStatus.CREATED).build();
}

/**
*
* @param eventId 이벤트 ID. HD000000~로 시작하는 그것
* @return 해당 이벤트에 대한 정보
*/
@Auth({AuthRole.admin})
@GetMapping("/edit")
@Operation(summary = "이벤트 수정 초기 데이터 획득", description = "이벤트 정보 수정을 위해 초기 정보를 받는다", responses = {
@GetMapping("{eventId}")
@Operation(summary = "이벤트 데이터 획득", description = "이벤트 초기 정보를 받는다", responses = {
@ApiResponse(responseCode = "200", description = "이벤트 정보를 정상적으로 받음"),
@ApiResponse(responseCode = "404", description = "대응되는 이벤트가 존재하지 않음")
})
public ResponseEntity<EventDto> getEventEditData(
@RequestParam("eventId") String eventId
public ResponseEntity<EventDto> getEventData(
@PathVariable("eventId") String eventId
) {
EventDto eventInfo = eventService.getEventInfo(eventId);
return ResponseEntity.ok(eventInfo);
}

/**
* @param eventDto 수정된 이벤트 정보
*/
@Auth({AuthRole.admin})
@PostMapping("/edit")
@Operation(summary = "이벤트 수정", description = "관리자가 이벤트를 수정한다", responses = {
@ApiResponse(responseCode = "200", description = "이벤트 생성 성공"),
@ApiResponse(responseCode = "4xx", description = "유저 측 실수로 이벤트 생성 실패")
})
public ResponseEntity<?> editEvent(
@Validated({EventEditGroup.class}) @RequestBody EventDto eventDto
) {
public ResponseEntity<Void> editEvent(
@Validated({EventEditGroup.class}) @RequestBody EventDto eventDto) {
eventService.editEvent(eventDto);
return ResponseEntity.ok().build();
}



/**
* @param req 이벤트 프레임 생성을 위한 json
*/
@Auth({AuthRole.admin})
@PostMapping("/frame")
@Operation(summary = "이벤트 프레임 생성", description = "관리자가 이벤트 프레임을 새롭게 등록한다", responses = {
@ApiResponse(responseCode = "201", description = "이벤트 프레임 생성 성공"),
@ApiResponse(responseCode = "4xx", description = "이벤트 프레임 생성 실패")
})
public ResponseEntity<?> createEventFrame(@Valid @RequestBody EventFrameCreateRequest req) {
public ResponseEntity<Void> createEventFrame(@Valid @RequestBody EventFrameCreateRequest req) {
eventService.createEventFrame(req.getName());
return ResponseEntity.status(HttpStatus.CREATED).build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public class EventFrame {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column
@Column(unique = true, nullable = false)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

동일한 이름의 이벤트 프레임이 생성되는 문제가 있어 name을 유니크 필드로 지정했습니다.

private String name;

@OneToMany(mappedBy="eventFrame")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

import hyundai.softeer.orange.event.common.entity.EventMetadata;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface EventMetadataRepository extends JpaRepository<EventMetadata, Long> {
public interface EventMetadataRepository extends JpaRepository<EventMetadata, Long>, JpaSpecificationExecutor<EventMetadata> {
Optional<EventMetadata> findFirstByEventId(String eventId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package hyundai.softeer.orange.event.common.repository;

import hyundai.softeer.orange.event.common.entity.EventMetadata;
import jakarta.persistence.criteria.Predicate;
import org.springframework.data.jpa.domain.Specification;

public class EventSpecification {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spring jpa specification을 이용하여 동적 쿼리를 구현하기 위해 Specification 코드를 작성했습니다. eventId 검색 부분은 댓글 검색 기능에도 거의 동일한 로직으로 사용되므로, 추후 서로 다른 Specification으로 분리하는 것이 나을 것 같네요.

public static Specification<EventMetadata> withSearch(String search) {
return (metadata, query, cb) -> {
if (search == null || search.isEmpty()) return cb.conjunction();

Predicate searchName = cb.like(metadata.get("name"), "%" + search + "%");
Predicate searchEventId = cb.like(metadata.get("eventId"), "%" + search + "%");
return cb.or(searchName, searchEventId);
};
}
}
Original file line number Diff line number Diff line change
@@ -1,22 +1,31 @@
package hyundai.softeer.orange.event.common.service;

import hyundai.softeer.orange.common.ErrorCode;
import hyundai.softeer.orange.event.common.EventConst;
import hyundai.softeer.orange.event.common.component.eventFieldMapper.EventFieldMapperMatcher;
import hyundai.softeer.orange.event.common.component.eventFieldMapper.mapper.EventFieldMapper;
import hyundai.softeer.orange.event.common.component.query.EventSearchQueryParser;
import hyundai.softeer.orange.event.common.entity.EventFrame;
import hyundai.softeer.orange.event.common.entity.EventMetadata;
import hyundai.softeer.orange.event.common.enums.EventStatus;
import hyundai.softeer.orange.event.common.enums.EventType;
import hyundai.softeer.orange.event.common.exception.EventException;
import hyundai.softeer.orange.event.common.repository.EventFrameRepository;
import hyundai.softeer.orange.event.common.repository.EventMetadataRepository;
import hyundai.softeer.orange.event.common.repository.EventSpecification;
import hyundai.softeer.orange.event.component.EventKeyGenerator;
import hyundai.softeer.orange.event.dto.BriefEventDto;
import hyundai.softeer.orange.event.dto.EventDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

/**
Expand All @@ -31,6 +40,10 @@ public class EventService {
private final EventFieldMapperMatcher mapperMatcher;
private final EventKeyGenerator keyGenerator;

/**
* 이벤트를 생성한다.
* @param eventDto 이벤트 dto
*/
@Transactional
public void createEvent(EventDto eventDto) {
// 1. eventframe을 찾는다. 없으면 작업이 의미 X
Expand Down Expand Up @@ -62,6 +75,10 @@ public void createEvent(EventDto eventDto) {
emRepository.save(eventMetadata);
}

/**
* 이벤트를 수정한다.
* @param eventDto 수정 데이터가 담긴 이벤트 dto
*/
@Transactional
public void editEvent(EventDto eventDto) {
String eventId = eventDto.getEventId();
Expand All @@ -81,6 +98,11 @@ public void editEvent(EventDto eventDto) {
emRepository.save(eventMetadata);
}

/**
* 이벤트에 대한 초기 데이터 정보를 제공한다.
* @param eventId 요청한 이벤트의 id
* @return 이벤트 내용을 담은 dto
*/
@Transactional(readOnly = true)
public EventDto getEventInfo(String eventId) {
Optional<EventMetadata> metadataOpt = emRepository.findFirstByEventId(eventId);
Expand All @@ -91,7 +113,6 @@ public EventDto getEventInfo(String eventId) {
if(mapper == null) throw new EventException(ErrorCode.INVALID_EVENT_TYPE);

EventDto eventDto = EventDto.builder()
.id(metadata.getId())
.eventId(metadata.getEventId())
.name(metadata.getName())
.description(metadata.getDescription())
Expand All @@ -105,6 +126,58 @@ public EventDto getEventInfo(String eventId) {
return eventDto;
}

/**
* 매칭되는 이벤트를 탐색한다
* @param search 이벤트 검색 내용
* @param sortQuery 정렬 내용이 담긴 쿼리
* @param page 현재 페이지
* @param size 페이지의 크기
* @return 매칭된 이벤트 목록
*/
@Transactional(readOnly = true)
public List<BriefEventDto> searchEvents(String search, String sortQuery, Integer page, Integer size) {

List<Sort.Order> orders = new ArrayList<>();
for(var entries: EventSearchQueryParser.parse(sortQuery).entrySet()){
String field = entries.getKey();
String value = entries.getValue().toLowerCase();

if(!EventConst.sortableFields.contains(field)) continue;
switch (value) {
case "asc": case "":
orders.add(Sort.Order.asc(field));
break;
case "desc":
orders.add(Sort.Order.desc(field));
break;
}
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sort를 만드는 객체 자체를 만드는 것도 좋을 것 같다는 생각이 드네요. 나중에 분리해서 리팩토링해봐야겠습니다.

Sort sort = Sort.by(orders);

PageRequest pageInfo = PageRequest.of(
page != null ? page : EventConst.EVENT_DEFAULT_PAGE,
size != null ? size : EventConst.EVENT_DEFAULT_SIZE,
sort
);

var withSearch = EventSpecification.withSearch(search);
Page<EventMetadata> eventPage = emRepository.findAll(withSearch, pageInfo);
List<EventMetadata> events = eventPage.getContent();

return events.stream().map(
it -> BriefEventDto.of(
it.getEventId(),
it.getName(),
it.getStartTime(),
it.getEndTime(),
it.getEventType()
)).toList();
}

/**
* 이벤트 프레임을 생성한다.
* @param name 이벤트 프레임의 이름
*/
@Transactional
public void createEventFrame(String name) {
EventFrame eventFrame = EventFrame.of(name);
Expand Down
Loading
Loading