diff --git a/build.gradle b/build.gradle index 6a63314..13e1b22 100644 --- a/build.gradle +++ b/build.gradle @@ -42,6 +42,13 @@ dependencies { annotationProcessor "io.github.openfeign.querydsl:querydsl-apt:7.0:jpa" annotationProcessor "jakarta.persistence:jakarta.persistence-api" annotationProcessor "jakarta.annotation:jakarta.annotation-api" + + // Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.13' + + // Validation + implementation 'org.springframework.boot:spring-boot-starter-validation' } tasks.named('test') { diff --git a/src/main/java/com/example/UMC/domain/mission/controller/MissionController.java b/src/main/java/com/example/UMC/domain/mission/controller/MissionController.java new file mode 100644 index 0000000..e78b452 --- /dev/null +++ b/src/main/java/com/example/UMC/domain/mission/controller/MissionController.java @@ -0,0 +1,28 @@ +package com.example.UMC.domain.mission.controller; + +import com.example.UMC.domain.mission.dto.response.MissionChallengeResponse; +import com.example.UMC.domain.mission.service.MissionService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/my/missions") +public class MissionController { + + private final MissionService missionService; + + /** + * [POST] /api/my/missions/{missionId}/challenge + * 미션 도전하기 + */ + @PostMapping("/{missionId}/challenge") + public ResponseEntity challengeMission( + @RequestHeader("X-USER-ID") Long userId, + @PathVariable Long missionId + ) { + MissionChallengeResponse response = missionService.challengeMission(userId, missionId); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/example/UMC/domain/mission/dto/response/MissionChallengeResponse.java b/src/main/java/com/example/UMC/domain/mission/dto/response/MissionChallengeResponse.java new file mode 100644 index 0000000..58e4e59 --- /dev/null +++ b/src/main/java/com/example/UMC/domain/mission/dto/response/MissionChallengeResponse.java @@ -0,0 +1,12 @@ +package com.example.UMC.domain.mission.dto.response; + +import com.example.UMC.domain.enums.entity.MissionStatus; +import lombok.Builder; + +@Builder +public record MissionChallengeResponse( + Long userMissionId, + Long missionId, + Long userId, + MissionStatus status +) {} diff --git a/src/main/java/com/example/UMC/domain/mission/exception/MissionException.java b/src/main/java/com/example/UMC/domain/mission/exception/MissionException.java new file mode 100644 index 0000000..b8836d9 --- /dev/null +++ b/src/main/java/com/example/UMC/domain/mission/exception/MissionException.java @@ -0,0 +1,10 @@ +package com.example.UMC.domain.mission.exception; + +import com.example.UMC.global.apiPayload.code.BaseErrorCode; +import com.example.UMC.global.apiPayload.exception.GeneralException; + +public class MissionException extends GeneralException { + public MissionException(BaseErrorCode code) { + super(code); + } +} diff --git a/src/main/java/com/example/UMC/domain/mission/exception/code/MissionErrorCode.java b/src/main/java/com/example/UMC/domain/mission/exception/code/MissionErrorCode.java new file mode 100644 index 0000000..68b5a74 --- /dev/null +++ b/src/main/java/com/example/UMC/domain/mission/exception/code/MissionErrorCode.java @@ -0,0 +1,19 @@ +package com.example.UMC.domain.mission.exception.code; + +import com.example.UMC.global.apiPayload.code.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum MissionErrorCode implements BaseErrorCode { + + MISSION_NOT_FOUND(HttpStatus.NOT_FOUND, "MISSION4004", "해당 미션을 찾을 수 없습니다."), + MISSION_ALREADY_CHALLENGING(HttpStatus.CONFLICT, "MISSION4090", "이미 도전 중인 미션입니다."), + MISSION_ALREADY_COMPLETED(HttpStatus.CONFLICT, "MISSION4091", "이미 완료한 미션입니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/example/UMC/domain/mission/service/MissionService.java b/src/main/java/com/example/UMC/domain/mission/service/MissionService.java new file mode 100644 index 0000000..0945487 --- /dev/null +++ b/src/main/java/com/example/UMC/domain/mission/service/MissionService.java @@ -0,0 +1,75 @@ +package com.example.UMC.domain.mission.service; + +import com.example.UMC.domain.enums.entity.MissionStatus; +import com.example.UMC.domain.mission.dto.response.MissionChallengeResponse; +import com.example.UMC.domain.mission.entity.Mission; +import com.example.UMC.domain.mission.entity.UserMission; +import com.example.UMC.domain.mission.exception.MissionException; +import com.example.UMC.domain.mission.exception.code.MissionErrorCode; +import com.example.UMC.domain.mission.repository.MissionRepository; +import com.example.UMC.domain.mission.repository.UserMissionRepository; +import com.example.UMC.domain.user.entity.User; +import com.example.UMC.domain.user.exception.UserException; +import com.example.UMC.domain.user.exception.code.UserErrorCode; +import com.example.UMC.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class MissionService { + private final MissionRepository missionRepository; + private final UserRepository userRepository; + private final UserMissionRepository userMissionRepository; + + /** + * 미션 도전하기 API + */ + @Transactional + public MissionChallengeResponse challengeMission(Long userId, Long missionId) { + + //유저 확인 + User user = userRepository.findById(userId) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); + + //미션 확인 + Mission mission = missionRepository.findById(missionId) + .orElseThrow(() -> new MissionException(MissionErrorCode.MISSION_NOT_FOUND)); + + //유저가 이미 같은 미션을 도전 중이거나 완료했는지 확인 + var existing = userMissionRepository.findAllByUser(user, null) + .stream() + .filter(um -> um.getMission().getId().equals(missionId)) + .findFirst(); + + if (existing.isPresent()) { + var current = existing.get(); + if (current.getStatus() == MissionStatus.PROCESS) + throw new MissionException(MissionErrorCode.MISSION_ALREADY_CHALLENGING); + if (current.getStatus() == MissionStatus.COMPLETE) + throw new MissionException(MissionErrorCode.MISSION_ALREADY_COMPLETED); + } + + //새로운 UserMisson 생성 + UserMission newChanllenge=UserMission.builder() + .user(user) + .mission(mission) + .region(mission.getRegion()) + .status(MissionStatus.PROCESS) + .challengeAt(LocalDateTime.now()) + .build(); + + UserMission saved = userMissionRepository.save(newChanllenge); + + //응답 반환 + return MissionChallengeResponse.builder() + .userMissionId(saved.getId()) + .missionId(mission.getId()) + .userId(user.getId()) + .status(saved.getStatus()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/UMC/domain/review/controller/ReviewController.java b/src/main/java/com/example/UMC/domain/review/controller/ReviewController.java index 47d2daa..0aed354 100644 --- a/src/main/java/com/example/UMC/domain/review/controller/ReviewController.java +++ b/src/main/java/com/example/UMC/domain/review/controller/ReviewController.java @@ -1,19 +1,38 @@ package com.example.UMC.domain.review.controller; +import com.example.UMC.domain.review.dto.request.ReviewCreateRequest; +import com.example.UMC.domain.review.dto.response.ReviewResponse; import com.example.UMC.domain.review.entity.Review; import com.example.UMC.domain.review.repository.ReviewQueryRepository; +import com.example.UMC.domain.review.service.ReviewService; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @RestController @RequiredArgsConstructor -@RequestMapping("/reviews") +@RequestMapping("/api/stores") public class ReviewController { private final ReviewQueryRepository reviewQueryRepository; + private final ReviewService reviewService; + + /** + * [POST] /api/stores/{storeId}/reviews + * 가게에 리뷰 작성 API + */ + @PostMapping("/{storeId}/reviews") + public ResponseEntity createReview( + @RequestHeader("X-USER-ID") Long userId, + @PathVariable Long storeId, + @RequestBody @Valid ReviewCreateRequest request) { + ReviewResponse response = reviewService.createReview(userId, storeId, request); + return ResponseEntity.ok(response); + } // 태스트 컨틀로러 @@ -30,6 +49,7 @@ public Page getReviews( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size ) { + Pageable pageable = PageRequest.of(page, size); return reviewQueryRepository.findReviews(storeId, storeName, regionId, star, pageable); } diff --git a/src/main/java/com/example/UMC/domain/review/dto/request/ReviewCreateRequest.java b/src/main/java/com/example/UMC/domain/review/dto/request/ReviewCreateRequest.java new file mode 100644 index 0000000..fddb1bf --- /dev/null +++ b/src/main/java/com/example/UMC/domain/review/dto/request/ReviewCreateRequest.java @@ -0,0 +1,16 @@ +package com.example.UMC.domain.review.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record ReviewCreateRequest( + @Min(1) @Max(5) + Integer rating, + + @NotBlank + @Size(max = 1000) + String content + ) { +} diff --git a/src/main/java/com/example/UMC/domain/review/dto/response/ReviewResponse.java b/src/main/java/com/example/UMC/domain/review/dto/response/ReviewResponse.java new file mode 100644 index 0000000..39d4e09 --- /dev/null +++ b/src/main/java/com/example/UMC/domain/review/dto/response/ReviewResponse.java @@ -0,0 +1,9 @@ +package com.example.UMC.domain.review.dto.response; + +public record ReviewResponse( + Long reviewId, + Long storeId, + Long userId, + Integer rating, + String content +) {} diff --git a/src/main/java/com/example/UMC/domain/review/exception/ReviewException.java b/src/main/java/com/example/UMC/domain/review/exception/ReviewException.java new file mode 100644 index 0000000..f0add53 --- /dev/null +++ b/src/main/java/com/example/UMC/domain/review/exception/ReviewException.java @@ -0,0 +1,10 @@ +package com.example.UMC.domain.review.exception; + +import com.example.UMC.global.apiPayload.code.BaseErrorCode; +import com.example.UMC.global.apiPayload.exception.GeneralException; + +public class ReviewException extends GeneralException { + public ReviewException(BaseErrorCode code) { + super(code); + } +} diff --git a/src/main/java/com/example/UMC/domain/review/exception/code/ReviewErrorCode.java b/src/main/java/com/example/UMC/domain/review/exception/code/ReviewErrorCode.java new file mode 100644 index 0000000..9523bf4 --- /dev/null +++ b/src/main/java/com/example/UMC/domain/review/exception/code/ReviewErrorCode.java @@ -0,0 +1,17 @@ +package com.example.UMC.domain.review.exception.code; + +import com.example.UMC.global.apiPayload.code.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ReviewErrorCode implements BaseErrorCode { + REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND, "REVIEW4004", "해당 리뷰를 찾을 수 없습니다."), + DUPLICATE_REVIEW(HttpStatus.CONFLICT, "REVIEW4090", "이미 리뷰를 작성했습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/example/UMC/domain/review/service/ReviewService.java b/src/main/java/com/example/UMC/domain/review/service/ReviewService.java new file mode 100644 index 0000000..814adaa --- /dev/null +++ b/src/main/java/com/example/UMC/domain/review/service/ReviewService.java @@ -0,0 +1,57 @@ +package com.example.UMC.domain.review.service; + +import com.example.UMC.domain.review.dto.request.ReviewCreateRequest; +import com.example.UMC.domain.review.dto.response.ReviewResponse; +import com.example.UMC.domain.review.entity.Review; +import com.example.UMC.domain.review.repository.ReviewRepository; +import com.example.UMC.domain.store.entity.Store; +import com.example.UMC.domain.store.exception.StoreException; +import com.example.UMC.domain.store.exception.code.StoreErrorCode; +import com.example.UMC.domain.store.repository.StoreRepository; +import com.example.UMC.domain.user.entity.User; +import com.example.UMC.domain.user.exception.UserException; +import com.example.UMC.domain.user.exception.code.UserErrorCode; +import com.example.UMC.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ReviewService { + private final ReviewRepository reviewRepository; + private final UserRepository userRepository; + private final StoreRepository storeRepository; + + @Transactional + public ReviewResponse createReview(Long userId, Long storeId, ReviewCreateRequest request) { + + //유저 존재 확인 + User user = userRepository.findById(userId) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); + + //가게 검증 + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorCode.STORE_NOT_FOUND)); + + //리뷰 생성 + Review review = Review.builder() + .store(store) + .user(user) + .region(store.getRegion()) + .rating(request.rating()) + .content(request.content()) + .build(); + + Review saved = reviewRepository.save(review); + + //응답 DTO 변환 + return new ReviewResponse( + saved.getId(), + store.getId(), + user.getId(), + saved.getRating(), + saved.getContent() + ); + } +} diff --git a/src/main/java/com/example/UMC/domain/store/exception/StoreException.java b/src/main/java/com/example/UMC/domain/store/exception/StoreException.java new file mode 100644 index 0000000..833ca7a --- /dev/null +++ b/src/main/java/com/example/UMC/domain/store/exception/StoreException.java @@ -0,0 +1,10 @@ +package com.example.UMC.domain.store.exception; + +import com.example.UMC.global.apiPayload.code.BaseErrorCode; +import com.example.UMC.global.apiPayload.exception.GeneralException; + +public class StoreException extends GeneralException { + public StoreException(BaseErrorCode code) { + super(code); + } +} diff --git a/src/main/java/com/example/UMC/domain/store/exception/code/StoreErrorCode.java b/src/main/java/com/example/UMC/domain/store/exception/code/StoreErrorCode.java new file mode 100644 index 0000000..6dea003 --- /dev/null +++ b/src/main/java/com/example/UMC/domain/store/exception/code/StoreErrorCode.java @@ -0,0 +1,16 @@ +package com.example.UMC.domain.store.exception.code; + +import com.example.UMC.global.apiPayload.code.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum StoreErrorCode implements BaseErrorCode { + STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE4004", "해당 가게를 찾을 수 없습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/example/UMC/domain/store/repository/StoreRepository.java b/src/main/java/com/example/UMC/domain/store/repository/StoreRepository.java new file mode 100644 index 0000000..7382a9f --- /dev/null +++ b/src/main/java/com/example/UMC/domain/store/repository/StoreRepository.java @@ -0,0 +1,7 @@ +package com.example.UMC.domain.store.repository; + +import com.example.UMC.domain.store.entity.Store; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StoreRepository extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/com/example/UMC/domain/user/exception/code/UserErrorCode.java b/src/main/java/com/example/UMC/domain/user/exception/code/UserErrorCode.java index 6cafc29..ea92c8f 100644 --- a/src/main/java/com/example/UMC/domain/user/exception/code/UserErrorCode.java +++ b/src/main/java/com/example/UMC/domain/user/exception/code/UserErrorCode.java @@ -8,7 +8,7 @@ @Getter @AllArgsConstructor public enum UserErrorCode implements BaseErrorCode { - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER4001", "해당 사용자를 찾을 수 없습니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER4004", "해당 사용자를 찾을 수 없습니다."), DUPLICATE_EMAIL(HttpStatus.BAD_REQUEST, "USER4002","이미 존재하는 이메일입니다."); private final HttpStatus status; diff --git a/src/main/java/com/example/UMC/global/config/SwaggerConfig.java b/src/main/java/com/example/UMC/global/config/SwaggerConfig.java new file mode 100644 index 0000000..1c67861 --- /dev/null +++ b/src/main/java/com/example/UMC/global/config/SwaggerConfig.java @@ -0,0 +1,36 @@ +package com.example.UMC.global.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI swagger() { + + Info info = new Info().title("Project").description("Project Swagger").version("0.0.1"); + + // JWT 토큰 헤더 방식 + String securityScheme = "JWT TOKEN"; + SecurityRequirement securityRequirement = new SecurityRequirement().addList(securityScheme); + + Components components = new Components() + .addSecuritySchemes(securityScheme, new SecurityScheme() + .name(securityScheme) + .scheme("Bearer") + .bearerFormat("JWT")); + + return new OpenAPI() + .info(info) + .addServersItem(new Server().url("/")) + .addSecurityItem(securityRequirement) + .components(components); + } +} \ No newline at end of file