diff --git a/src/main/java/sopt/comfit/company/domain/CompanyRepository.java b/src/main/java/sopt/comfit/company/domain/CompanyRepository.java new file mode 100644 index 0000000..47b44cb --- /dev/null +++ b/src/main/java/sopt/comfit/company/domain/CompanyRepository.java @@ -0,0 +1,6 @@ +package sopt.comfit.company.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CompanyRepository extends JpaRepository { +} diff --git a/src/main/java/sopt/comfit/company/exception/CompanyErrorCode.java b/src/main/java/sopt/comfit/company/exception/CompanyErrorCode.java new file mode 100644 index 0000000..4f1feb3 --- /dev/null +++ b/src/main/java/sopt/comfit/company/exception/CompanyErrorCode.java @@ -0,0 +1,17 @@ +package sopt.comfit.company.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import sopt.comfit.global.exception.ErrorCode; + +@Getter +@RequiredArgsConstructor +public enum CompanyErrorCode implements ErrorCode { + + COMPANY_NOT_FOUND(HttpStatus.NOT_FOUND, "COMPANY_404_001", "해당하는 회사를 찾을 수 없습니다"); + + private final HttpStatus status; + private final String prefix; + private final String message; +} diff --git a/src/main/java/sopt/comfit/global/advice/GlobalExceptionHandler.java b/src/main/java/sopt/comfit/global/advice/GlobalExceptionHandler.java index d11e5a8..8fad32b 100644 --- a/src/main/java/sopt/comfit/global/advice/GlobalExceptionHandler.java +++ b/src/main/java/sopt/comfit/global/advice/GlobalExceptionHandler.java @@ -6,6 +6,7 @@ import org.springframework.beans.TypeMismatchException; import org.springframework.context.MessageSourceResolvable; import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.validation.BindException; @@ -169,6 +170,17 @@ public ResponseEntity handleMediaTypeNotSupported(HttpMedia return convert(CommonErrorCode.NOT_SUPPORTED_MEDIA_TYPE_ERROR); } + /** + *데이터 정합성 예외 + *unique key, FK, NOT NULL 위반 + */ + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity handleDataIntegrityViolation(DataIntegrityViolationException e) { + + log.warn("Data integrity violation: {}", e.getMessage()); + return convert(CommonErrorCode.DATA_INTEGRITY_VIOLATION); + } + /** * 예상치 못한 서버 오류 처리 (500) * 위의 핸들러들로 처리되지 않은 모든 RuntimeException diff --git a/src/main/java/sopt/comfit/global/exception/CommonErrorCode.java b/src/main/java/sopt/comfit/global/exception/CommonErrorCode.java index c6e24fc..ac2de60 100644 --- a/src/main/java/sopt/comfit/global/exception/CommonErrorCode.java +++ b/src/main/java/sopt/comfit/global/exception/CommonErrorCode.java @@ -16,6 +16,8 @@ public enum CommonErrorCode implements ErrorCode { NOT_FOUND_URI(HttpStatus.NOT_FOUND, "COMMON_006", "존재하지 않는 URI입니다."), NOT_SUPPORTED_METHOD_ERROR(HttpStatus.METHOD_NOT_ALLOWED, "COMMON_007", "지원하지 않는 HTTP 메서드입니다."), NOT_SUPPORTED_MEDIA_TYPE_ERROR(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "COMMON_008", "지원하지 않는 미디어 타입입니다."), + DATA_INTEGRITY_VIOLATION(HttpStatus.BAD_REQUEST, "COMMON_009", "데이터 정합성 오류입니다"), + NOT_SUPPORTED_SORT_TYPE(HttpStatus.BAD_REQUEST, "COMMON_010", "지원하지 않는 정렬 타입입니다."), // ==== 인증/인가 에러 (4xx) ==== // ==== 인증 에러 (4xx) ==== diff --git a/src/main/java/sopt/comfit/report/domain/AIReportRepository.java b/src/main/java/sopt/comfit/report/domain/AIReportRepository.java new file mode 100644 index 0000000..7d27643 --- /dev/null +++ b/src/main/java/sopt/comfit/report/domain/AIReportRepository.java @@ -0,0 +1,9 @@ +package sopt.comfit.report.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface AIReportRepository extends JpaRepository { + boolean existsByCompanyIdAndUserId(Long companyId, Long userId); +} diff --git a/src/main/java/sopt/comfit/user/controller/UserController.java b/src/main/java/sopt/comfit/user/controller/UserController.java new file mode 100644 index 0000000..99d3f02 --- /dev/null +++ b/src/main/java/sopt/comfit/user/controller/UserController.java @@ -0,0 +1,49 @@ +package sopt.comfit.user.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import sopt.comfit.global.annotation.LoginUser; +import sopt.comfit.global.dto.PageDto; +import sopt.comfit.global.enums.ESort; +import sopt.comfit.user.dto.response.GetBookmarkCompany; +import sopt.comfit.user.dto.response.GetMeResponseDto; +import sopt.comfit.user.service.UserService; + +@RestController +@RequestMapping("/api/v1/me") +@RequiredArgsConstructor +public class UserController implements UserSwagger { + + private final UserService userService; + + @Override + public GetMeResponseDto getMe (@LoginUser Long userId){ + return userService.getMe(userId); + } + + @Override + public Long addBookmark(@LoginUser Long userId, + @PathVariable Long companyId){ + return userService.addBookmark(userId, companyId); + } + + @Override + public void removeBookmark(@LoginUser Long userId, + @PathVariable Long companyId) { + userService.removeBookmark(userId, companyId); + } + + @Override + public PageDto getBookmarkCompany(@LoginUser Long userId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "LATEST") ESort sort){ + Pageable pageable = PageRequest.of(page, 6); + return userService.getBookmarkCompany(userId, sort, pageable); + } + +} diff --git a/src/main/java/sopt/comfit/user/controller/UserSwagger.java b/src/main/java/sopt/comfit/user/controller/UserSwagger.java new file mode 100644 index 0000000..811acea --- /dev/null +++ b/src/main/java/sopt/comfit/user/controller/UserSwagger.java @@ -0,0 +1,135 @@ +package sopt.comfit.user.controller; + +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 io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import sopt.comfit.global.annotation.LoginUser; +import sopt.comfit.global.dto.CommonApiResponse; +import sopt.comfit.global.dto.CustomErrorResponse; +import sopt.comfit.global.dto.PageDto; +import sopt.comfit.global.enums.ESort; +import sopt.comfit.user.dto.response.GetBookmarkCompany; +import sopt.comfit.user.dto.response.GetMeResponseDto; + +@Tag(name = "me", description = "사용자 관련 API") +public interface UserSwagger { + + @Operation( + summary = "사용자 프로필 조회 API", + description = "사용자 프로필 조회 API입니다" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "프로필 조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = CommonApiResponse.class))), + + @ApiResponse(responseCode = "403", description = "권한 오류", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = CustomErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "헤더값 오류", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = CustomErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "사용자 id 값 오류", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = CustomErrorResponse.class))) + }) + @GetMapping + @SecurityRequirement(name = "JWT") + GetMeResponseDto getMe (@LoginUser Long userId); + + + @Operation( + summary = "관심 기업 북마크 API", + description = "관심 기업 북마크 API입니다" + ) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "관심 기업 북마크 추가 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = CommonApiResponse.class))), + + @ApiResponse(responseCode = "403", description = "권한 오류", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = CustomErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "헤더값 오류", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = CustomErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "사용자 id 값 오류", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = CustomErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "회사 id 값 오류", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = CustomErrorResponse.class))), + @ApiResponse(responseCode = "400", description = "중복 저장 오류", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = CustomErrorResponse.class))) + + }) + @PostMapping("/companies/{companyId}") + @SecurityRequirement(name = "JWT") + @ResponseStatus(HttpStatus.CREATED) + Long addBookmark(@LoginUser Long userId, + @PathVariable Long companyId); + + @Operation( + summary = "관심 기업 북마크 삭제 API", + description = "관심 기업 북마크 삭제 API입니다" + ) + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "관심 기업 북마크 삭제 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = CommonApiResponse.class))), + + @ApiResponse(responseCode = "403", description = "권한 오류", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = CustomErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "헤더값 오류", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = CustomErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "사용자 id 값 오류", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = CustomErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "회사 id 값 오류", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = CustomErrorResponse.class))) + }) + @DeleteMapping("/companies/{companyId}") + @SecurityRequirement(name = "JWT") + @ResponseStatus(HttpStatus.NO_CONTENT) + void removeBookmark(@LoginUser Long userId, + @PathVariable Long companyId); + + @Operation( + summary = "관심 기업 북마크 조회 리스트 API", + description = "관심 기업 북마크 조회 리스트 API입니다" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "북마크 기업 리스트 조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = CommonApiResponse.class))), + + @ApiResponse(responseCode = "403", description = "권한 오류", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = CustomErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "헤더값 오류", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = CustomErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "사용자 id 값 오류", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = CustomErrorResponse.class))), + @ApiResponse(responseCode = "400", description = "지원하지 않는 정렬 값 오류", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = CustomErrorResponse.class))) + + }) + @GetMapping("/companies") + @SecurityRequirement(name = "JWT") + PageDto getBookmarkCompany(@LoginUser Long userId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "LATEST") ESort sort); +} diff --git a/src/main/java/sopt/comfit/user/domain/UserCompany.java b/src/main/java/sopt/comfit/user/domain/UserCompany.java index d4779ed..62e4576 100644 --- a/src/main/java/sopt/comfit/user/domain/UserCompany.java +++ b/src/main/java/sopt/comfit/user/domain/UserCompany.java @@ -2,6 +2,7 @@ import jakarta.persistence.*; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import sopt.comfit.company.domain.Company; @@ -26,4 +27,19 @@ public class UserCompany extends BaseTimeEntity { @Column(name = "is_connected", nullable = false) private boolean isConnected; + + @Builder(access = AccessLevel.PRIVATE) + private UserCompany(final User user, + final Company company, + final boolean isConnected) { + this.user = user; + this.company = company; + this.isConnected = isConnected; + } + + public static UserCompany create(final User user, + final Company company, + final boolean isConnected) { + return new UserCompany(user, company, isConnected); + } } diff --git a/src/main/java/sopt/comfit/user/domain/UserCompanyRepository.java b/src/main/java/sopt/comfit/user/domain/UserCompanyRepository.java new file mode 100644 index 0000000..a4f36b8 --- /dev/null +++ b/src/main/java/sopt/comfit/user/domain/UserCompanyRepository.java @@ -0,0 +1,17 @@ +package sopt.comfit.user.domain; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserCompanyRepository extends JpaRepository { + Optional findByCompanyIdAndUserId(Long companyId, Long userId); + + Page findByUserIdOrderByCreatedAtAsc(Long userId, Pageable pageable); + + Page findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable); + + Page findByUserIdOrderByCompanyName(Long companyId, Pageable pageable); +} diff --git a/src/main/java/sopt/comfit/user/dto/response/GetBookmarkCompany.java b/src/main/java/sopt/comfit/user/dto/response/GetBookmarkCompany.java new file mode 100644 index 0000000..3e1fe29 --- /dev/null +++ b/src/main/java/sopt/comfit/user/dto/response/GetBookmarkCompany.java @@ -0,0 +1,28 @@ +package sopt.comfit.user.dto.response; + +import sopt.comfit.user.domain.UserCompany; + +import java.time.LocalDate; + +public record GetBookmarkCompany( + + Long id, + + Long companyId, + + String name, + + LocalDate createdAt, + + boolean isConnected +) { + public static GetBookmarkCompany from(UserCompany userCompany){ + return new GetBookmarkCompany( + userCompany.getId(), + userCompany.getCompany().getId(), + userCompany.getCompany().getName(), + userCompany.getCreatedAt().toLocalDate(), + userCompany.isConnected() + ); + } +} diff --git a/src/main/java/sopt/comfit/user/dto/response/GetMeResponseDto.java b/src/main/java/sopt/comfit/user/dto/response/GetMeResponseDto.java new file mode 100644 index 0000000..93f35eb --- /dev/null +++ b/src/main/java/sopt/comfit/user/dto/response/GetMeResponseDto.java @@ -0,0 +1,29 @@ +package sopt.comfit.user.dto.response; + +import sopt.comfit.global.enums.EIndustry; +import sopt.comfit.user.domain.EEducationLevel; +import sopt.comfit.user.domain.EJob; +import sopt.comfit.user.domain.User; + +public record GetMeResponseDto( + + String name, + + String email, + + EEducationLevel educationLevel, + + EIndustry firstIndustry, + + EJob fistJob +) { + public static GetMeResponseDto from(User user) { + return new GetMeResponseDto( + user.getName(), + user.getEmail(), + user.getEducationLevel(), + user.getFirstIndustry(), + user.getFirstJob() + ); + } +} diff --git a/src/main/java/sopt/comfit/user/exception/UserCompanyErrorCode.java b/src/main/java/sopt/comfit/user/exception/UserCompanyErrorCode.java new file mode 100644 index 0000000..0e9650a --- /dev/null +++ b/src/main/java/sopt/comfit/user/exception/UserCompanyErrorCode.java @@ -0,0 +1,17 @@ +package sopt.comfit.user.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import sopt.comfit.global.exception.ErrorCode; + +@Getter +@RequiredArgsConstructor +public enum UserCompanyErrorCode implements ErrorCode { + + USER_COMPANY_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_COMPANY_404_001", "해당하는 북마크 기업을 찾을 수 없습니다"); + + private final HttpStatus status; + private final String prefix; + private final String message; +} diff --git a/src/main/java/sopt/comfit/user/exception/UserErrorCode.java b/src/main/java/sopt/comfit/user/exception/UserErrorCode.java index 40805dd..32a9eda 100644 --- a/src/main/java/sopt/comfit/user/exception/UserErrorCode.java +++ b/src/main/java/sopt/comfit/user/exception/UserErrorCode.java @@ -9,8 +9,8 @@ @Getter public enum UserErrorCode implements ErrorCode { - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_001", "해당하는 유저를 찾을 수 없습니다."), - INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "USER_002", "비밀번호가 일치하지 않습니다."),; + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_001", "해당하는 유저를 찾을 수 없습니다."), + INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "USER_400_002", "비밀번호가 일치하지 않습니다."),; diff --git a/src/main/java/sopt/comfit/user/service/UserService.java b/src/main/java/sopt/comfit/user/service/UserService.java new file mode 100644 index 0000000..a92bb2b --- /dev/null +++ b/src/main/java/sopt/comfit/user/service/UserService.java @@ -0,0 +1,82 @@ +package sopt.comfit.user.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import sopt.comfit.company.domain.Company; +import sopt.comfit.company.domain.CompanyRepository; +import sopt.comfit.company.exception.CompanyErrorCode; +import sopt.comfit.global.dto.PageDto; +import sopt.comfit.global.enums.ESort; +import sopt.comfit.global.exception.BaseException; +import sopt.comfit.global.exception.CommonErrorCode; +import sopt.comfit.report.domain.AIReportRepository; +import sopt.comfit.user.domain.User; +import sopt.comfit.user.domain.UserCompany; +import sopt.comfit.user.domain.UserCompanyRepository; +import sopt.comfit.user.domain.UserRepository; +import sopt.comfit.user.dto.response.GetBookmarkCompany; +import sopt.comfit.user.dto.response.GetMeResponseDto; +import sopt.comfit.user.exception.UserCompanyErrorCode; +import sopt.comfit.user.exception.UserErrorCode; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + private final CompanyRepository companyRepository; + private final UserCompanyRepository userCompanyRepository; + private final AIReportRepository aIReportRepository; + + @Transactional(readOnly = true) + public GetMeResponseDto getMe (Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> BaseException.type(UserErrorCode.USER_NOT_FOUND)); + + return GetMeResponseDto.from(user); + } + + @Transactional + public Long addBookmark(Long userId, Long companyId) { + + log.info("북마크 추가 userId:{} companyId:{}", userId, companyId); + User user = userRepository.findById(userId) + .orElseThrow(() -> BaseException.type(UserErrorCode.USER_NOT_FOUND)); + Company company = companyRepository.findById(companyId) + .orElseThrow(() -> BaseException.type(CompanyErrorCode.COMPANY_NOT_FOUND)); + + boolean isConnected = aIReportRepository.existsByCompanyIdAndUserId(companyId, userId); + UserCompany userCompany = userCompanyRepository.save(UserCompany.create(user, company, isConnected)); + return userCompany.getId(); + } + + @Transactional + public void removeBookmark(Long userId, Long companyId) { + log.info("북마크 삭제 userId:{} companyId:{}", userId, companyId); + UserCompany userCompany = userCompanyRepository.findByCompanyIdAndUserId(companyId, userId) + .orElseThrow(() -> BaseException.type(UserCompanyErrorCode.USER_COMPANY_NOT_FOUND)); + + userCompanyRepository.delete(userCompany); + } + + @Transactional(readOnly = true) + public PageDto getBookmarkCompany (Long userId, ESort sort, Pageable pageable) { + + Page page = switch (sort) { + case LATEST -> userCompanyRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable); + case OLDEST -> userCompanyRepository.findByUserIdOrderByCreatedAtAsc(userId, pageable); + case NAME -> userCompanyRepository.findByUserIdOrderByCompanyName(userId, pageable); + default -> { + log.warn("잘못된 정렬 타입 값입니다 type : {}", sort); + throw BaseException.type(CommonErrorCode.NOT_SUPPORTED_SORT_TYPE); + } + }; + + return PageDto.from(page.map(GetBookmarkCompany::from)); + } +}