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

✨ 문의하기 API #36

Merged
merged 44 commits into from
Apr 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
b1dc050
feat: question 엔티티, repository 작성
asn6878 Apr 4, 2024
7a0a59b
feat: mail 발송 로직 임시 작성
asn6878 Apr 4, 2024
0defa67
fix: http 메서드 수정
asn6878 Apr 4, 2024
2b79e80
feat: question 예외 작성
asn6878 Apr 6, 2024
b628dc8
feat: question service 작성
asn6878 Apr 6, 2024
7571a8f
feat: 문의 발송 api 작성
asn6878 Apr 6, 2024
1353fae
fix: 문의 발송 내용 수정
asn6878 Apr 7, 2024
f5e78d7
feat: 필요한 환경변수 설정
asn6878 Apr 7, 2024
44258e1
docs: swagger 문서 작성 및 분리
asn6878 Apr 7, 2024
bbe30db
fix: 라이브러리 버전 명시
asn6878 Apr 8, 2024
f1c01cb
docs: swagger parameter 제거 및 schema 내용 수정
asn6878 Apr 8, 2024
df0f1fb
fix: 컨벤션에 따른 uri 수정
asn6878 Apr 8, 2024
4e1de3e
fix: controller 메소드 인가 권한 수정
asn6878 Apr 8, 2024
59a2e66
fix: dto 필드별 schema 작성
asn6878 Apr 8, 2024
1e7d533
fix: dto email 필드 유효성 검사 추가
asn6878 Apr 8, 2024
905cec3
fix: category(enum) 필드 유효성 검사 처리
asn6878 Apr 8, 2024
120c5de
fix: restful 원칙에 따른 request uri 수정
asn6878 Apr 9, 2024
57d8595
fix: dto inner class 제거
asn6878 Apr 9, 2024
248fc85
fix: transactional 어노테이션 변경
asn6878 Apr 9, 2024
a1fc836
fix: email_error 오탈자 수정
asn6878 Apr 10, 2024
02f5417
fix: 공통 허용 endpoint 선언
asn6878 Apr 10, 2024
601c53c
fix: createddate 를 사용하기 위한 entitylistners 추가
asn6878 Apr 10, 2024
47d866c
fix: domainservice 연결
asn6878 Apr 10, 2024
2677d45
fix: swagger schema 오탈자 수정 및 enum 설명 제거
asn6878 Apr 10, 2024
c6d1eb8
fix: transactional 어노테이션 추가
asn6878 Apr 10, 2024
fc9a3db
fix: @schema 어노테이션 제거
asn6878 Apr 13, 2024
19e22aa
fix: questioncategory enum converter 적용
asn6878 Apr 13, 2024
2f0bb95
fix: sendquestion 응답 nocontent로 변경
asn6878 Apr 13, 2024
014f875
refactor: starter-mail 의존성 이동
asn6878 Apr 15, 2024
d584a89
refactor: starter-mail 의존성 구성 속성 변수 이동
asn6878 Apr 15, 2024
7ad53cc
refactor: 메일발송 로직 infra 모듈 이전
asn6878 Apr 16, 2024
3412ba8
fix: 임시 로그 제거
asn6878 Apr 16, 2024
2041340
refactor: 의존성 주입을 위한 mailconfig 수정
asn6878 Apr 17, 2024
1542870
chore: dev, question 브랜치 병합
asn6878 Apr 17, 2024
646b3d2
refactor: transactionaleventlistener를 활용한 메일발송 이벤트처리
asn6878 Apr 17, 2024
ee6fa76
test: 테스트 작성
asn6878 Apr 17, 2024
4d53780
fix: mockbean 누락 오류 수정
asn6878 Apr 17, 2024
d444b1e
fix: 테스팅간 로직 임시 주석처리 복구
asn6878 Apr 17, 2024
9eae3ee
fix: 불필요한 getter 제거
asn6878 Apr 19, 2024
67f7c9e
fix: main핸들링 log 수준 변경
asn6878 Apr 19, 2024
2ae3a8f
feat: admin_address 환경변수 기본값 추가
asn6878 Apr 19, 2024
b28333c
fix: 메일 발송 이벤트 실패시 로그 레벨 변경
asn6878 Apr 20, 2024
bb2358d
feat: swagger 성공 응답 명시
asn6878 Apr 20, 2024
c2d40f5
Merge branch 'dev' into feat/PW-115-question
asn6878 Apr 20, 2024
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
psychology50 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package kr.co.pennyway.api.apis.question.api;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import kr.co.pennyway.api.apis.question.dto.QuestionReq;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RequestBody;

@Tag(name = "[문의 API]")
public interface QuestionApi {

@Operation(summary = "문의 전송", description = "사용자는 관리자에게 문의 메일을 발송한다.")
@ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", examples = {
@ExampleObject(name = "발신 성공", value = """
{
"code": "2000",
"data": {}
}
""")
}))
ResponseEntity<?> sendQuestion(@RequestBody @Validated QuestionReq request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package kr.co.pennyway.api.apis.question.controller;

import kr.co.pennyway.api.apis.question.api.QuestionApi;
import kr.co.pennyway.api.apis.question.dto.QuestionReq;
import kr.co.pennyway.api.apis.question.usecase.QuestionUseCase;
import kr.co.pennyway.api.common.response.SuccessResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
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;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/v1/questions")
public class QuestionController implements QuestionApi {
private final QuestionUseCase questionUseCase;

@Override
@PostMapping("")
@PreAuthorize("permitAll()")
public ResponseEntity<?> sendQuestion(@RequestBody @Validated QuestionReq request) {
questionUseCase.sendQuestion(request);
return ResponseEntity.ok(SuccessResponse.noContent());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package kr.co.pennyway.api.apis.question.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import kr.co.pennyway.domain.domains.question.domain.Question;
import kr.co.pennyway.domain.domains.question.domain.QuestionCategory;

public record QuestionReq(
@Schema(description = "문의자 이메일", example = "[email protected]")
@NotBlank(message = "이메일을 입력해주세요")
@Email(message = "이메일 형식이 올바르지 않습니다.")
String email,
@Schema(description = "문의 내용", example = "문의 내용입니다.")
@NotBlank(message = "문의 내용을 입력해주세요")
String content,
@Schema(description = "문의 카테고리", example = "UTILIZATION")
@NotNull(message = "문의 카테고리를 입력해주세요")
QuestionCategory category
) {
public Question toEntity() {
return Question.builder()
.email(email)
.content(content)
.category(category)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package kr.co.pennyway.api.apis.question.usecase;

import jakarta.transaction.Transactional;
import kr.co.pennyway.api.apis.question.dto.QuestionReq;
import kr.co.pennyway.common.annotation.UseCase;
import kr.co.pennyway.domain.domains.question.domain.Question;
import kr.co.pennyway.domain.domains.question.service.QuestionService;
import kr.co.pennyway.infra.common.event.MailEvent;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;

@Slf4j
@UseCase
@AllArgsConstructor
public class QuestionUseCase {
private final QuestionService questionService;
private final ApplicationEventPublisher applicationEventPublisher;

@Transactional
public void sendQuestion(QuestionReq request) {
Question question = request.toEntity();

questionService.createQuestion(question);
applicationEventPublisher.publishEvent(MailEvent.of(request.email(), request.content(), request.category().getTitle()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@
@RequiredArgsConstructor
public class SecurityConfig {
private static final String[] READ_ONLY_PUBLIC_ENDPOINTS = {"/favicon.ico", "/v1/duplicate/**"};
private static final String[] PUBLIC_ENDPOINTS = {"/v1/questions/**"};
private static final String[] ANONYMOUS_ENDPOINTS = {"/v1/auth/**", "/v1/phone/**"};
private static final String[] SWAGGER_ENDPOINTS = {"/api-docs/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger",};


private final SecurityAdapterConfig securityAdapterConfig;
private final CorsConfigurationSource corsConfigurationSource;
private final AccessDeniedHandler accessDeniedHandler;
Expand Down Expand Up @@ -79,6 +81,7 @@ private AbstractRequestMatcherRegistry<AuthorizeHttpRequestsConfigurer<HttpSecur
return auth.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.requestMatchers(HttpMethod.OPTIONS, "*").permitAll()
.requestMatchers(HttpMethod.GET, READ_ONLY_PUBLIC_ENDPOINTS).permitAll()
.requestMatchers(PUBLIC_ENDPOINTS).permitAll()
.requestMatchers(ANONYMOUS_ENDPOINTS).anonymous();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package kr.co.pennyway.api.apis.question.controller;

import com.fasterxml.jackson.databind.ObjectMapper;
import kr.co.pennyway.api.apis.question.dto.QuestionReq;
import kr.co.pennyway.api.apis.question.usecase.QuestionUseCase;
import kr.co.pennyway.domain.domains.question.domain.QuestionCategory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(controllers = QuestionController.class)
@ActiveProfiles("local")
public class QuestionControllerTest {

private final String expectedEmail = "[email protected]";
private final String expectedContent = "test question content";
private final QuestionCategory expectedCategory = QuestionCategory.ETC;
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private QuestionUseCase questionUseCase;

@BeforeEach
void setUp(WebApplicationContext webApplicationContext) {
this.mockMvc = MockMvcBuilders
.webAppContextSetup(webApplicationContext)
.defaultRequest(post("/**"))//.with(csrf()))
.build();
}

@Test
@DisplayName("[1] 이메일, 내용을 필수로 입력해야 합니다.")
void requiredInputError() throws Exception {
// given
QuestionReq request = new QuestionReq("", "", QuestionCategory.ETC);

// when
ResultActions resultActions = mockMvc.perform(
post("/v1/questions")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
);

// then
resultActions
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.fieldErrors.email").value("이메일을 입력해주세요"))
.andExpect(jsonPath("$.fieldErrors.content").value("문의 내용을 입력해주세요"))
.andDo(print());
}

@Test
@DisplayName("[2] 이메일 형식 오류입니다.")
void emailValidError() throws Exception {
// given
QuestionReq request = new QuestionReq("test", "test question content", QuestionCategory.ETC);

// when
ResultActions resultActions = mockMvc.perform(
post("/v1/questions")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
);

// then
resultActions
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.fieldErrors.email").value("이메일 형식이 올바르지 않습니다."))
.andDo(print());
}

@Test
@DisplayName(("[3] 문의 카테고리를 선택해주세요."))
void categoryMissingError() throws Exception {
// given
QuestionReq request = new QuestionReq("[email protected]", "test question content", null);

// when
ResultActions resultActions = mockMvc.perform(
post("/v1/questions")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
);

// then
resultActions
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.fieldErrors.category").value("문의 카테고리를 입력해주세요"))
.andDo(print());
}

@Test
@DisplayName("[4] 정상적인 문의 요청입니다.")
void sendQuestion() throws Exception {
// given
QuestionReq request = new QuestionReq(expectedEmail, expectedContent, expectedCategory);

// when
ResultActions resultActions = mockMvc.perform(
post("/v1/questions")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
);

// then
resultActions
.andExpect(status().isOk())
.andDo(print());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package kr.co.pennyway.domain.common.converter;

import jakarta.persistence.Converter;
import kr.co.pennyway.domain.domains.question.domain.QuestionCategory;

@Converter
public class QuestionCategoryConverter extends AbstractLegacyEnumAttributeConverter<QuestionCategory> {
private static final String ENUM_NAME = "문의 카테고리";

public QuestionCategoryConverter() {
super(QuestionCategory.class, false, ENUM_NAME);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package kr.co.pennyway.domain.domains.question.domain;

import jakarta.persistence.*;
import kr.co.pennyway.domain.common.converter.QuestionCategoryConverter;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

@Entity
@Getter
@Table(name = "Question")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
public class Question {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String email;
@Convert(converter = QuestionCategoryConverter.class)
@Column(nullable = false)
private QuestionCategory category;
psychology50 marked this conversation as resolved.
Show resolved Hide resolved
private String content;
@CreatedDate
psychology50 marked this conversation as resolved.
Show resolved Hide resolved
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
private LocalDateTime deletedAt;

@Builder
private Question(String email, QuestionCategory category, String content, LocalDateTime createdAt, LocalDateTime deletedAt) {
this.email = email;
this.category = category;
this.content = content;
this.createdAt = createdAt;
}
}
asn6878 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package kr.co.pennyway.domain.domains.question.domain;

import kr.co.pennyway.domain.common.converter.LegacyCommonType;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.RequiredArgsConstructor;


@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public enum QuestionCategory implements LegacyCommonType {
UTILIZATION("1", "이용 관련"),
BUG_REPORT("2", "오류 신고"),
SUGGESTION("3", "서비스 제안"),
ETC("4", "기타");

private final String code;
private final String title;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package kr.co.pennyway.domain.domains.question.exception;

import kr.co.pennyway.common.exception.BaseErrorCode;
import kr.co.pennyway.common.exception.CausedBy;
import kr.co.pennyway.common.exception.ReasonCode;
import kr.co.pennyway.common.exception.StatusCode;
import lombok.AllArgsConstructor;

@AllArgsConstructor
psychology50 marked this conversation as resolved.
Show resolved Hide resolved
public enum QuestionErrorCode implements BaseErrorCode {
INTERNAL_MAIL_ERROR(StatusCode.INTERNAL_SERVER_ERROR, ReasonCode.UNEXPECTED_ERROR, "메일 발송에 실패했습니다.");

private final StatusCode statusCode;
private final ReasonCode reasonCode;
private final String message;

@Override
public CausedBy causedBy() {
return CausedBy.of(statusCode, reasonCode);
}

@Override
public String getExplainError() throws NoSuchFieldError {
return message;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package kr.co.pennyway.domain.domains.question.exception;

import kr.co.pennyway.common.exception.BaseErrorCode;
import kr.co.pennyway.common.exception.CausedBy;
import kr.co.pennyway.common.exception.GlobalErrorException;
import kr.co.pennyway.domain.domains.user.exception.UserErrorCode;
import lombok.Getter;

public class QuestionErrorException extends GlobalErrorException {
private final QuestionErrorCode questionErrorCode;

public QuestionErrorException(QuestionErrorCode questionErrorCode) {
super(questionErrorCode);
this.questionErrorCode = questionErrorCode;
}

public CausedBy causedBy() {
return questionErrorCode.causedBy();
}

public String getExplainError() {
return questionErrorCode.getExplainError();
}
}
Loading
Loading