diff --git a/src/main/java/org/runimo/runimo/user/controller/FeedbackController.java b/src/main/java/org/runimo/runimo/user/controller/FeedbackController.java new file mode 100644 index 0000000..780500c --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/controller/FeedbackController.java @@ -0,0 +1,45 @@ +package org.runimo.runimo.user.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.common.response.SuccessResponse; +import org.runimo.runimo.user.controller.request.FeedbackRequest; +import org.runimo.runimo.user.enums.UserHttpResponseCode; +import org.runimo.runimo.user.service.usecases.FeedbackUsecase; +import org.springframework.http.HttpStatus; +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; + +@Tag(name = "피드백 API") +@RestController +@RequestMapping("/api/v1/feedback") +@RequiredArgsConstructor +public class FeedbackController { + + private final FeedbackUsecase feedbackUsecase; + + @Operation(summary = "피드백 생성", description = "사용자가 피드백을 작성합니다.") + @ApiResponses( + value = { + @ApiResponse(responseCode = "201", description = "평가 생성 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터") + } + ) + @PostMapping + public ResponseEntity> createFeedback( + @UserId Long userId, + @Valid @RequestBody FeedbackRequest request + ) { + feedbackUsecase.createFeedback(FeedbackRequest.toCommand(userId, request)); + return ResponseEntity.status(HttpStatus.CREATED.value()).body( + SuccessResponse.messageOnly(UserHttpResponseCode.FEEDBACK_CREATED)); + } + +} diff --git a/src/main/java/org/runimo/runimo/user/controller/request/FeedbackRequest.java b/src/main/java/org/runimo/runimo/user/controller/request/FeedbackRequest.java new file mode 100644 index 0000000..5954188 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/controller/request/FeedbackRequest.java @@ -0,0 +1,24 @@ +package org.runimo.runimo.user.controller.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.hibernate.validator.constraints.Length; +import org.runimo.runimo.user.service.dto.command.FeedbackCommand; + +@Schema(description = "피드백 요청 DTO") +public record FeedbackRequest( + + @Schema(description = "평가지표 (1: 매우 불만족, 6: 매우 만족)", example = "3") + @Min(1) @Max(6) + Integer rate, + @Schema(description = "피드백 내용", example = "피드백 내용") + @Length(max = 100) + String feedback +) { + + public static FeedbackCommand toCommand(Long userId, FeedbackRequest request) { + return new FeedbackCommand(userId, request.rate(), request.feedback()); + } + +} diff --git a/src/main/java/org/runimo/runimo/user/domain/Feedback.java b/src/main/java/org/runimo/runimo/user/domain/Feedback.java new file mode 100644 index 0000000..bf47477 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/domain/Feedback.java @@ -0,0 +1,39 @@ +package org.runimo.runimo.user.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.runimo.runimo.common.CreateUpdateAuditEntity; + +@Table(name = "user_feedback") +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Feedback extends CreateUpdateAuditEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + @Column(name = "rate", nullable = false) + private Integer rate; + @Column(name = "content", nullable = false) + private String content; + + @Builder + public Feedback(Long userId, Integer rate, String content) { + this.userId = userId; + this.rate = rate; + this.content = content.trim(); + } + +} diff --git a/src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java b/src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java index f5fe713..95d935c 100644 --- a/src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java +++ b/src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java @@ -16,6 +16,7 @@ public enum UserHttpResponseCode implements CustomResponseCode { MY_INCUBATING_EGG_FETCHED(HttpStatus.OK, "부화기중인 알 조회 성공", "부화중인 알 조회 성공"), NOTIFICATION_ALLOW_UPDATED(HttpStatus.OK, "알림 허용 업데이트 성공", "알림 허용 업데이트 성공"), NOTIFICATION_ALLOW_FETCHED(HttpStatus.OK, "알림 허용 조회 성공", "알림 허용 조회 성공"), + FEEDBACK_CREATED(HttpStatus.CREATED, "피드백 생성 성공", "피드백 생성 성공"), LOGIN_FAIL_NOT_SIGN_IN(HttpStatus.NOT_FOUND , "로그인 실패 - 회원가입하지 않은 사용자", "로그인 실패 - 회원가입하지 않은 사용자"), diff --git a/src/main/java/org/runimo/runimo/user/repository/FeedbackRepository.java b/src/main/java/org/runimo/runimo/user/repository/FeedbackRepository.java new file mode 100644 index 0000000..24eadd9 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/repository/FeedbackRepository.java @@ -0,0 +1,8 @@ +package org.runimo.runimo.user.repository; + +import org.runimo.runimo.user.domain.Feedback; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FeedbackRepository extends JpaRepository { + +} \ No newline at end of file diff --git a/src/main/java/org/runimo/runimo/user/service/dto/command/FeedbackCommand.java b/src/main/java/org/runimo/runimo/user/service/dto/command/FeedbackCommand.java new file mode 100644 index 0000000..d432b00 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/dto/command/FeedbackCommand.java @@ -0,0 +1,9 @@ +package org.runimo.runimo.user.service.dto.command; + +public record FeedbackCommand( + Long userId, + Integer rate, + String content +) { + +} diff --git a/src/main/java/org/runimo/runimo/user/service/usecases/FeedbackUsecase.java b/src/main/java/org/runimo/runimo/user/service/usecases/FeedbackUsecase.java new file mode 100644 index 0000000..e85f486 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/usecases/FeedbackUsecase.java @@ -0,0 +1,8 @@ +package org.runimo.runimo.user.service.usecases; + +import org.runimo.runimo.user.service.dto.command.FeedbackCommand; + +public interface FeedbackUsecase { + + void createFeedback(FeedbackCommand command); +} diff --git a/src/main/java/org/runimo/runimo/user/service/usecases/FeedbackUsecaseImpl.java b/src/main/java/org/runimo/runimo/user/service/usecases/FeedbackUsecaseImpl.java new file mode 100644 index 0000000..96c3d10 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/usecases/FeedbackUsecaseImpl.java @@ -0,0 +1,26 @@ +package org.runimo.runimo.user.service.usecases; + +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.user.domain.Feedback; +import org.runimo.runimo.user.repository.FeedbackRepository; +import org.runimo.runimo.user.service.dto.command.FeedbackCommand; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class FeedbackUsecaseImpl implements FeedbackUsecase { + + private final FeedbackRepository feedbackRepository; + + @Override + @Transactional + public void createFeedback(FeedbackCommand command) { + Feedback feedback = Feedback.builder() + .userId(command.userId()) + .rate(command.rate()) + .content(command.content()) + .build(); + feedbackRepository.save(feedback); + } +} diff --git a/src/main/resources/db/migration/V202050709__add_unique_constraint_to_device_token.sql b/src/main/resources/db/migration/V20250709__add_unique_constraint_to_device_token.sql similarity index 100% rename from src/main/resources/db/migration/V202050709__add_unique_constraint_to_device_token.sql rename to src/main/resources/db/migration/V20250709__add_unique_constraint_to_device_token.sql diff --git a/src/main/resources/db/migration/V20250710__add_feedback_table.sql b/src/main/resources/db/migration/V20250710__add_feedback_table.sql new file mode 100644 index 0000000..7a70dc0 --- /dev/null +++ b/src/main/resources/db/migration/V20250710__add_feedback_table.sql @@ -0,0 +1,9 @@ +CREATE TABLE `user_feedback` +( + `id` BIGINT NOT NULL PRIMARY KEY AUTO_INCREMENT, + `user_id` BIGINT NOT NULL, + `rate` INT, + `content` VARCHAR(128), + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); diff --git a/src/test/java/org/runimo/runimo/CleanUpUtil.java b/src/test/java/org/runimo/runimo/CleanUpUtil.java index 78d0e25..fc03d52 100644 --- a/src/test/java/org/runimo/runimo/CleanUpUtil.java +++ b/src/test/java/org/runimo/runimo/CleanUpUtil.java @@ -15,7 +15,8 @@ public class CleanUpUtil { "user_love_point", "incubating_egg", "runimo", - "user_token" + "user_token", + "user_feedback" }; @Autowired diff --git a/src/test/java/org/runimo/runimo/user/api/FeedbackAcceptanceTest.java b/src/test/java/org/runimo/runimo/user/api/FeedbackAcceptanceTest.java new file mode 100644 index 0000000..b816acb --- /dev/null +++ b/src/test/java/org/runimo/runimo/user/api/FeedbackAcceptanceTest.java @@ -0,0 +1,74 @@ +package org.runimo.runimo.user.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.runimo.runimo.TestConsts.TEST_USER_UUID; + +import io.restassured.RestAssured; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.runimo.runimo.CleanUpUtil; +import org.runimo.runimo.TokenUtils; +import org.runimo.runimo.user.repository.FeedbackRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +public class FeedbackAcceptanceTest { + + @LocalServerPort + int port; + + @Autowired + private CleanUpUtil cleanUpUtil; + + @Autowired + private TokenUtils tokenUtils; + private String token; + @Autowired + private FeedbackRepository feedbackRepository; + + @BeforeEach + void setUp() { + RestAssured.port = port; + token = tokenUtils.createTokenByUserPublicId(TEST_USER_UUID); + } + + @AfterEach + void tearDown() { + cleanUpUtil.cleanUpUserInfos(); + } + + @Test + @Sql(scripts = "/sql/user_default_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 피드백_저장시_성공() { + // given + String feedbackBody = """ + { + "rate": 3, + "feedback": "이것은 피드백입니다." + } + """; + + // when & then + RestAssured.given() + .header("Authorization", token) + .contentType("application/json") + .body(feedbackBody) + .when() + .post("/api/v1/feedback") + .then() + .log().ifError() + .statusCode(HttpStatus.CREATED.value()); + + var savedFeedback = feedbackRepository.findById(1L).get(); + assertEquals("이것은 피드백입니다.", savedFeedback.getContent()); + assertEquals(3, savedFeedback.getRate()); + } + +} diff --git a/src/test/resources/db/migration/h2/V202050709__add_unique_constraint_to_device_token.sql b/src/test/resources/db/migration/h2/V20250709__add_unique_constraint_to_device_token.sql similarity index 100% rename from src/test/resources/db/migration/h2/V202050709__add_unique_constraint_to_device_token.sql rename to src/test/resources/db/migration/h2/V20250709__add_unique_constraint_to_device_token.sql diff --git a/src/test/resources/db/migration/h2/V20250710__add_feedback_table.sql b/src/test/resources/db/migration/h2/V20250710__add_feedback_table.sql new file mode 100644 index 0000000..7a70dc0 --- /dev/null +++ b/src/test/resources/db/migration/h2/V20250710__add_feedback_table.sql @@ -0,0 +1,9 @@ +CREATE TABLE `user_feedback` +( + `id` BIGINT NOT NULL PRIMARY KEY AUTO_INCREMENT, + `user_id` BIGINT NOT NULL, + `rate` INT, + `content` VARCHAR(128), + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); diff --git a/src/test/resources/sql/user_default_data.sql b/src/test/resources/sql/user_default_data.sql new file mode 100644 index 0000000..bb05a7b --- /dev/null +++ b/src/test/resources/sql/user_default_data.sql @@ -0,0 +1,31 @@ +-- 테스트용 기본 유저 (기존에 있다고 가정) +SET FOREIGN_KEY_CHECKS = 0; +TRUNCATE TABLE users; +INSERT INTO users (id, public_id, nickname, img_url, total_distance_in_meters, + total_time_in_seconds, created_at, + updated_at) +VALUES (1, 'test-user-uuid-1', 'Daniel', 'https://example.com/images/user1.png', 10000, 3600, NOW(), + NOW()); +SET FOREIGN_KEY_CHECKS = 1; + +TRUNCATE TABLE oauth_account; +INSERT INTO oauth_account (id, created_at, deleted_at, updated_at, provider, provider_id, user_id) +VALUES (1, NOW(), null, NOW(), 'KAKAO', 1234, 1); + +-- 기존 디바이스 토큰 데이터 +INSERT INTO user_token (id, user_id, device_token, platform, notification_allowed, created_at, + updated_at) +VALUES (1, 1, 'existing_device_token_12345', 'FCM', true, NOW(), NOW()); + +-- 보유 애정 +INSERT INTO user_love_point (id, user_id, amount, created_at, updated_at) +VALUES (1, 1, 0, NOW(), NOW()); + +-- 보유 아이템 +INSERT INTO user_item (id, user_id, item_id, quantity, created_at, updated_at) +VALUES (1001, 1, 1, 0, NOW(), NOW()), + (1002, 1, 2, 0, NOW(), NOW()); + + + +