diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c154dd0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ + +# 빌드 단계 +FROM eclipse-temurin:21-alpine AS builder +WORKDIR /app + +# Gradle 이용 빌드 단계 +COPY gradlew . +COPY gradle gradle +COPY build.gradle settings.gradle ./ +COPY src src +RUN chmod +x ./gradlew +RUN ./gradlew clean build -x test + +# 실행 단계 +FROM eclipse-temurin:21-alpine AS runner +WORKDIR /app +COPY --from=builder /app/build/libs/*.jar HyetaekOn.jar +RUN mkdir -p /app/logs + +# 환경 변수 선언 (기본값) +ENV JAVA_OPTS="" + +# 포트 설정 +EXPOSE 8080 + +# 실행 명령에서 JAVA_OPTS 사용 +CMD ["sh", "-c", "java $JAVA_OPTS -jar HyetaekOn.jar"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8497d36 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,59 @@ + services: + app: + build: + context: . + dockerfile: Dockerfile + image: hyetaekon:latest + container_name: hyetaekon + ports: + - "8080:8080" + restart: always + env_file: + - ./.env + environment: + SPRING_PROFILES_ACTIVE: dev,s3Bucket + TZ: Asia/Seoul + volumes: + - ./logs:/app/logs # 로그 마운트 + depends_on: + redis: + condition: service_healthy + networks: + - app-network + + redis: + image: redis:latest + container_name: hyetaekon-redis + ports: + - "6379:6379" + volumes: + - redis-data:/data # 데이터 지속성을 위한 볼륨 추가 + command: redis-server --appendonly yes + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + networks: + - app-network + + nginx: + image: nginx:latest + container_name: hyetaekon-nginx + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/conf.d:/etc/nginx/conf.d + - ./nginx/ssl:/etc/nginx/ssl + - ./nginx/logs:/var/log/nginx + depends_on: + - app + networks: + - app-network + + + networks: + app-network: + name: hyetaekon-network \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/controller/UserInterestController.java b/src/main/java/com/hyetaekon/hyetaekon/UserInterest/controller/UserInterestController.java index ac2de12..6e15c52 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/controller/UserInterestController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/UserInterest/controller/UserInterestController.java @@ -1,4 +1,57 @@ package com.hyetaekon.hyetaekon.UserInterest.controller; +import com.hyetaekon.hyetaekon.UserInterest.dto.CategorizedInterestsResponseDto; +import com.hyetaekon.hyetaekon.UserInterest.dto.CategorizedInterestsWithSelectionDto; +import com.hyetaekon.hyetaekon.UserInterest.dto.InterestSelectionRequestDto; +import com.hyetaekon.hyetaekon.UserInterest.entity.UserInterestEnum; +import com.hyetaekon.hyetaekon.UserInterest.service.UserInterestService; +import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/interests") +@RequiredArgsConstructor public class UserInterestController { + + private final UserInterestService userInterestService; + + // 선택할 키워드 목록 조회 + @GetMapping + public ResponseEntity getAvailableInterests() { + // Enum에서 카테고리별 displayName 값 추출 + Map> categorizedInterests = Arrays.stream(UserInterestEnum.values()) + .collect(Collectors.groupingBy( + UserInterestEnum::getCategory, + Collectors.mapping(UserInterestEnum::getDisplayName, Collectors.toList()) + )); + return ResponseEntity.ok(new CategorizedInterestsResponseDto(categorizedInterests)); + } + + // 모든 관심사와 사용자 선택 여부 조회 + @GetMapping("/me") + public ResponseEntity getMyInterestsWithSelection( + @AuthenticationPrincipal CustomUserDetails userDetails) { + Long userId = userDetails.getId(); + return ResponseEntity.ok(userInterestService.getUserInterestsWithSelection(userId)); + } + + // 선택한 관심사 저장 + @PostMapping("/me") + public ResponseEntity saveInterests( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody InterestSelectionRequestDto requestDto + ) { + Long userId = userDetails.getId(); + userInterestService.saveUserInterests(userId, requestDto.getAllInterests()); + return ResponseEntity.status(HttpStatus.OK).build(); + } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/dto/CategorizedInterestsResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/UserInterest/dto/CategorizedInterestsResponseDto.java new file mode 100644 index 0000000..f156cbc --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/UserInterest/dto/CategorizedInterestsResponseDto.java @@ -0,0 +1,14 @@ +package com.hyetaekon.hyetaekon.UserInterest.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; +import java.util.Map; + +@Getter +@AllArgsConstructor +public class CategorizedInterestsResponseDto { + private Map> categorizedInterests; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/dto/CategorizedInterestsWithSelectionDto.java b/src/main/java/com/hyetaekon/hyetaekon/UserInterest/dto/CategorizedInterestsWithSelectionDto.java new file mode 100644 index 0000000..907417a --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/UserInterest/dto/CategorizedInterestsWithSelectionDto.java @@ -0,0 +1,13 @@ +package com.hyetaekon.hyetaekon.UserInterest.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; +import java.util.Map; + +@Getter +@AllArgsConstructor +public class CategorizedInterestsWithSelectionDto { + private Map> categorizedInterests; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/dto/InterestItemDto.java b/src/main/java/com/hyetaekon/hyetaekon/UserInterest/dto/InterestItemDto.java new file mode 100644 index 0000000..f572bda --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/UserInterest/dto/InterestItemDto.java @@ -0,0 +1,11 @@ +package com.hyetaekon.hyetaekon.UserInterest.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class InterestItemDto { + private String name; // 관심사 이름 + private boolean selected; // 선택 여부 +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/dto/InterestSelectionRequestDto.java b/src/main/java/com/hyetaekon/hyetaekon/UserInterest/dto/InterestSelectionRequestDto.java new file mode 100644 index 0000000..a34be5e --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/UserInterest/dto/InterestSelectionRequestDto.java @@ -0,0 +1,21 @@ +package com.hyetaekon.hyetaekon.UserInterest.dto; + +import lombok.Getter; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Getter +public class InterestSelectionRequestDto { + private Map> categorizedInterests; + + public List getAllInterests() { + if (categorizedInterests == null) { + return new ArrayList<>(); + } + List allInterests = new ArrayList<>(); + categorizedInterests.values().forEach(allInterests::addAll); + return allInterests; + } +} \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/dto/UserInterestDto.java b/src/main/java/com/hyetaekon/hyetaekon/UserInterest/dto/UserInterestDto.java deleted file mode 100644 index f00cb35..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/dto/UserInterestDto.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.hyetaekon.hyetaekon.UserInterest.dto; - -public class UserInterestDto { -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/entity/UserInterest.java b/src/main/java/com/hyetaekon/hyetaekon/UserInterest/entity/UserInterest.java index 7ee7c49..118b63c 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/entity/UserInterest.java +++ b/src/main/java/com/hyetaekon/hyetaekon/UserInterest/entity/UserInterest.java @@ -1,13 +1,31 @@ package com.hyetaekon.hyetaekon.UserInterest.entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; +import com.hyetaekon.hyetaekon.user.entity.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +@Entity +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Builder public class UserInterest { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @ManyToOne + @JoinColumn(name = "user_id", nullable = false) + private User user; + @Column(nullable = false) + private String interest; + + @Override + public String toString() { + return "{user_id: " + user.getId() + ", keyword: " + interest + "}"; + } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/entity/UserInterestEnum.java b/src/main/java/com/hyetaekon/hyetaekon/UserInterest/entity/UserInterestEnum.java index 44c4c7e..92f8296 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/entity/UserInterestEnum.java +++ b/src/main/java/com/hyetaekon/hyetaekon/UserInterest/entity/UserInterestEnum.java @@ -4,26 +4,37 @@ @Getter public enum UserInterestEnum { - HOT_WATER_PROVIDED("온수 잘 나오는"), - HEALING("힐링"), - FRIENDLY("친절한"), - STAR_VIEWING("별 보기 좋은"), - COUPLE("커플"), - LOTS_OF_SHADE("그늘이 많은"), - FAMILY("가족"), - SWIMMING("물놀이 하기 좋은"), - OCEAN_VIEW("바다가 보이는"), - GOOD_FOR_KIDS("아이들 놀기 좋은"), - RELAXING("여유있는"), - CLEAN("깨끗한"), - EASY_CAR_PARKING("차대기 편한"), - FUN("재미있는"), - WIDE_SITE_SPACING("사이트 간격이 넓은"); + // ServiceCategory 관련 관심사 + CHILDCARE_EDUCATION("보육·교육", "관심주제"), + HOUSING_INDEPENDENCE("주거·자립", "관심주제"), + ADMINISTRATION_SAFETY("행정·안전", "관심주제"), + AGRICULTURE_FISHERY("농림축산어업", "관심주제"), + EMPLOYMENT_STARTUP("고용·창업", "관심주제"), + HEALTH_MEDICAL("보건·의료", "관심주제"), + CULTURE_ENVIRONMENT("문화·환경", "관심주제"), + LIFE_STABILITY("생활안정", "관심주제"), + + // SpecialGroup 관련 관심사 + IS_MULTI_CULTURAL("다문화가족", "가구형태"), + IS_NORTH_KOREAN_DEFECTOR("북한이탈주민", "가구형태"), + IS_SINGLE_PARENT_FAMILY("한부모가정/조손가정", "가구형태"), + IS_SINGLE_MEMBER_HOUSEHOLD("1인가구", "가구형태"), + IS_DISABLED("장애인", "가구형태"), + IS_NATIONAL_MERIT_RECIPIENT("국가보훈대상자", "가구형태"), + IS_CHRONIC_ILLNESS("질병/질환자", "가구형태"), + + // FamilyType 관련 관심사 + // IS_NOT_APPLICABLE("해당사항 없음", "가구상황"), + IS_MULTI_CHILDREN_FAMILY("다자녀가구", "가구상황"), + IS_NON_HOUSING_HOUSEHOLD("무주택세대", "가구상황"), + IS_NEW_RESIDENCE("신규전입", "가구상황"), + IS_EXTENDED_FAMILY("확대가족", "가구상황"); private final String displayName; + private final String category; - UserInterestEnum(String displayName) { + UserInterestEnum(String displayName, String category) { this.displayName = displayName; + this.category = category; } - } \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/repository/UserInterestRepository.java b/src/main/java/com/hyetaekon/hyetaekon/UserInterest/repository/UserInterestRepository.java index d53acc0..907ebcf 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/repository/UserInterestRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/UserInterest/repository/UserInterestRepository.java @@ -1,4 +1,10 @@ package com.hyetaekon.hyetaekon.UserInterest.repository; -public class UserInterestRepository { +import com.hyetaekon.hyetaekon.UserInterest.entity.UserInterest; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserInterestRepository extends JpaRepository { + } diff --git a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/service/UserInterestService.java b/src/main/java/com/hyetaekon/hyetaekon/UserInterest/service/UserInterestService.java index f7aeff1..84d1a91 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/service/UserInterestService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/UserInterest/service/UserInterestService.java @@ -1,4 +1,114 @@ package com.hyetaekon.hyetaekon.UserInterest.service; +import com.hyetaekon.hyetaekon.UserInterest.dto.CategorizedInterestsWithSelectionDto; +import com.hyetaekon.hyetaekon.UserInterest.dto.InterestItemDto; +import com.hyetaekon.hyetaekon.UserInterest.entity.UserInterest; +import com.hyetaekon.hyetaekon.UserInterest.entity.UserInterestEnum; +import com.hyetaekon.hyetaekon.UserInterest.repository.UserInterestRepository; +import com.hyetaekon.hyetaekon.common.exception.ErrorCode; +import com.hyetaekon.hyetaekon.common.exception.GlobalException; +import com.hyetaekon.hyetaekon.user.entity.User; +import com.hyetaekon.hyetaekon.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor public class UserInterestService { + private final UserRepository userRepository; + private final UserInterestRepository userInterestRepository; + + // 모든 관심사 목록과 사용자 선택 여부 함께 조회 + @Transactional(readOnly = true) + public CategorizedInterestsWithSelectionDto getUserInterestsWithSelection(Long userId) { + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + // 사용자가 선택한 관심사 목록 + List selectedInterests = user.getInterests().stream() + .map(UserInterest::getInterest) + .toList(); + + // 카테고리별로 모든 관심사를 포함하되, 선택 여부 표시 + Map> result = new HashMap<>(); + + Arrays.stream(UserInterestEnum.values()) + .forEach(interestEnum -> { + String category = interestEnum.getCategory(); + String displayName = interestEnum.getDisplayName(); + boolean isSelected = selectedInterests.contains(displayName); + + if (!result.containsKey(category)) { + result.put(category, new ArrayList<>()); + } + + result.get(category).add(new InterestItemDto(displayName, isSelected)); + }); + + log.debug("회원 관심사 조회 (선택 여부 포함) - 유저 ID: {}", userId); + return new CategorizedInterestsWithSelectionDto(result); + } + + // 선택한 관심사 저장 + @Transactional + public void saveUserInterests(Long userId, List selectedInterests) { + if (selectedInterests == null) { + selectedInterests = new ArrayList<>(); // 빈 리스트로 초기화 + } + + // 최대 선택 개수 검증 + if (selectedInterests.size() > 5) { + throw new GlobalException(ErrorCode.INTEREST_LIMIT_EXCEEDED); + } + + // 유효한 관심사인지 검증 + Set validInterests = Arrays.stream(UserInterestEnum.values()) + .map(UserInterestEnum::getDisplayName) + .collect(Collectors.toSet()); + + for (String interest : selectedInterests) { + if (!validInterests.contains(interest)) { + throw new GlobalException(ErrorCode.INVALID_INTEREST); + } + } + + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + // 기존 관심사 제거 + user.getInterests().clear(); + + // 새 관심사 추가 + for (String interest : selectedInterests) { + UserInterest newInterest = UserInterest.builder() + .user(user) + .interest(interest) + .build(); + userInterestRepository.save(newInterest); + } + + log.debug("회원 관심사 갱신 - 유저 ID: {}, 선택 관심사: {}", userId, selectedInterests); + } + + /* // 나의 관심사 조회 + @Transactional(readOnly = true) + public UserInterestResponseDto getUserInterestsByUserId(Long userId) { + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + List userInterests = user.getInterests().stream() + .map(UserInterest::getInterest) + .toList(); + + log.debug("회원 관심사 조회 - 유저 ID: {}, 관심사: {}", userId, userInterests); + return new UserInterestResponseDto(userInterests); + } + + */ } diff --git a/src/main/java/com/hyetaekon/hyetaekon/answer/controller/AnswerController.java b/src/main/java/com/hyetaekon/hyetaekon/answer/controller/AnswerController.java index fbf36bd..3417bdd 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/answer/controller/AnswerController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/answer/controller/AnswerController.java @@ -1,4 +1,42 @@ package com.hyetaekon.hyetaekon.answer.controller; +import com.hyetaekon.hyetaekon.answer.dto.AnswerDto; +import com.hyetaekon.hyetaekon.answer.service.AnswerService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/posts/{postId}/answers") +@RequiredArgsConstructor public class AnswerController { + + private final AnswerService answerService; + + // 답변 작성 + @PostMapping + @PreAuthorize("hasRole('USER')") + public ResponseEntity createAnswer(@PathVariable Long postId, @RequestBody AnswerDto answerDto) { + AnswerDto createdAnswer = answerService.createAnswer(postId, answerDto); + return ResponseEntity.status(HttpStatus.CREATED).body(createdAnswer); + } + + // 답변 채택 + @PutMapping("/{answerId}/select") + @PreAuthorize("hasRole('USER')") + public ResponseEntity selectAnswer(@PathVariable Long answerId) { + answerService.selectAnswer(answerId); + return ResponseEntity.ok().build(); + } + + // 답변 삭제 (관리자만 가능) + @DeleteMapping("/admin/answers/{answerId}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity deleteAnswer(@PathVariable Long answerId) { + answerService.deleteAnswer(answerId); + return ResponseEntity.noContent().build(); + } } + diff --git a/src/main/java/com/hyetaekon/hyetaekon/answer/dto/AnswerDto.java b/src/main/java/com/hyetaekon/hyetaekon/answer/dto/AnswerDto.java index e970a89..d3bd5ce 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/answer/dto/AnswerDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/answer/dto/AnswerDto.java @@ -1,4 +1,14 @@ package com.hyetaekon.hyetaekon.answer.dto; +import lombok.Data; +import java.time.LocalDateTime; + +@Data public class AnswerDto { + private Long id; + private Long postId; + private Long userId; + private String content; + private LocalDateTime createdAt; + private boolean selected; } diff --git a/src/main/java/com/hyetaekon/hyetaekon/answer/entity/Answer.java b/src/main/java/com/hyetaekon/hyetaekon/answer/entity/Answer.java index b0e58f3..a54cbcd 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/answer/entity/Answer.java +++ b/src/main/java/com/hyetaekon/hyetaekon/answer/entity/Answer.java @@ -1,4 +1,33 @@ package com.hyetaekon.hyetaekon.answer.entity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@Table(name = "answer") public class Answer { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; // 답변 ID + + @Column(name = "post_id", nullable = false) + private Long postId; // 게시글 ID + + @Column(name = "user_id", nullable = false) + private Long userId; // 회원 ID + + @Column(name = "content", columnDefinition = "TEXT", nullable = false) + private String content; // 답변 내용 + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; // 생성일 + + @Column(name = "selected", nullable = false) + private boolean selected; // 채택 여부 } diff --git a/src/main/java/com/hyetaekon/hyetaekon/answer/mapper/AnswerMapper.java b/src/main/java/com/hyetaekon/hyetaekon/answer/mapper/AnswerMapper.java index 2cd41cb..5b87c17 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/answer/mapper/AnswerMapper.java +++ b/src/main/java/com/hyetaekon/hyetaekon/answer/mapper/AnswerMapper.java @@ -1,4 +1,19 @@ package com.hyetaekon.hyetaekon.answer.mapper; +import com.hyetaekon.hyetaekon.answer.dto.AnswerDto; +import com.hyetaekon.hyetaekon.answer.entity.Answer; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +@Mapper(componentModel = "spring") public interface AnswerMapper { + AnswerMapper INSTANCE = Mappers.getMapper(AnswerMapper.class); + + AnswerDto toDto(Answer answer); + + @Mapping(target = "id", ignore = true) // id는 자동 생성되므로 무시 + @Mapping(target = "selected", ignore = true) // 기본값 false 처리 + @Mapping(target = "createdAt", ignore = true) // createdAt 자동 설정 + Answer toEntity(AnswerDto answerDto); } diff --git a/src/main/java/com/hyetaekon/hyetaekon/answer/repository/AnswerRepository.java b/src/main/java/com/hyetaekon/hyetaekon/answer/repository/AnswerRepository.java index 2cdc096..6a587aa 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/answer/repository/AnswerRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/answer/repository/AnswerRepository.java @@ -1,4 +1,9 @@ package com.hyetaekon.hyetaekon.answer.repository; -public class AnswerRepository { +import com.hyetaekon.hyetaekon.answer.entity.Answer; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface AnswerRepository extends JpaRepository { } diff --git a/src/main/java/com/hyetaekon/hyetaekon/answer/service/AnswerService.java b/src/main/java/com/hyetaekon/hyetaekon/answer/service/AnswerService.java index eefb31f..04a5eaa 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/answer/service/AnswerService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/answer/service/AnswerService.java @@ -1,4 +1,34 @@ package com.hyetaekon.hyetaekon.answer.service; +import com.hyetaekon.hyetaekon.answer.dto.AnswerDto; +import com.hyetaekon.hyetaekon.answer.entity.Answer; +import com.hyetaekon.hyetaekon.answer.mapper.AnswerMapper; +import com.hyetaekon.hyetaekon.answer.repository.AnswerRepository; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor public class AnswerService { + private final AnswerRepository answerRepository; + private final AnswerMapper answerMapper; + + public AnswerDto createAnswer(Long postId, AnswerDto answerDto) { + Answer answer = answerMapper.toEntity(answerDto); + answer.setPostId(postId); + answer = answerRepository.save(answer); + return answerMapper.toDto(answer); + } + + public void selectAnswer(Long answerId) { + Answer answer = answerRepository.findById(answerId) + .orElseThrow(() -> new EntityNotFoundException("Answer not found")); + answer.setSelected(true); + answerRepository.save(answer); + } + + public void deleteAnswer(Long answerId) { + answerRepository.deleteById(answerId); + } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/banner/controller/AnswerController.java b/src/main/java/com/hyetaekon/hyetaekon/banner/controller/AnswerController.java deleted file mode 100644 index 55ed580..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/banner/controller/AnswerController.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.hyetaekon.hyetaekon.banner.controller; - -public class AnswerController { -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/banner/controller/BannerController.java b/src/main/java/com/hyetaekon/hyetaekon/banner/controller/BannerController.java new file mode 100644 index 0000000..89b9a1e --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/banner/controller/BannerController.java @@ -0,0 +1,67 @@ +package com.hyetaekon.hyetaekon.banner.controller; + +import com.hyetaekon.hyetaekon.banner.dto.BannerDto; +import com.hyetaekon.hyetaekon.banner.service.BannerService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/banners") +@RequiredArgsConstructor +public class BannerController { + private final BannerService bannerService; + + // 배너 목록 조회 (ALL) + @GetMapping + public ResponseEntity> getBanners() { + return ResponseEntity.ok(bannerService.getBanners()); + } + + // 배너 상세 조회 (ALL) + @GetMapping("/{bannerId}") + public ResponseEntity getBanner(@PathVariable Long bannerId) { + return ResponseEntity.ok(bannerService.getBanner(bannerId)); + } + + // 배너 목록 조회 (ADMIN) + @GetMapping("/admin") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> getAdminBanners() { + return ResponseEntity.ok(bannerService.getAdminBanners()); + } + + // 배너 등록 (ADMIN) + @PostMapping("/admin") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity createBanner(@RequestBody BannerDto bannerDto) { + return ResponseEntity.ok(bannerService.createBanner(bannerDto)); + } + + // 배너 수정 (ADMIN) + @PutMapping("/admin/{bannerId}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity updateBanner( + @PathVariable Long bannerId, @RequestBody BannerDto bannerDto) { + return ResponseEntity.ok(bannerService.updateBanner(bannerId, bannerDto)); + } + + // 배너 삭제 (ADMIN) + @DeleteMapping("/admin/{bannerId}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity deleteBanner(@PathVariable Long bannerId) { + bannerService.deleteBanner(bannerId); + return ResponseEntity.noContent().build(); + } + + // 배너 순서 변경 (ADMIN) + @PatchMapping("/admin") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity updateBannerOrder(@RequestBody List bannerIds) { + bannerService.updateBannerOrder(bannerIds); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/banner/dto/AnswerDto.java b/src/main/java/com/hyetaekon/hyetaekon/banner/dto/AnswerDto.java deleted file mode 100644 index fafc17d..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/banner/dto/AnswerDto.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.hyetaekon.hyetaekon.banner.dto; - -public class AnswerDto { -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/banner/dto/BannerDto.java b/src/main/java/com/hyetaekon/hyetaekon/banner/dto/BannerDto.java new file mode 100644 index 0000000..3578b12 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/banner/dto/BannerDto.java @@ -0,0 +1,14 @@ +package com.hyetaekon.hyetaekon.banner.dto; + +import lombok.Data; +import java.time.LocalDateTime; + +@Data +public class BannerDto { + private Long id; + private String title; + private String imageUrl; + private String linkUrl; + private int displayOrder; + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/banner/entity/Answer.java b/src/main/java/com/hyetaekon/hyetaekon/banner/entity/Answer.java deleted file mode 100644 index fe64651..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/banner/entity/Answer.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.hyetaekon.hyetaekon.banner.entity; - -public class Answer { -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/banner/entity/Banner.java b/src/main/java/com/hyetaekon/hyetaekon/banner/entity/Banner.java new file mode 100644 index 0000000..f9b4852 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/banner/entity/Banner.java @@ -0,0 +1,36 @@ +package com.hyetaekon.hyetaekon.banner.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@Table(name = "banner") +public class Banner { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; // 배너 ID + + @Column(name = "title", nullable = false, length = 100) + private String title; // 배너 제목 + + @Column(name = "image_url", nullable = false, length = 500) + private String imageUrl; // 배너 이미지 URL + + @Column(name = "link_url", length = 500) + private String linkUrl; // 배너 클릭 시 이동할 URL + + @Column(name = "display_order", nullable = false) + private int displayOrder; // 배너 정렬 순서 + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; // 생성일 + + @Column(name = "updated_at") + private LocalDateTime updatedAt; // 수정일 +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/banner/mapper/AnswerMapper.java b/src/main/java/com/hyetaekon/hyetaekon/banner/mapper/AnswerMapper.java deleted file mode 100644 index 2cd9b2c..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/banner/mapper/AnswerMapper.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.hyetaekon.hyetaekon.banner.mapper; - -public interface AnswerMapper { -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/banner/mapper/BannerMapper.java b/src/main/java/com/hyetaekon/hyetaekon/banner/mapper/BannerMapper.java new file mode 100644 index 0000000..526e7a9 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/banner/mapper/BannerMapper.java @@ -0,0 +1,17 @@ +package com.hyetaekon.hyetaekon.banner.mapper; + +import com.hyetaekon.hyetaekon.banner.dto.BannerDto; +import com.hyetaekon.hyetaekon.banner.entity.Banner; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +@Mapper(componentModel = "spring") +public interface BannerMapper { + BannerMapper INSTANCE = Mappers.getMapper(BannerMapper.class); + + BannerDto toDto(Banner banner); + + @Mapping(target = "id", ignore = true) // id는 자동 생성됨 + Banner toEntity(BannerDto bannerDto); +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/banner/repository/AnswerRepository.java b/src/main/java/com/hyetaekon/hyetaekon/banner/repository/AnswerRepository.java deleted file mode 100644 index 49299aa..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/banner/repository/AnswerRepository.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.hyetaekon.hyetaekon.banner.repository; - -public class AnswerRepository { -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/banner/repository/BannerRepository.java b/src/main/java/com/hyetaekon/hyetaekon/banner/repository/BannerRepository.java new file mode 100644 index 0000000..c5174d9 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/banner/repository/BannerRepository.java @@ -0,0 +1,9 @@ +package com.hyetaekon.hyetaekon.banner.repository; + +import com.hyetaekon.hyetaekon.banner.entity.Banner; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface BannerRepository extends JpaRepository { + List findAllByOrderByDisplayOrderAsc(); +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/banner/service/AnswerService.java b/src/main/java/com/hyetaekon/hyetaekon/banner/service/AnswerService.java deleted file mode 100644 index fc63b81..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/banner/service/AnswerService.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.hyetaekon.hyetaekon.banner.service; - -public class AnswerService { -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/banner/service/BannerService.java b/src/main/java/com/hyetaekon/hyetaekon/banner/service/BannerService.java new file mode 100644 index 0000000..29c2bb9 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/banner/service/BannerService.java @@ -0,0 +1,74 @@ +package com.hyetaekon.hyetaekon.banner.service; + +import com.hyetaekon.hyetaekon.banner.dto.BannerDto; +import com.hyetaekon.hyetaekon.banner.entity.Banner; +import com.hyetaekon.hyetaekon.banner.mapper.BannerMapper; +import com.hyetaekon.hyetaekon.banner.repository.BannerRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class BannerService { + private final BannerRepository bannerRepository; + + // 배너 목록 조회 (모든 사용자) + public List getBanners() { + List banners = bannerRepository.findAllByOrderByDisplayOrderAsc(); + return banners.stream().map(BannerMapper.INSTANCE::toDto).collect(Collectors.toList()); + } + + // 배너 상세 조회 (모든 사용자) + public BannerDto getBanner(Long bannerId) { + Banner banner = bannerRepository.findById(bannerId) + .orElseThrow(() -> new IllegalArgumentException("해당 배너가 존재하지 않습니다.")); + return BannerMapper.INSTANCE.toDto(banner); + } + + // 배너 목록 조회 (관리자) + public List getAdminBanners() { + return getBanners(); + } + + // 배너 등록 (관리자) + public BannerDto createBanner(BannerDto bannerDto) { + Banner banner = BannerMapper.INSTANCE.toEntity(bannerDto); + banner.setCreatedAt(LocalDateTime.now()); + banner.setUpdatedAt(LocalDateTime.now()); + return BannerMapper.INSTANCE.toDto(bannerRepository.save(banner)); + } + + // 배너 수정 (관리자) + public BannerDto updateBanner(Long bannerId, BannerDto bannerDto) { + Banner banner = bannerRepository.findById(bannerId) + .orElseThrow(() -> new IllegalArgumentException("해당 배너가 존재하지 않습니다.")); + + banner.setTitle(bannerDto.getTitle()); + banner.setImageUrl(bannerDto.getImageUrl()); + banner.setLinkUrl(bannerDto.getLinkUrl()); + banner.setDisplayOrder(bannerDto.getDisplayOrder()); + banner.setUpdatedAt(LocalDateTime.now()); + + return BannerMapper.INSTANCE.toDto(bannerRepository.save(banner)); + } + + // 배너 삭제 (관리자) + public void deleteBanner(Long bannerId) { + bannerRepository.deleteById(bannerId); + } + + // 배너 순서 변경 (관리자) + @Transactional + public void updateBannerOrder(List bannerIds) { + int order = 1; + for (Long bannerId : bannerIds) { + Banner banner = bannerRepository.findById(bannerId) + .orElseThrow(() -> new IllegalArgumentException("배너를 찾을 수 없습니다.")); + banner.setDisplayOrder(order++); + } + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/bookmark/controller/BookMarkController.java b/src/main/java/com/hyetaekon/hyetaekon/bookmark/controller/BookMarkController.java index e6a9e73..cee5f98 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/bookmark/controller/BookMarkController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/bookmark/controller/BookMarkController.java @@ -1,22 +1,39 @@ package com.hyetaekon.hyetaekon.bookmark.controller; import com.hyetaekon.hyetaekon.bookmark.service.BookmarkService; +import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @Slf4j @RestController -@RequestMapping("/api") +@RequestMapping("/api/services/{serviceId}/bookmark") @RequiredArgsConstructor public class BookMarkController { private final BookmarkService bookmarkService; - // 찜 기능 + // 북마크 추가 + @PostMapping + public ResponseEntity addBookmark( + @PathVariable("serviceId") Long serviceId, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + bookmarkService.addBookmark(serviceId, customUserDetails.getId()); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + // 북마크 제거 + @DeleteMapping + public ResponseEntity removeBookmark( + @PathVariable("serviceId") Long serviceId, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + bookmarkService.removeBookmark(serviceId, customUserDetails.getId()); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/bookmark/entity/Bookmark.java b/src/main/java/com/hyetaekon/hyetaekon/bookmark/entity/Bookmark.java index 1907a0a..1721ad2 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/bookmark/entity/Bookmark.java +++ b/src/main/java/com/hyetaekon/hyetaekon/bookmark/entity/Bookmark.java @@ -1,6 +1,9 @@ package com.hyetaekon.hyetaekon.bookmark.entity; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.hyetaekon.hyetaekon.common.util.BaseEntity; +import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; +import com.hyetaekon.hyetaekon.user.entity.User; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; @@ -13,15 +16,18 @@ @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor -@Table( - indexes = { - @Index(name = "idx_id", columnList = "id") - } -) -public class Bookmark { +public class Bookmark extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @ManyToOne + @JsonIgnore + @JoinColumn(name = "public_service_id", nullable = false) + private PublicService publicService; + + @ManyToOne + @JoinColumn(name = "user_id", nullable = false) + private User user; } \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/bookmark/repository/BookmarkRepository.java b/src/main/java/com/hyetaekon/hyetaekon/bookmark/repository/BookmarkRepository.java index b72564c..f43e20b 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/bookmark/repository/BookmarkRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/bookmark/repository/BookmarkRepository.java @@ -3,6 +3,7 @@ import com.hyetaekon.hyetaekon.bookmark.entity.Bookmark; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.util.Optional; @@ -10,4 +11,16 @@ @Repository public interface BookmarkRepository extends JpaRepository { + boolean existsByUserIdAndPublicServiceId(Long userId, Long serviceId); + + Optional findByUserIdAndPublicServiceId(Long userId, Long serviceId); + + /*@Query("SELECT b FROM Bookmark b " + + "JOIN FETCH b.publicService ps " + + "JOIN FETCH b.user u " + + "WHERE ps.id = :serviceId AND u.id = :userId") + Optional findByUserIdAndPublicServiceIdWithDetails( + @Param("userId") Long userId, + @Param("serviceId") Long serviceId + );*/ } \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/bookmark/service/BookmarkService.java b/src/main/java/com/hyetaekon/hyetaekon/bookmark/service/BookmarkService.java index 836d91c..41aaad6 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/bookmark/service/BookmarkService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/bookmark/service/BookmarkService.java @@ -1,14 +1,58 @@ package com.hyetaekon.hyetaekon.bookmark.service; +import com.hyetaekon.hyetaekon.bookmark.entity.Bookmark; +import com.hyetaekon.hyetaekon.bookmark.repository.BookmarkRepository; +import com.hyetaekon.hyetaekon.common.exception.GlobalException; +import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; +import com.hyetaekon.hyetaekon.publicservice.repository.PublicServiceRepository; +import com.hyetaekon.hyetaekon.user.entity.User; +import com.hyetaekon.hyetaekon.user.repository.UserRepository; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import java.util.Optional; +import static com.hyetaekon.hyetaekon.common.exception.ErrorCode.*; @Service -@RequiredArgsConstructor @Transactional +@RequiredArgsConstructor public class BookmarkService { + private final BookmarkRepository bookmarkRepository; + private final UserRepository userRepository; + private final PublicServiceRepository publicServiceRepository; + + public void addBookmark(Long serviceId, Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(BOOKMARK_USER_NOT_FOUND)); + + PublicService publicService = publicServiceRepository.findById(serviceId) + .orElseThrow(() -> new GlobalException(SERVICE_NOT_FOUND_BY_ID)); + + // 이미 북마크가 있는지 확인 + if (bookmarkRepository.existsByUserIdAndPublicServiceId(userId, serviceId)) { + throw new GlobalException(BOOKMARK_ALREADY_EXISTS); + } + + Bookmark bookmark = Bookmark.builder() + .user(user) + .publicService(publicService) + .build(); + + bookmarkRepository.save(bookmark); + + // 북마크 수 증가 + publicService.increaseBookmarkCount(); + } + + @Transactional + public void removeBookmark(Long serviceId, Long userId) { + Bookmark bookmark = bookmarkRepository.findByUserIdAndPublicServiceId(userId, serviceId) + .orElseThrow(() -> new GlobalException(BOOKMARK_NOT_FOUND)); + + bookmarkRepository.delete(bookmark); + // 북마크 수 감소 + PublicService publicService = bookmark.getPublicService(); + publicService.decreaseBookmarkCount(); + } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/chatbot/controller/ChatbotController.java b/src/main/java/com/hyetaekon/hyetaekon/chatbot/controller/ChatbotController.java new file mode 100644 index 0000000..e9f9384 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/chatbot/controller/ChatbotController.java @@ -0,0 +1,26 @@ +package com.hyetaekon.hyetaekon.chatbot.controller; + +import com.hyetaekon.hyetaekon.chatbot.dto.ChatbotDto; +import com.hyetaekon.hyetaekon.chatbot.service.ChatbotService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/chatbot") +@RequiredArgsConstructor +public class ChatbotController { + private final ChatbotService chatbotService; + + // 📌 사용자가 질문하면 답변을 반환하는 API + @GetMapping + public ResponseEntity getAnswer(@RequestParam String question) { + return ResponseEntity.ok(chatbotService.getAnswer(question)); + } + + // 📌 새로운 질문-답변을 DB에 추가하는 API (관리자용) + @PostMapping("/add") + public ResponseEntity addQuestionAndAnswer(@RequestBody ChatbotDto chatbotDto) { + return ResponseEntity.ok(chatbotService.addQuestionAndAnswer(chatbotDto)); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/chatbot/dto/ChatbotDto.java b/src/main/java/com/hyetaekon/hyetaekon/chatbot/dto/ChatbotDto.java new file mode 100644 index 0000000..929acdc --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/chatbot/dto/ChatbotDto.java @@ -0,0 +1,13 @@ +package com.hyetaekon.hyetaekon.chatbot.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ChatbotDto { + private String question; + private String answer; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/chatbot/entity/Chatbot.java b/src/main/java/com/hyetaekon/hyetaekon/chatbot/entity/Chatbot.java new file mode 100644 index 0000000..dcce1e0 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/chatbot/entity/Chatbot.java @@ -0,0 +1,28 @@ +package com.hyetaekon.hyetaekon.chatbot.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@Setter +@NoArgsConstructor +public class Chatbot { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 500) + private String question; // 질문 + + @Column(nullable = false, length = 1000) + private String answer; // 답변 + + public Chatbot(String question, String answer) { + this.question = question; + this.answer = answer; + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/chatbot/mapper/ChatbotMapper.java b/src/main/java/com/hyetaekon/hyetaekon/chatbot/mapper/ChatbotMapper.java new file mode 100644 index 0000000..17c9ca2 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/chatbot/mapper/ChatbotMapper.java @@ -0,0 +1,14 @@ +package com.hyetaekon.hyetaekon.chatbot.mapper; + +import com.hyetaekon.hyetaekon.chatbot.dto.ChatbotDto; +import com.hyetaekon.hyetaekon.chatbot.entity.Chatbot; +import org.mapstruct.Mapper; +import org.mapstruct.factory.Mappers; + +@Mapper +public interface ChatbotMapper { + ChatbotMapper INSTANCE = Mappers.getMapper(ChatbotMapper.class); + + ChatbotDto toDto(Chatbot chatbot); + Chatbot toEntity(ChatbotDto chatbotDto); +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/chatbot/repository/ChatbotRepository.java b/src/main/java/com/hyetaekon/hyetaekon/chatbot/repository/ChatbotRepository.java new file mode 100644 index 0000000..c230015 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/chatbot/repository/ChatbotRepository.java @@ -0,0 +1,10 @@ +package com.hyetaekon.hyetaekon.chatbot.repository; + +import com.hyetaekon.hyetaekon.chatbot.entity.Chatbot; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ChatbotRepository extends JpaRepository { + Optional findByQuestion(String question); +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/chatbot/service/ChatbotService.java b/src/main/java/com/hyetaekon/hyetaekon/chatbot/service/ChatbotService.java new file mode 100644 index 0000000..af93923 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/chatbot/service/ChatbotService.java @@ -0,0 +1,36 @@ +package com.hyetaekon.hyetaekon.chatbot.service; + +import com.hyetaekon.hyetaekon.chatbot.dto.ChatbotDto; +import com.hyetaekon.hyetaekon.chatbot.entity.Chatbot; +import com.hyetaekon.hyetaekon.chatbot.mapper.ChatbotMapper; +import com.hyetaekon.hyetaekon.chatbot.repository.ChatbotRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class ChatbotService { + private final ChatbotRepository chatbotRepository; + + // 📌 질문을 DB에서 찾아서 답변을 반환하는 메서드 + public ChatbotDto getAnswer(String question) { + Optional chatbot = chatbotRepository.findByQuestion(question); + + // 📌 질문이 DB에 있다면 해당 답변 반환 + if (chatbot.isPresent()) { + return ChatbotMapper.INSTANCE.toDto(chatbot.get()); + } + + // 📌 질문이 DB에 없을 경우 기본 응답 반환 + return new ChatbotDto(question, "죄송해요, 해당 질문에 대한 답변을 찾을 수 없어요."); + } + + // 📌 새로운 질문-답변을 DB에 추가하는 메서드 + public ChatbotDto addQuestionAndAnswer(ChatbotDto chatbotDto) { + Chatbot chatbot = ChatbotMapper.INSTANCE.toEntity(chatbotDto); + chatbotRepository.save(chatbot); + return chatbotDto; + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/comment/controller/AnswerController.java b/src/main/java/com/hyetaekon/hyetaekon/comment/controller/AnswerController.java deleted file mode 100644 index b5ee124..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/comment/controller/AnswerController.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.hyetaekon.hyetaekon.comment.controller; - -public class AnswerController { -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/comment/controller/CommentController.java b/src/main/java/com/hyetaekon/hyetaekon/comment/controller/CommentController.java new file mode 100644 index 0000000..c0c314a --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/comment/controller/CommentController.java @@ -0,0 +1,65 @@ +package com.hyetaekon.hyetaekon.comment.controller; + +import com.hyetaekon.hyetaekon.comment.dto.CommentDto; +import com.hyetaekon.hyetaekon.comment.service.CommentService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/posts/{postId}/comments") +@RequiredArgsConstructor +public class CommentController { + + private final CommentService commentService; + + // 게시글 댓글 목록 조회 (페이징 지원) + @GetMapping + @PreAuthorize("hasRole('USER')") + public ResponseEntity> getComments(@PathVariable Long postId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + Page comments = commentService.getComments(postId, page, size); + return ResponseEntity.ok(comments); + } + + // 게시글에 댓글 작성 + @PostMapping + @PreAuthorize("hasRole('USER')") + public ResponseEntity createComment(@PathVariable Long postId, @RequestBody CommentDto commentDto) { + CommentDto createdComment = commentService.createComment(postId, commentDto); + return ResponseEntity.status(HttpStatus.CREATED).body(createdComment); + } + + // 대댓글 목록 조회 + @GetMapping("/{commentId}/replies") + @PreAuthorize("hasRole('USER')") + public ResponseEntity> getReplies(@PathVariable Long postId, + @PathVariable Long commentId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "5") int size) { + Page replies = commentService.getReplies(postId, commentId, page, size); + return ResponseEntity.ok(replies); + } + + // 대댓글 작성 + @PostMapping("/{commentId}/replies") + @PreAuthorize("hasRole('USER')") + public ResponseEntity createReply(@PathVariable Long postId, + @PathVariable Long commentId, + @RequestBody CommentDto commentDto) { + CommentDto createdReply = commentService.createReply(postId, commentId, commentDto); + return ResponseEntity.status(HttpStatus.CREATED).body(createdReply); + } + + // 댓글 삭제 (관리자만 가능) + @DeleteMapping("/admin/comments/{commentId}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity deleteComment(@PathVariable Long commentId) { + commentService.deleteComment(commentId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/comment/dto/AnswerDto.java b/src/main/java/com/hyetaekon/hyetaekon/comment/dto/AnswerDto.java deleted file mode 100644 index 84aa543..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/comment/dto/AnswerDto.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.hyetaekon.hyetaekon.comment.dto; - -public class AnswerDto { -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/comment/dto/CommentDto.java b/src/main/java/com/hyetaekon/hyetaekon/comment/dto/CommentDto.java new file mode 100644 index 0000000..eed3798 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/comment/dto/CommentDto.java @@ -0,0 +1,18 @@ +package com.hyetaekon.hyetaekon.comment.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CommentDto { + private Long id; + private Long postId; + private Long parentId; // 대댓글일 경우 부모 댓글 ID + private String content; + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/comment/entity/Answer.java b/src/main/java/com/hyetaekon/hyetaekon/comment/entity/Answer.java deleted file mode 100644 index 94dcb73..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/comment/entity/Answer.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.hyetaekon.hyetaekon.comment.entity; - -public class Answer { -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/comment/entity/Comment.java b/src/main/java/com/hyetaekon/hyetaekon/comment/entity/Comment.java new file mode 100644 index 0000000..bf9281e --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/comment/entity/Comment.java @@ -0,0 +1,30 @@ +package com.hyetaekon.hyetaekon.comment.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "comments") +public class Comment { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long postId; + + private Long parentId; // 대댓글이면 부모 댓글 ID, 아니면 null + + @Column(nullable = false, length = 1000) + private String content; + + private LocalDateTime createdAt = LocalDateTime.now(); +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/comment/mapper/AnswerMapper.java b/src/main/java/com/hyetaekon/hyetaekon/comment/mapper/AnswerMapper.java deleted file mode 100644 index df25d0c..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/comment/mapper/AnswerMapper.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.hyetaekon.hyetaekon.comment.mapper; - -public interface AnswerMapper { -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/comment/mapper/CommentMapper.java b/src/main/java/com/hyetaekon/hyetaekon/comment/mapper/CommentMapper.java new file mode 100644 index 0000000..61fa050 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/comment/mapper/CommentMapper.java @@ -0,0 +1,18 @@ +package com.hyetaekon.hyetaekon.comment.mapper; + +import com.hyetaekon.hyetaekon.comment.dto.CommentDto; +import com.hyetaekon.hyetaekon.comment.entity.Comment; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +@Mapper(componentModel = "spring") +public interface CommentMapper { + CommentMapper INSTANCE = Mappers.getMapper(CommentMapper.class); + + CommentDto toDto(Comment comment); + + @Mapping(target = "id", ignore = true) // id는 자동 생성되므로 무시 + @Mapping(target = "createdAt", ignore = true) // createdAt 자동 설정 + Comment toEntity(CommentDto commentDto); +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/comment/repository/AnswerRepository.java b/src/main/java/com/hyetaekon/hyetaekon/comment/repository/AnswerRepository.java deleted file mode 100644 index b485726..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/comment/repository/AnswerRepository.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.hyetaekon.hyetaekon.comment.repository; - -public class AnswerRepository { -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/comment/repository/CommentRepository.java b/src/main/java/com/hyetaekon/hyetaekon/comment/repository/CommentRepository.java new file mode 100644 index 0000000..f31d589 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/comment/repository/CommentRepository.java @@ -0,0 +1,13 @@ +package com.hyetaekon.hyetaekon.comment.repository; + +import com.hyetaekon.hyetaekon.comment.entity.Comment; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface CommentRepository extends JpaRepository { + Page findByPostId(Long postId, Pageable pageable); + Page findByPostIdAndParentId(Long postId, Long parentId, Pageable pageable); +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/comment/service/AnswerService.java b/src/main/java/com/hyetaekon/hyetaekon/comment/service/AnswerService.java deleted file mode 100644 index 7535744..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/comment/service/AnswerService.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.hyetaekon.hyetaekon.comment.service; - -public class AnswerService { -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/comment/service/CommentService.java b/src/main/java/com/hyetaekon/hyetaekon/comment/service/CommentService.java new file mode 100644 index 0000000..3790730 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/comment/service/CommentService.java @@ -0,0 +1,49 @@ +package com.hyetaekon.hyetaekon.comment.service; + +import com.hyetaekon.hyetaekon.comment.dto.CommentDto; +import com.hyetaekon.hyetaekon.comment.entity.Comment; +import com.hyetaekon.hyetaekon.comment.mapper.CommentMapper; +import com.hyetaekon.hyetaekon.comment.repository.CommentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CommentService { + private final CommentRepository commentRepository; + private final CommentMapper commentMapper; + + public Page getComments(Long postId, int page, int size) { + Pageable pageable = PageRequest.of(page, size); + return commentRepository.findByPostId(postId, pageable) + .map(commentMapper::toDto); + } + + public CommentDto createComment(Long postId, CommentDto commentDto) { + Comment comment = commentMapper.toEntity(commentDto); + comment.setPostId(postId); + comment = commentRepository.save(comment); + return commentMapper.toDto(comment); + } + + public Page getReplies(Long postId, Long commentId, int page, int size) { + Pageable pageable = PageRequest.of(page, size); + return commentRepository.findByPostIdAndParentId(postId, commentId, pageable) + .map(commentMapper::toDto); + } + + public CommentDto createReply(Long postId, Long commentId, CommentDto commentDto) { + Comment reply = commentMapper.toEntity(commentDto); + reply.setPostId(postId); + reply.setParentId(commentId); + reply = commentRepository.save(reply); + return commentMapper.toDto(reply); + } + + public void deleteComment(Long commentId) { + commentRepository.deleteById(commentId); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityConfig.java b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityConfig.java new file mode 100644 index 0000000..a8fc1ca --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityConfig.java @@ -0,0 +1,107 @@ +package com.hyetaekon.hyetaekon.common.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfigurationSource; +import com.hyetaekon.hyetaekon.common.jwt.CustomAccessDeniedHandler; +import com.hyetaekon.hyetaekon.common.jwt.CustomAuthenticationEntryPoint; +import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetailsService; +import com.hyetaekon.hyetaekon.common.jwt.JwtAuthenticationFilter; +import com.hyetaekon.hyetaekon.common.jwt.JwtTokenProvider; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +@EnableMethodSecurity(prePostEnabled = true) +public class SecurityConfig { + + private final JwtTokenProvider jwtTokenProvider; + private final ObjectMapper objectMapper; + private final CustomUserDetailsService customUserDetailsService; + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final CustomAccessDeniedHandler customAccessDeniedHandler; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + http + .csrf((auth) -> auth.disable()) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .formLogin((auth) -> auth.disable()) + .httpBasic((auth) -> auth.disable()) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .headers((headerConfig) -> headerConfig + .frameOptions(frameOptionsConfig -> frameOptionsConfig.disable())); + + // 경로별 인가 작업 + http + .authorizeHttpRequests((auth) -> auth + .requestMatchers(SecurityPath.ADMIN_ENDPOINTS).hasRole("ADMIN") + .requestMatchers(SecurityPath.USER_ENDPOINTS).hasRole("USER") + .requestMatchers(SecurityPath.PUBLIC_ENDPOINTS).permitAll() + .anyRequest().permitAll() + ); + + // 예외 처리 + http + .exceptionHandling(exceptionHandling -> exceptionHandling + .authenticationEntryPoint(customAuthenticationEntryPoint) // 인증 실패 처리 + .accessDeniedHandler(customAccessDeniedHandler)); // 인가 실패 처리 + + // JwtFilter 추가 + http.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, objectMapper), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + // CORS 설정을 위한 Bean 등록 + @Bean + public CorsConfigurationSource corsConfigurationSource() { + org.springframework.web.cors.CorsConfiguration configuration = new org.springframework.web.cors.CorsConfiguration(); + configuration.addAllowedOrigin("http://localhost:3000"); // 개발 환경 + configuration.addAllowedOrigin("https://hyetaek-on.site"); // 혜택온 도메인 + configuration.addAllowedOrigin("https://www.hyetaek-on.site"); + configuration.addAllowedMethod("*"); // 모든 HTTP 메서드 허용 + configuration.addAllowedHeader("*"); // 모든 헤더 허용 + configuration.setAllowedHeaders(java.util.List.of("Authorization", "Content-Type")); + configuration.setExposedHeaders(java.util.List.of("Authorization")); + configuration.setAllowCredentials(true); // 인증 정보 허용 (쿠키 등) + + org.springframework.web.cors.UrlBasedCorsConfigurationSource source = new org.springframework.web.cors.UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); // 모든 경로에 대해 적용 + return source; + } + + // Authentication manager + @Bean + public AuthenticationManager authenticationManager( + HttpSecurity http, + PasswordEncoder passwordEncoder) throws Exception { + AuthenticationManagerBuilder authenticationManagerBuilder + = http.getSharedObject(AuthenticationManagerBuilder.class); + authenticationManagerBuilder + .userDetailsService(customUserDetailsService) + .passwordEncoder(passwordEncoder); + + return authenticationManagerBuilder.build(); + } + + // 비밀번호 암호화 저장을 위한 Encoder Bean 등록 + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java new file mode 100644 index 0000000..2d23372 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java @@ -0,0 +1,39 @@ +package com.hyetaekon.hyetaekon.common.config; + + +public class SecurityPath { + + // permitAll + public static final String[] PUBLIC_ENDPOINTS = { + "/api/signup", + "/api/login", + "/api/token/refresh", + "/api/users/check-duplicate", + "/", + "/api/services", + "/api/services/category/*", + "/api/services/detail/*", + "/api/public-data/serviceList/test" + }; + + + // hasRole("USER") + public static final String[] USER_ENDPOINTS = { + "/api/users/me/**", + "/api/users/me", + "/api/logout", + "/api/services/popular", + "/api/services/*/bookmark", + "/api/interests", + "/api/interests/me" + }; + + // hasRole("ADMIN") + public static final String[] ADMIN_ENDPOINTS = { + "/api/admin/users/**", + "/api/public-data/serviceDetailList", + "/api/public-data/supportConditionsList", + "/api/public-data/serviceList" + }; +} + diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/config/redis/RedisConfig.java b/src/main/java/com/hyetaekon/hyetaekon/common/config/redis/RedisConfig.java new file mode 100644 index 0000000..edad542 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/config/redis/RedisConfig.java @@ -0,0 +1,49 @@ +package com.hyetaekon.hyetaekon.common.config.redis; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisPassword; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String redisHost; + + @Value("${spring.data.redis.port}") + private int redisPort; + + @Value("${spring.data.redis.password}") + private String redisPassword; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration redisConfig = new RedisStandaloneConfiguration(); + redisConfig.setHostName(redisHost); + redisConfig.setPort(redisPort); + + if (!redisPassword.isEmpty()) { + redisConfig.setPassword(RedisPassword.of(redisPassword)); + } + + return new LettuceConnectionFactory(redisConfig); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory); + + // Redis에 저장되는 데이터의 직렬화 방식을 지정 + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); + return redisTemplate; + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/exception/ErrorCode.java b/src/main/java/com/hyetaekon/hyetaekon/common/exception/ErrorCode.java index 052fe5a..215ae9a 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/exception/ErrorCode.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/exception/ErrorCode.java @@ -20,16 +20,31 @@ public enum ErrorCode { BLACKLIST_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH-010", "사용할 수 없는 액세스 토큰입니다."), // 계정 관련 - DUPLICATED_EMAIL(HttpStatus.CONFLICT, "ACCOUNT-001", "이미 존재하는 이메일입니다."), - USER_NOT_FOUND_BY_EMAIL(HttpStatus.NOT_FOUND, "ACCOUNT-002", "해당 이메일의 회원을 찾을 수 없습니다."), + DUPLICATED_REAL_ID(HttpStatus.CONFLICT, "ACCOUNT-001", "이미 존재하는 아이디입니다."), + USER_NOT_FOUND_BY_REAL_ID(HttpStatus.NOT_FOUND, "ACCOUNT-002", "해당 아이디의 회원을 찾을 수 없습니다."), USER_NOT_FOUND_BY_ID(HttpStatus.NOT_FOUND, "ACCOUNT-003", "해당 아이디의 회원을 찾을 수 없습니다."), DUPLICATED_NICKNAME(HttpStatus.CONFLICT, "ACCOUNT-004", "이미 사용 중인 닉네임입니다."), PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "ACCOUNT-005", "현재 비밀번호가 일치하지 않습니다."), PASSWORD_SAME_AS_OLD(HttpStatus.BAD_REQUEST, "ACCOUNT-006", "새로운 비밀번호는 현재 비밀번호와 달라야 합니다."), CURRENT_PASSWORD_REQUIRED(HttpStatus.BAD_REQUEST, "ACCOUNT-007", "현재 비밀번호를 입력해야 합니다."), + FORBIDDEN_ACCESS(HttpStatus.FORBIDDEN, "AUTH-011", "잘못된 접근입니다."), + NOT_SUSPENDED_USER(HttpStatus.BAD_REQUEST, "ACCOUNT-008", "정지 상태가 아닌 회원입니다."), + INVALID_SUSPEND_TIME(HttpStatus.BAD_REQUEST, "ACCOUNT-009", "정지 기간이 유효하지 않습니다."), + PASSWORD_CONFIRM_MISMATCH(HttpStatus.BAD_REQUEST, "ACCOUNT-010", "새 비밀번호와 확인 비밀번호가 일치하지 않습니다."), + + // 북마크 + BOOKMARK_USER_NOT_FOUND(HttpStatus.NOT_FOUND, "BOOKMARK-001", "북마크한 유저를 찾을 수 없습니다."), + BOOKMARK_ALREADY_EXISTS(HttpStatus.CONFLICT, "BOOKMARK-002", "이미 북마크한 서비스입니다."), + BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, "BOOKMARK-003", "북마크 정보를 찾을 수 없습니다."), + + // 좋아요 + RECOMMEND_ALREADY_EXISTS(HttpStatus.CONFLICT, "RECOMMEND-001", "이미 좋아요를 누른 게시글입니다."), + RECOMMEND_NOT_FOUND(HttpStatus.NOT_FOUND, "RECOMMEND-002", "좋아요 정보를 찾을 수 없습니다."), + POST_NOT_FOUND(HttpStatus.NOT_FOUND, "POST-001", "해당 게시글을 찾을 수 없습니다."), // 관심사 선택 제한 INTEREST_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "INTEREST-001", "관심사는 최대 6개까지만 등록 가능합니다."), + INVALID_INTEREST(HttpStatus.BAD_REQUEST, "INTEREST-002", "유효하지 않은 관심사입니다."), // 공공서비스 // 유효 JACODE 확인 diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/jwt/BlacklistService.java b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/BlacklistService.java new file mode 100644 index 0000000..20ff9e6 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/BlacklistService.java @@ -0,0 +1,63 @@ +package com.hyetaekon.hyetaekon.common.jwt; + + +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BlacklistService { + + private final RedisTemplate redisTemplate; + private final JwtTokenParser jwtTokenParser; + + private static final String ACCESS_TOKEN_BLACKLIST_PREFIX="blacklist:access:"; + + public void addToBlacklist(String accessToken) { + // Access Token 만료 시간 계산 + Claims claims = jwtTokenParser.parseClaims(accessToken); + Date expiredDate = claims.getExpiration(); + String jti = claims.getId(); + long now = System.currentTimeMillis(); + long timeToLive = (expiredDate.getTime() - now) / 1000; + + if (timeToLive > 0) { + // Redis에 블랙리스트 등록 + String redisKey = ACCESS_TOKEN_BLACKLIST_PREFIX + jti; + redisTemplate.opsForValue().set(redisKey, "blacklisted", timeToLive, TimeUnit.SECONDS); + log.debug("Access Token 블랙리스트 추가 - JTI: {}, 만료 시간: {}초 후", jti, timeToLive); + } + + } + + + public boolean isTokenBlacklisted(String jti) { + String redisKey = ACCESS_TOKEN_BLACKLIST_PREFIX + jti; + try { + Boolean exists = redisTemplate.hasKey(redisKey); + if (Boolean.TRUE.equals(exists)) { + log.warn("블랙리스트에 있는 Access Token으로 접근 시도 중 - JTI: {}", jti); + } + return Boolean.TRUE.equals(exists); + } catch (Exception e) { + log.error("Redis 연결 중 오류 발생 - JTI: {}, 오류: {}", jti, e.getMessage()); + return false; // Redis가 죽었을 때 기본값을 false로 + } + } + + + public void removeFromBlacklist(String jti) { + String redisKey = "blacklist:access:" + jti; + redisTemplate.delete(redisKey); + log.debug("Access Token 블랙리스트에서 삭제 - JTI: {}", jti); + } + + +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/jwt/CustomAccessDeniedHandler.java b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..4136246 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/CustomAccessDeniedHandler.java @@ -0,0 +1,49 @@ +package com.hyetaekon.hyetaekon.common.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hyetaekon.hyetaekon.common.exception.ErrorCode; +import com.hyetaekon.hyetaekon.common.exception.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +@Component +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + private final ObjectMapper objectMapper; + + @Override + public void handle(HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + log.error("No Authorities", accessDeniedException); + log.error("Request Uri : {}", request.getRequestURI()); + + // ErrorCode 정의 + ErrorCode errorCode = ErrorCode.FORBIDDEN_ACCESS; + + // ErrorResponse 생성 + ErrorResponse errorResponse = ErrorResponse.builder() + .code(errorCode.getCode()) + .message(errorCode.getMessage()) + .build(); + + // HTTP 응답 설정 + response.setStatus(HttpStatus.FORBIDDEN.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + // 응답 본문에 JSON 데이터 작성 + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } + +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/jwt/CustomAuthenticationEntryPoint.java b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..7e4b7d3 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/CustomAuthenticationEntryPoint.java @@ -0,0 +1,51 @@ +package com.hyetaekon.hyetaekon.common.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hyetaekon.hyetaekon.common.exception.ErrorCode; +import com.hyetaekon.hyetaekon.common.exception.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) throws IOException { + log.error("Not Authenticated Request", authException); + log.error("Request Uri : {}", request.getRequestURI()); + + // UNAUTHORIZED ErrorCode 사용 + ErrorCode errorCode = ErrorCode.ACCESS_DENIED; + + // ErrorResponse 생성 + ErrorResponse errorResponse = ErrorResponse.builder() + .code(errorCode.getCode()) + .message(errorCode.getMessage()) + .build(); + + // HTTP 응답 설정 + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setStatus(errorCode.getHttpStatus().value()); + response.setCharacterEncoding("UTF-8"); + + // JSON 응답 반환 + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } + +} + diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/jwt/CustomUserDetails.java b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/CustomUserDetails.java new file mode 100644 index 0000000..dfb23a6 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/CustomUserDetails.java @@ -0,0 +1,69 @@ +package com.hyetaekon.hyetaekon.common.jwt; + +import java.util.Collection; +import java.util.Collections; + +import com.hyetaekon.hyetaekon.user.entity.Role; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +@Getter +public class CustomUserDetails implements UserDetails, CustomUserPrincipal { + private final Long id; + private final String realId; + private final String nickname; + private final Role role; + private final String password; + private final String name; + + public CustomUserDetails(Long id, String realId, String nickname, Role role, String password, String name) { + this.id = id; + this.realId = realId; + this.nickname = nickname; + this.role = role; + this.password = password; + this.name = name; + } + + @Override + public Collection getAuthorities() { + return Collections.singletonList(new SimpleGrantedAuthority(role.name())); // 문자열 기반 권한 + } + + @Override + public String getRole() { + return role.name(); + } + + @Override + public String getPassword() { + return this.password; + } + + @Override + public String getUsername() { + return realId; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/jwt/CustomUserDetailsService.java b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/CustomUserDetailsService.java new file mode 100644 index 0000000..46f89ff --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/CustomUserDetailsService.java @@ -0,0 +1,32 @@ +package com.hyetaekon.hyetaekon.common.jwt; + +import com.hyetaekon.hyetaekon.user.entity.User; +import com.hyetaekon.hyetaekon.user.repository.UserRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + public CustomUserDetailsService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public UserDetails loadUserByUsername(String realId) throws UsernameNotFoundException { + // DB에서 사용자 조회 + User user = userRepository.findByRealIdAndDeletedAtIsNull(realId) + .orElseThrow(() -> new UsernameNotFoundException("해당하는 정보의 사용자를 찾을 수 없습니다.")); + + // CustomUserDetails 생성 + return new CustomUserDetails( + user.getId(), user.getRealId(), user.getNickname(), user.getRole(), user.getPassword(), user.getName() + ); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/jwt/CustomUserPrincipal.java b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/CustomUserPrincipal.java new file mode 100644 index 0000000..528285a --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/CustomUserPrincipal.java @@ -0,0 +1,8 @@ +package com.hyetaekon.hyetaekon.common.jwt; + +public interface CustomUserPrincipal { + String getRealId(); + String getNickname(); + String getRole(); + String getName(); +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/jwt/JwtAuthenticationFilter.java b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..a53a431 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,109 @@ +package com.hyetaekon.hyetaekon.common.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hyetaekon.hyetaekon.common.exception.ErrorCode; +import com.hyetaekon.hyetaekon.common.exception.ErrorResponse; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collection; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + @Value("${jwt.secret-key}") + private String secretKey; + private final JwtTokenProvider jwtTokenProvider; + private final ObjectMapper objectMapper; + private static final String BEARER = "Bearer"; + private static final String ADMIN_API_PREFIX = "/api/admin"; + + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + String requestURI = request.getRequestURI(); + boolean isAdminRequest = requestURI.startsWith(ADMIN_API_PREFIX); + String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + + // access token이 있고, BEARER로 시작한다면 + if (authHeader != null && authHeader.startsWith(BEARER)) { + String token = authHeader.substring(BEARER.length()); + // 토큰 검증 + if (jwtTokenProvider.validateToken(token)) { + // 유효한 토큰: 유저 정보 가져옴 + Authentication authentication = jwtTokenProvider.getAuthentication(token); + + // 관리자 API 접근 시 추가 검증 + if (isAdminRequest && !hasAdminRole(authentication.getAuthorities())) { + log.warn("관리자 권한 없이 관리자 리소스에 접근 시도: {}", requestURI); + sendAccessDeniedResponse(response); + return; + } + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } else if (isAdminRequest) { + // 관리자 API 접근 시 토큰 없으면 Unauthorized 응답 + log.warn("인증 없이 관리자 리소스에 접근 시도: {}", requestURI); + sendUnauthorizedResponse(response); + return; + } + + filterChain.doFilter(request, response); + } + + private boolean hasAdminRole(Collection authorities) { + return authorities.stream() + .anyMatch(authority -> "ROLE_ADMIN".equals(authority.getAuthority())); + } + + // 접근 거부 응답 처리(403 - 권한 없음) + private void sendAccessDeniedResponse(HttpServletResponse response) throws IOException { + log.warn("403 Forbidden - 접근 거부됨"); + + ErrorCode errorCode = ErrorCode.ACCESS_DENIED; + ErrorResponse errorResponse = ErrorResponse.builder() + .code(errorCode.getCode()) + .message(errorCode.getMessage()) + .build(); + + response.setStatus(errorCode.getHttpStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + + } + + // 인증 실패 응답 처리 (401 - 인증 실패) + private void sendUnauthorizedResponse(HttpServletResponse response) throws IOException { + log.warn("401 Unauthorized - 인증 실패"); + + ErrorCode errorCode = ErrorCode.NO_TOKEN; + ErrorResponse errorResponse = ErrorResponse.builder() + .code(errorCode.getCode()) + .message(errorCode.getMessage()) + .build(); + + response.setStatus(errorCode.getHttpStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/jwt/JwtToken.java b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/JwtToken.java new file mode 100644 index 0000000..1d456b9 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/JwtToken.java @@ -0,0 +1,14 @@ +package com.hyetaekon.hyetaekon.common.jwt; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Builder +@AllArgsConstructor +@Getter +public class JwtToken { + private String grantType; + private String accessToken; + private String refreshToken; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/jwt/JwtTokenParser.java b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/JwtTokenParser.java new file mode 100644 index 0000000..a5fc27f --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/JwtTokenParser.java @@ -0,0 +1,45 @@ +package com.hyetaekon.hyetaekon.common.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.security.Key; + +@Slf4j +@Component +public class JwtTokenParser { + + private final Key secretKey; + + public JwtTokenParser(@Value("${jwt.secret-key}") String secretKey) { + byte[] keyBytes = secretKey.getBytes(); + this.secretKey = Keys.hmacShaKeyFor(keyBytes); + } + + // Access Token에서 Claims 추출 + public Claims parseClaims(String accessToken) { + try { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(accessToken) + .getBody(); + } catch (ExpiredJwtException e) { + return e.getClaims(); + } + } + + // 토큰에서 이메일 정보 추출 + public String getRealIdFromToken(String accessToken) { + Claims claims = Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(accessToken) + .getBody(); + return claims.getSubject(); + }} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/jwt/JwtTokenProvider.java b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..16ce47a --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/JwtTokenProvider.java @@ -0,0 +1,159 @@ +package com.hyetaekon.hyetaekon.common.jwt; + +import com.hyetaekon.hyetaekon.common.exception.ErrorCode; +import com.hyetaekon.hyetaekon.common.exception.GlobalException; +import com.hyetaekon.hyetaekon.user.entity.Role; +import com.hyetaekon.hyetaekon.user.entity.User; +import com.hyetaekon.hyetaekon.user.repository.UserRepository; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.UUID; + +@Slf4j +@Getter +@Component +public class JwtTokenProvider { + + private final Key secretKey; + + @Autowired + private UserRepository userRepository; + + @Autowired + private RefreshTokenService refreshTokenService; + + @Autowired + private BlacklistService blackListService; + + @Autowired + private JwtTokenParser jwtTokenParser; + + @Value("${jwt.access-expired}") + private Long accessTokenExpired; + + @Value("${jwt.refresh-expired}") + private Long refreshTokenExpired; + + + public JwtTokenProvider(@Value("${jwt.secret-key}") String secretKey) { + byte[] keyBytes = secretKey.getBytes(); + this.secretKey = Keys.hmacShaKeyFor(keyBytes); + } + + // 토큰 생성 - 유저 정보 이용 + public JwtToken generateToken(Authentication authentication) { + + long now = (new Date()).getTime(); + Date accessTokenExpiration = new Date(now + accessTokenExpired * 1000); + + CustomUserPrincipal userPrincipal = (CustomUserPrincipal) authentication.getPrincipal(); + + String jti = UUID.randomUUID().toString(); + // Access Token 생성 + String accessToken = Jwts.builder() + .setSubject(userPrincipal.getRealId()) // 이메일을 Subject로 설정 + .setIssuedAt(new Date()) // 발행 시간 + .setId(jti) // blacklist 관리를 위한 jwt token id + .claim("nickname", userPrincipal.getNickname()) // 닉네임 + .claim("role", userPrincipal.getRole()) // 사용자 역할(Role) + .claim("name",userPrincipal.getName()) + .setExpiration(accessTokenExpiration) // 만료 시간 + .signWith(secretKey, SignatureAlgorithm.HS256) // 서명 + .compact(); + + // Refresh Token 생성 (임의의 값 생성) + String refreshToken = UUID.randomUUID().toString(); + + // Redis에 Refresh Token 정보 저장 + refreshTokenService.saveRefreshToken( refreshToken, userPrincipal.getRealId(), refreshTokenExpired); + + + // JWT Token 객체 반환 + return JwtToken.builder() + .grantType("Bearer") + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + + } + + + // 토큰에서 유저 정보 추출 + public Authentication getAuthentication(String accessToken) { + // 토큰에서 Claims 추출 + Claims claims = jwtTokenParser.parseClaims(accessToken); + + // 권한 정보 확인 + if (claims.get("role") == null) { + throw new GlobalException(ErrorCode.ROLE_NOT_FOUND); + } + + // 사용자 정보 추출 + String realId = claims.getSubject(); // 토큰 subject에서 realId 추출 + String nickname = claims.get("nickname").toString(); // nickname 추출 + String password = claims.get("password", String.class); + String name = claims.get("name", String.class); + + String roleName = claims.get("role", String.class); // 문자열로 읽기 + + // 문자열에서 Role 객체로 변환 + Role role = Role.valueOf(roleName); // Enum이라면 가능 + + // realId로 User 조회 + User user = userRepository.findByRealIdAndDeletedAtIsNull(realId) + .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다.")); + + // 사용자 ID 가져오기 + Long id = user.getId(); + + // CustomUserDetails 생성 + CustomUserDetails userDetails = new CustomUserDetails(id, realId, nickname, role, password, name); + + // 문자열을 GrantedAuthority로 변환 + Collection authorities = + Collections.singletonList(() -> roleName); + + + // Authentication 객체 반환 + return new UsernamePasswordAuthenticationToken(userDetails, null, authorities); + } + + // 토큰 정보 검증 + public boolean validateToken(String token) { + log.debug("validateToken start"); + try { + Claims claims = Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + + String jti = claims.getId(); // JTI 추출 + return !blackListService.isTokenBlacklisted(jti); + } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { + log.error("Invalid JWT Token", e); + } catch (ExpiredJwtException e) { + log.error("Expired JWT Token", e); + } catch (UnsupportedJwtException e) { + log.error("Unsupported JWT Token", e); + } catch (IllegalArgumentException e) { + log.error("JWT claims string is empty.", e); + } + return false; + } + +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/jwt/RefreshToken.java b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/RefreshToken.java new file mode 100644 index 0000000..0580b24 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/RefreshToken.java @@ -0,0 +1,24 @@ +package com.hyetaekon.hyetaekon.common.jwt; + +import lombok.*; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@RedisHash("refreshToken") +public class RefreshToken { + + @Id + private String refreshToken; + + private String realId; + private Long issuedAt; + + // Time to live (TTL) 설정, Redis에 만료 시간을 설정 + @TimeToLive + private Long ttl; +} \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/jwt/RefreshTokenRepository.java b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/RefreshTokenRepository.java new file mode 100644 index 0000000..ceeaf22 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/RefreshTokenRepository.java @@ -0,0 +1,8 @@ +package com.hyetaekon.hyetaekon.common.jwt; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface RefreshTokenRepository extends CrudRepository { +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/jwt/RefreshTokenService.java b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/RefreshTokenService.java new file mode 100644 index 0000000..a7b9ebd --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/jwt/RefreshTokenService.java @@ -0,0 +1,36 @@ +package com.hyetaekon.hyetaekon.common.jwt; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Optional; + + +@Slf4j +@Service +@RequiredArgsConstructor +public class RefreshTokenService { + private final RefreshTokenRepository refreshTokenRepository; + + public void saveRefreshToken(String refreshToken, String realId, long refreshTokenExpired) { + RefreshToken token = RefreshToken.builder() + .refreshToken(refreshToken) + .realId(realId) + .issuedAt(System.currentTimeMillis()) + .ttl(refreshTokenExpired) // @TimeToLive에 사용될 만료 시간 + .build(); + + refreshTokenRepository.save(token); + } + + + public Optional getRefreshToken(String refreshToken) { + return refreshTokenRepository.findById(refreshToken); + } + + + public void deleteRefreshToken(String refreshToken) { + refreshTokenRepository.deleteById(refreshToken); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/controller/PublicServiceDataController.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/controller/PublicServiceDataController.java index f509a37..8935573 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/controller/PublicServiceDataController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/controller/PublicServiceDataController.java @@ -69,7 +69,7 @@ public ResponseEntity createAndStoreSupportConditionsList() { /** * 페이지 단위 공공서비스 목록 조회 (테스트용) */ - @GetMapping("/serviceList") + @GetMapping("/serviceList/test") public ResponseEntity> getServiceListByPage( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "100") int perPage) { @@ -78,8 +78,8 @@ public ResponseEntity> getServiceListByPage( List dtoList = publicServiceDataService.fetchPublicServiceData(SERVICE_LIST, page, perPage); return dtoList.stream() - .filter(dto -> dto.getResponse() != null && dto.getResponse().getData() != null) - .flatMap(dto -> dto.getResponse().getData().stream()) + .filter(dto -> dto.getData() != null) + .flatMap(dto -> dto.getData().stream()) .toList(); }, SERVICE_LIST); diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceConditionsDataDto.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceConditionsDataDto.java index d481d1a..9e7a019 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceConditionsDataDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceConditionsDataDto.java @@ -15,21 +15,12 @@ @AllArgsConstructor @NoArgsConstructor public class PublicServiceConditionsDataDto { - private Response response; - - @Getter - @NoArgsConstructor - @AllArgsConstructor - @Builder - @JsonNaming(PropertyNamingStrategies.LowerCamelCaseStrategy.class) - public static class Response { - private List data; - private long totalCount; - private long currentCount; - private long matchCount; - private long page; - private long perPage; - } + private List data; + private long totalCount; + private long currentCount; + private long matchCount; + private long page; + private long perPage; @Getter @NoArgsConstructor @@ -72,7 +63,7 @@ public static class Data { private String JA0330; // 질병/질환자 // Family Type - private String JA0410; // 해당사항 없음 + // private String JA0410; // 해당사항 없음 private String JA0411; // 다자녀가구 private String JA0412; // 무주택세대 private String JA0413; // 신규전입 diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceConditionsDataParsedResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceConditionsDataParsedResponseDto.java deleted file mode 100644 index 19fb544..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceConditionsDataParsedResponseDto.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.hyetaekon.hyetaekon.common.publicdata.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -// 임시 -@Getter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class PublicServiceConditionsDataParsedResponseDto { - private long serviceId; - - private String targetGenderMale; - private String targetGenderFemale; - private Integer targetAgeStart; - private Integer targetAgeEnd; - - // 소득수준 - private String incomeLevelVeryLow; // 중위소득 0~50% - private String incomeLevelLow; // 중위소득 51~75% - private String incomeLevelMedium; // 중위소득 76~100% - private String incomeLevelHigh; // 중위소득 101~200% - private String incomeLevelVeryHigh; // 중위소득 200% 초과 - - // Special Group - private String JA0401; // 다문화가족 - private String JA0402; // 북한이탈주민 - private String JA0403; // 한부모가정/조손가정 - private String JA0404; // 1인가구 - private String JA0328; // 장애인 - private String JA0329; // 국가보훈대상자 - private String JA0330; // 질병/질환자 - - // Family Type - private String JA0410; // 해당사항 없음 - private String JA0411; // 다자녀가구 - private String JA0412; // 무주택세대 - private String JA0413; // 신규전입 - private String JA0414; // 확대가족 - - // Occupation - private String JA0313; // 농업인 - private String JA0314; // 어업인 - private String JA0315; // 축산업인 - private String JA0316; // 임업인 - private String JA0317; // 초등학생 - private String JA0318; // 중학생 - private String JA0319; // 고등학생 - private String JA0320; // 대학생/대학원생 - private String JA0326; // 근로자/직장인 - private String JA0327; // 구직자/실업자 - - // Business Type - private String JA1101; // 예비 창업자 - private String JA1102; // 영업중 - private String JA1103; // 생계곤란/폐업예정자 - private String JA1201; // 음식업 - private String JA1202; // 제조업 - private String JA1299; // 기타업종 - private String JA2101; // 중소기업 - private String JA2102; // 사회복지시설 - private String JA2103; // 기관/단체 - private String JA2201; // 제조업 - private String JA2202; // 농업, 임업 및 어업 - private String JA2203; // 정보통신업 - private String JA2299; // 기타업종 - -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDataDto.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDataDto.java index d637db9..25643ff 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDataDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDataDto.java @@ -15,21 +15,12 @@ @AllArgsConstructor @NoArgsConstructor public class PublicServiceDataDto { - private Response response; - - @Getter - @NoArgsConstructor - @AllArgsConstructor - @Builder - @JsonNaming(PropertyNamingStrategies.LowerCamelCaseStrategy.class) - public static class Response { - private List data; - private long totalCount; - private long currentCount; - private long matchCount; - private long page; - private long perPage; - } + private List data; + private long totalCount; + private long currentCount; + private long matchCount; + private long page; + private long perPage; @Getter @NoArgsConstructor @@ -49,7 +40,7 @@ public static class Data { @JsonProperty("서비스목적요약") private String summaryPurpose; - @JsonProperty("소관기관명") + /*@JsonProperty("소관기관명") private String governingAgency; @JsonProperty("부서명") @@ -59,6 +50,6 @@ public static class Data { private String userType; @JsonProperty("신청기한") - private String applicationDeadline; + private String applicationDeadline;*/ } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDataParsedResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDataParsedResponseDto.java deleted file mode 100644 index 0311465..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDataParsedResponseDto.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.hyetaekon.hyetaekon.common.publicdata.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -// 임시 -@Getter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class PublicServiceDataParsedResponseDto { - private long serviceId; - private String serviceName; - private String serviceCategory; - private String summaryPurpose; - private String governingAgency; - private String department; - private String userType; - private String applicationDeadline; -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDetailDataDto.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDetailDataDto.java index a7e0b0f..742ea6c 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDetailDataDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDetailDataDto.java @@ -16,21 +16,12 @@ @AllArgsConstructor @NoArgsConstructor public class PublicServiceDetailDataDto { - private Response response; - - @Getter - @NoArgsConstructor - @AllArgsConstructor - @Builder - @JsonNaming(PropertyNamingStrategies.LowerCamelCaseStrategy.class) - public static class Response { - private List data; - private long totalCount; - private long currentCount; - private long matchCount; - private long page; - private long perPage; - } + private List data; + private long totalCount; + private long currentCount; + private long matchCount; + private long page; + private long perPage; @Getter @NoArgsConstructor @@ -41,6 +32,9 @@ public static class Data { @JsonProperty("서비스ID") private long serviceId; + @JsonProperty("서비스명") + private String serviceName; + @JsonProperty("서비스목적") private String servicePurpose; @@ -59,8 +53,11 @@ public static class Data { @JsonProperty("신청방법") private String applicationMethod; - @JsonProperty("구비서류") - private String requiredDocuments; + @JsonProperty("신청기한") + private String applicationDeadline; + + /*@JsonProperty("구비서류") + private String requiredDocuments;*/ @JsonProperty("문의처") private String contactInfo; @@ -68,7 +65,10 @@ public static class Data { @JsonProperty("온라인신청사이트URL") private String onlineApplicationUrl; - @JsonProperty("법령") - private String relatedLaws; + @JsonProperty("소관기관명") + private String governingAgency; + + /*@JsonProperty("법령") + private String relatedLaws;*/ } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDetailDataParsedResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDetailDataParsedResponseDto.java deleted file mode 100644 index 2383f76..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/dto/PublicServiceDetailDataParsedResponseDto.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.hyetaekon.hyetaekon.common.publicdata.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -// 임시 -@Getter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class PublicServiceDetailDataParsedResponseDto { - private long serviceId; - private String servicePurpose; - private String supportTarget; - private String selectionCriteria; - private String supportDetail; - private String supportType; - private String applicationMethod; - private String requiredDocuments; - private String contactInfo; - private String onlineApplicationUrl; - private String relatedLaws; -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataProviderService.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataProviderService.java index 12d7242..6d31222 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataProviderService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataProviderService.java @@ -77,9 +77,9 @@ void updateFamilyTypes(PublicService publicService, PublicServiceConditionsDataD // 새로운 가족 유형 정보 추가 List familyTypes = new ArrayList<>(); - if ("Y".equals(data.getJA0410())) { + /*if ("Y".equals(data.getJA0410())) { familyTypes.add(createFamilyType(publicService, FamilyTypeEnum.IS_NOT_APPLICABLE)); - } + }*/ if ("Y".equals(data.getJA0411())) { familyTypes.add(createFamilyType(publicService, FamilyTypeEnum.IS_MULTI_CHILDREN_FAMILY)); } diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataServiceImpl.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataServiceImpl.java index add17f8..f6d8315 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataServiceImpl.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataServiceImpl.java @@ -15,9 +15,12 @@ import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; + import java.net.URI; import java.util.*; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.function.BiFunction; import java.util.stream.Collectors; @@ -91,52 +94,20 @@ public List fetchPublicServiceConditionsData(Pub */ @Transactional public void syncPublicServiceData(PublicDataPath apiPath) { - currentServiceIds.clear(); // 현재 서비스 ID 초기화 - - int page = 1; - int perPage = DEFAULT_PAGE_SIZE; - boolean hasMoreData = true; - long totalProcessed = 0; - - try { - while (hasMoreData) { - List dtoList = fetchPublicServiceData(apiPath, page, perPage); - List pageData = new ArrayList<>(); - - // 현재 페이지 데이터 추출 - for (PublicServiceDataDto dto : dtoList) { - if (dto.getResponse() != null && dto.getResponse().getData() != null) { - pageData.addAll(dto.getResponse().getData()); - - // 전체 데이터 개수 확인 - long totalCount = dto.getResponse().getTotalCount(); - long currentCount = dto.getResponse().getCurrentCount(); - - // 더 이상 데이터가 없는지 확인 - if (currentCount < perPage || page * perPage >= totalCount) { - hasMoreData = false; - } - } else { - hasMoreData = false; - } - } - - if (!pageData.isEmpty()) { - upsertServiceData(pageData); - totalProcessed += pageData.size(); - log.info("공공서비스 목록 데이터 페이지 {} 처리 완료: {}건, 총 {}건", page, pageData.size(), totalProcessed); - } else { - hasMoreData = false; - } - - page++; - } - } catch (Exception e) { - log.error("공공서비스 목록 데이터 동기화 중 오류 발생", e); - throw e; - } - - log.info("공공서비스 목록 데이터 전체 동기화 완료: 총 {}건", totalProcessed); + syncDataWithPaging( + apiPath, + // 데이터 조회 함수 + (path, pg) -> fetchPublicServiceData(path, pg, DEFAULT_PAGE_SIZE), + // 데이터 추출 함수 + PublicServiceDataDto::getData, + // 전체 개수 getter + PublicServiceDataDto::getTotalCount, + // 현재 페이지 개수 getter + PublicServiceDataDto::getCurrentCount, + // 데이터 처리 함수 + this::upsertServiceData, + "공공서비스 목록 데이터" + ); } /** @@ -144,50 +115,15 @@ public void syncPublicServiceData(PublicDataPath apiPath) { */ @Transactional public void syncPublicServiceDetailData(PublicDataPath apiPath) { - int page = 1; - int perPage = DEFAULT_PAGE_SIZE; - boolean hasMoreData = true; - long totalProcessed = 0; - - try { - while (hasMoreData) { - List dtoList = fetchPublicServiceDetailData(apiPath, page, perPage); - List pageData = new ArrayList<>(); - - // 현재 페이지 데이터 추출 - for (PublicServiceDetailDataDto dto : dtoList) { - if (dto.getResponse() != null && dto.getResponse().getData() != null) { - pageData.addAll(dto.getResponse().getData()); - - // 전체 데이터 개수 확인 - long totalCount = dto.getResponse().getTotalCount(); - long currentCount = dto.getResponse().getCurrentCount(); - - // 더 이상 데이터가 없는지 확인 - if (currentCount < perPage || page * perPage >= totalCount) { - hasMoreData = false; - } - } else { - hasMoreData = false; - } - } - - if (!pageData.isEmpty()) { - upsertServiceDetailData(pageData); - totalProcessed += pageData.size(); - log.info("공공서비스 상세정보 데이터 페이지 {} 처리 완료: {}건, 총 {}건", page, pageData.size(), totalProcessed); - } else { - hasMoreData = false; - } - - page++; - } - } catch (Exception e) { - log.error("공공서비스 상세정보 데이터 동기화 중 오류 발생", e); - throw e; - } - - log.info("공공서비스 상세정보 데이터 전체 동기화 완료: 총 {}건", totalProcessed); + syncDataWithPaging( + apiPath, + (path, pg) -> fetchPublicServiceDetailData(path, pg, DEFAULT_PAGE_SIZE), + PublicServiceDetailDataDto::getData, + PublicServiceDetailDataDto::getTotalCount, + PublicServiceDetailDataDto::getCurrentCount, + this::upsertServiceDetailData, + "공공서비스 상세정보 데이터" + ); } /** @@ -195,6 +131,31 @@ public void syncPublicServiceDetailData(PublicDataPath apiPath) { */ @Transactional public void syncPublicServiceConditionsData(PublicDataPath apiPath) { + syncDataWithPaging( + apiPath, + (path, pg) -> fetchPublicServiceConditionsData(path, pg, DEFAULT_PAGE_SIZE), + PublicServiceConditionsDataDto::getData, + PublicServiceConditionsDataDto::getTotalCount, + PublicServiceConditionsDataDto::getCurrentCount, + this::upsertSupportConditionsData, + "공공서비스 지원조건 데이터" + ); + } + + /** + * 공통 데이터 동기화 메서드 - 중복 코드 제거를 위한 템플릿 메서드 + */ + private long syncDataWithPaging( + PublicDataPath apiPath, + BiFunction> fetcher, // 데이터 조회 함수 + Function> dataExtractor, // DTO에서 데이터 추출 함수 + Function totalCountGetter, // 전체 개수 조회 함수 + Function currentCountGetter, // 현재 페이지 개수 조회 함수 + Function, List> processor, // 데이터 처리 함수 + String operationName) { // 작업 이름 (로깅용) + + currentServiceIds.clear(); // ID 초기화 (필요한 경우) + int page = 1; int perPage = DEFAULT_PAGE_SIZE; boolean hasMoreData = true; @@ -202,20 +163,22 @@ public void syncPublicServiceConditionsData(PublicDataPath apiPath) { try { while (hasMoreData) { - List dtoList = fetchPublicServiceConditionsData(apiPath, page, perPage); - List pageData = new ArrayList<>(); + // 데이터 조회 + List dtoList = fetcher.apply(apiPath, page); + List pageData = new ArrayList<>(); - // 현재 페이지 데이터 추출 - for (PublicServiceConditionsDataDto dto : dtoList) { - if (dto.getResponse() != null && dto.getResponse().getData() != null) { - pageData.addAll(dto.getResponse().getData()); + // 페이지 데이터 추출 + for (T dto : dtoList) { + List extractedData = dataExtractor.apply(dto); + if (extractedData != null && !extractedData.isEmpty()) { + pageData.addAll(extractedData); - // 전체 데이터 개수 확인 - long totalCount = dto.getResponse().getTotalCount(); - long currentCount = dto.getResponse().getCurrentCount(); + // 페이징 처리 로직 + long totalCount = totalCountGetter.apply(dto); + long currentCount = currentCountGetter.apply(dto); // 더 이상 데이터가 없는지 확인 - if (currentCount < perPage || page * perPage >= totalCount) { + if (currentCount < perPage || (long)page * perPage >= totalCount) { hasMoreData = false; } } else { @@ -223,10 +186,12 @@ public void syncPublicServiceConditionsData(PublicDataPath apiPath) { } } + // 데이터 처리 및 저장 if (!pageData.isEmpty()) { - upsertSupportConditionsData(pageData); - totalProcessed += pageData.size(); - log.info("공공서비스 지원조건 데이터 페이지 {} 처리 완료: {}건, 총 {}건", page, pageData.size(), totalProcessed); + List processedData = processor.apply(pageData); + totalProcessed += processedData.size(); + log.info("{} 페이지 {} 처리 완료: {}건, 총 {}건", + operationName, page, pageData.size(), totalProcessed); } else { hasMoreData = false; } @@ -234,11 +199,12 @@ public void syncPublicServiceConditionsData(PublicDataPath apiPath) { page++; } } catch (Exception e) { - log.error("공공서비스 지원조건 데이터 동기화 중 오류 발생", e); + log.error("{} 동기화 중 오류 발생", operationName, e); throw e; } - log.info("공공서비스 지원조건 데이터 전체 동기화 완료: 총 {}건", totalProcessed); + log.info("{} 전체 동기화 완료: 총 {}건", operationName, totalProcessed); + return totalProcessed; } /** diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/util/PublicServiceDataValidate.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/util/PublicServiceDataValidate.java index dca7988..a1fda6b 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/util/PublicServiceDataValidate.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/util/PublicServiceDataValidate.java @@ -53,7 +53,7 @@ public T validateAndHandleException(PublicServiceDataOperation operation, public boolean validatePublicServiceData(PublicServiceDataDto.Data data) { if (data.getServiceName() == null || data.getServiceName().isEmpty() || data.getServiceCategory() == null || data.getServiceCategory().isEmpty() || - data.getGoverningAgency() == null || data.getGoverningAgency().isEmpty()) { + data.getSummaryPurpose() == null || data.getSummaryPurpose().isEmpty()) { log.warn("⚠️ 공공 서비스 ID {}에 필수 데이터가 누락되었습니다.", data.getServiceId()); return false; } @@ -64,7 +64,10 @@ public boolean validatePublicServiceDetailData(PublicServiceDetailDataDto.Data d if (data.getServicePurpose() == null || data.getServicePurpose().isEmpty() || data.getSupportTarget() == null || data.getSupportTarget().isEmpty() || data.getSupportDetail() == null || data.getSupportDetail().isEmpty() || - data.getSupportType() == null || data.getSupportType().isEmpty()) { + data.getSupportType() == null || data.getSupportType().isEmpty() || + data.getApplicationMethod() == null || data.getApplicationMethod().isEmpty() || + data.getApplicationDeadline() == null || data.getApplicationDeadline().isEmpty() || + data.getGoverningAgency() == null || data.getGoverningAgency().isEmpty()) { log.warn("⚠️ 공공 서비스 상세내용 ID {}에 필수 데이터가 누락되었습니다.", data.getServiceId()); return false; } @@ -72,9 +75,15 @@ public boolean validatePublicServiceDetailData(PublicServiceDetailDataDto.Data d } public boolean validatePublicServiceConditionsData(PublicServiceConditionsDataDto.Data data) { - // 성별 조건 확인 + // 성별 조건 확인 (필수) boolean hasGenderCondition = "Y".equals(data.getTargetGenderMale()) || "Y".equals(data.getTargetGenderFemale()); + // 성별 정보가 없으면 유효하지 않은 데이터로 간주 + if (!hasGenderCondition) { + log.warn("⚠️ 공공 서비스 지원조건 ID {}에 성별 지원 조건이 없습니다.", data.getServiceId()); + return false; + } + // 특수 그룹 조건 확인 boolean hasSpecialGroupCondition = "Y".equals(data.getJA0401()) || "Y".equals(data.getJA0402()) || @@ -84,9 +93,8 @@ public boolean validatePublicServiceConditionsData(PublicServiceConditionsDataDt // 가족 유형 조건 확인 boolean hasFamilyTypeCondition = - "Y".equals(data.getJA0410()) || "Y".equals(data.getJA0411()) || - "Y".equals(data.getJA0412()) || "Y".equals(data.getJA0413()) || - "Y".equals(data.getJA0414()); + "Y".equals(data.getJA0411()) || "Y".equals(data.getJA0412()) || + "Y".equals(data.getJA0413()) || "Y".equals(data.getJA0414()); // 직업 유형 조건 확인 boolean hasOccupationCondition = @@ -106,10 +114,10 @@ public boolean validatePublicServiceConditionsData(PublicServiceConditionsDataDt "Y".equals(data.getJA2202()) || "Y".equals(data.getJA2203()) || "Y".equals(data.getJA2299()); - // 지원 조건이 전혀 없는 경우는 유효하지 않은 데이터로 간주 - if (!(hasGenderCondition || hasSpecialGroupCondition || hasFamilyTypeCondition || + // 성별 외에 다른 지원 조건이 하나라도 있어야 함 + if (!(hasSpecialGroupCondition || hasFamilyTypeCondition || hasOccupationCondition || hasBusinessTypeCondition)) { - log.warn("⚠️ 공공 서비스 지원조건 ID {}에 유효한 지원 조건이 없습니다.", data.getServiceId()); + log.warn("⚠️ 공공 서비스 지원조건 ID {}에 성별 외 다른 지원 조건이 없습니다.", data.getServiceId()); return false; } diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/util/AuthenticateUser.java b/src/main/java/com/hyetaekon/hyetaekon/common/util/AuthenticateUser.java new file mode 100644 index 0000000..904f2b6 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/util/AuthenticateUser.java @@ -0,0 +1,21 @@ +package com.hyetaekon.hyetaekon.common.util; + +import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +@Component +public class AuthenticateUser { + // 인증된 사용자면 userId, 그렇지않으면 0 반환 + public Long authenticateUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated() || authentication.getPrincipal().equals("anonymousUser")) { + return 0L; // 인증되지 않은 사용자일 경우 기본 값 + } + + CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal(); + return userDetails.getId(); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/util/BaseEntity.java b/src/main/java/com/hyetaekon/hyetaekon/common/util/BaseEntity.java new file mode 100644 index 0000000..10e924c --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/util/BaseEntity.java @@ -0,0 +1,25 @@ +package com.hyetaekon.hyetaekon.common.util; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@EntityListeners(AuditingEntityListener.class) +@Getter +@MappedSuperclass +public class BaseEntity { + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false, columnDefinition = "DATETIME(0)") + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "modified_at", nullable = false, columnDefinition = "DATETIME(0)") + private LocalDateTime modifiedAt; +} \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/util/CookieUtil.java b/src/main/java/com/hyetaekon/hyetaekon/common/util/CookieUtil.java new file mode 100644 index 0000000..e3b2867 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/util/CookieUtil.java @@ -0,0 +1,44 @@ +package com.hyetaekon.hyetaekon.common.util; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.web.util.WebUtils; + +import java.util.Optional; + +public class CookieUtil { + // 쿠키 설정 + public static void setCookie(HttpServletResponse response, String name, String value, Long maxAge) { + ResponseCookie cookie = ResponseCookie.from(name, value) + .httpOnly(true) + .secure(true) + .path("/") + .sameSite("None") + .maxAge(maxAge) + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } + + // 쿠키 삭제 + public static void deleteCookie(HttpServletResponse response, String name) { + ResponseCookie cookie = ResponseCookie.from(name, null) + .httpOnly(true) + .secure(true) + .path("/") + .sameSite("None") + .maxAge(0) // 즉시 만료 + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } + + // 특정 쿠키 값 가져오기 + public static Optional getCookieValue(HttpServletRequest request, String name) { + Cookie cookie = WebUtils.getCookie(request, name); + return cookie != null ? Optional.of(cookie.getValue()) : Optional.empty(); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/controller/AnswerController.java b/src/main/java/com/hyetaekon/hyetaekon/post/controller/AnswerController.java deleted file mode 100644 index e275165..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/post/controller/AnswerController.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.hyetaekon.hyetaekon.post.controller; - -public class AnswerController { -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java b/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java new file mode 100644 index 0000000..bc69467 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java @@ -0,0 +1,54 @@ +package com.hyetaekon.hyetaekon.post.controller; + +import com.hyetaekon.hyetaekon.post.dto.PostDto; +import com.hyetaekon.hyetaekon.post.service.PostService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/posts") +@RequiredArgsConstructor +public class PostController { + + private final PostService postService; + + // ✅ 게시글 생성 + @PostMapping + public ResponseEntity createPost(@RequestBody PostDto postDto) { + return ResponseEntity.ok(postService.createPost(postDto)); + } + + // ✅ 특정 게시글 조회 + @GetMapping("/{postId}") + public ResponseEntity getPost(@PathVariable Long postId) { + return ResponseEntity.ok(postService.getPostById(postId)); + } + + // ✅ 특정 카테고리의 게시글 조회 + @GetMapping("/category/{categoryId}") + public ResponseEntity> getPostsByCategory(@PathVariable Long categoryId) { + return ResponseEntity.ok(postService.getPostsByCategoryId(categoryId)); + } + + // ✅ 모든 게시글 조회 + @GetMapping + public ResponseEntity> getAllPosts() { + return ResponseEntity.ok(postService.getAllPosts()); + } + + // ✅ 게시글 수정 + @PutMapping("/{postId}") + public ResponseEntity updatePost(@PathVariable Long postId, @RequestBody PostDto postDto) { + return ResponseEntity.ok(postService.updatePost(postId, postDto)); + } + + // ✅ 게시글 삭제 (soft delete 방식 사용 가능) + @DeleteMapping("/{postId}") + public ResponseEntity deletePost(@PathVariable Long postId) { + postService.deletePost(postId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/AnswerDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/AnswerDto.java deleted file mode 100644 index 3748fb1..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/AnswerDto.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.hyetaekon.hyetaekon.post.dto; - -public class AnswerDto { -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDto.java new file mode 100644 index 0000000..61cea56 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDto.java @@ -0,0 +1,27 @@ +package com.hyetaekon.hyetaekon.post.dto; + +import lombok.Getter; +import lombok.Setter; +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Setter +public class PostDto { + private Long id; + private Long userId; + private Long publicServiceId; + private String title; + private String content; + private LocalDateTime createdAt; + private LocalDateTime deletedAt; // 삭제일 추가 + private String postType; + private String serviceUrl; + private int recommendCnt; + private int viewCount; + private String urlTitle; + private String urlPath; + private String tags; + private Long categoryId; // 추가됨 + private List imageUrls; // ✅ 이미지 URL 리스트 추가 +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/entity/Answer.java b/src/main/java/com/hyetaekon/hyetaekon/post/entity/Answer.java deleted file mode 100644 index f016bf1..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/post/entity/Answer.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.hyetaekon.hyetaekon.post.entity; - -public class Answer { -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java b/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java new file mode 100644 index 0000000..b2c2df1 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java @@ -0,0 +1,62 @@ +package com.hyetaekon.hyetaekon.post.entity; + +import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; +import com.hyetaekon.hyetaekon.user.entity.User; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import java.time.LocalDateTime; +import java.util.List; + +@Entity +@Getter +@Setter +@Table(name = "posts") +public class Post { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; // 게시글 ID + + @ManyToOne + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne + @JoinColumn(name = "public_service_id") + private PublicService publicService; + + @Column(length = 20, nullable = false) // ✅ 제목 20자 제한 + private String title; + + @Column(length = 500, nullable = false) // ✅ 내용 500자 제한 + private String content; + + private LocalDateTime createdAt = LocalDateTime.now(); + + private LocalDateTime deletedAt; + + private int recommendCnt; // 추천수 + + private int viewCount; // 조회수 + + @Column(name = "post_type") + @Enumerated(EnumType.STRING) // ✅ ENUM 타입으로 저장 (질문, 자유, 인사) + private PostType postType; + + private String serviceUrl; + + @Column(length = 12) // ✅ 관련 링크 제목 12자 제한 + private String urlTitle; + + private String urlPath; + + @Column(length = 255) + private String tags; // ✅ 태그는 최대 3개 (쉼표 구분) + + @Column(name = "category_id") + private Long categoryId; + + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + private List postImages; // ✅ 게시글 이미지와 연결 +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostImage.java b/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostImage.java new file mode 100644 index 0000000..177ac34 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostImage.java @@ -0,0 +1,22 @@ +package com.hyetaekon.hyetaekon.post.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Getter +@Setter +public class PostImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "post_id") + private Post post; + + @Column(name = "image_url", length = 255) + private String imageUrl; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostType.java b/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostType.java new file mode 100644 index 0000000..db9cb66 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostType.java @@ -0,0 +1,7 @@ +package com.hyetaekon.hyetaekon.post.entity; + +public enum PostType { + QUESTION, // 질문 게시판 + FREE, // 자유 게시판 + GREETING // 인사 게시판 +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/mapper/AnswerMapper.java b/src/main/java/com/hyetaekon/hyetaekon/post/mapper/AnswerMapper.java deleted file mode 100644 index 1382ade..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/post/mapper/AnswerMapper.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.hyetaekon.hyetaekon.post.mapper; - -public interface AnswerMapper { -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java b/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java new file mode 100644 index 0000000..908c493 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java @@ -0,0 +1,62 @@ +package com.hyetaekon.hyetaekon.post.mapper; + +import com.hyetaekon.hyetaekon.post.dto.PostDto; +import com.hyetaekon.hyetaekon.post.entity.Post; +import com.hyetaekon.hyetaekon.post.entity.PostImage; +import com.hyetaekon.hyetaekon.post.entity.PostType; +import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; +import com.hyetaekon.hyetaekon.user.entity.User; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import java.util.List; +import java.util.stream.Collectors; + +@Mapper(componentModel = "spring") +public interface PostMapper { + + @Mapping(target = "imageUrls", expression = "java(mapPostImages(post))") + @Mapping(target = "postType", expression = "java(post.getPostType() != null ? post.getPostType().name() : null)") + PostDto toDto(Post post); + + default Post toEntity(PostDto dto) { + Post post = new Post(); + // 👇 User 객체 세팅 + User user = new User(); + user.setId(dto.getUserId()); + post.setUser(user); + + // 👇 PublicService 객체 세팅 + PublicService publicService = new PublicService(); + publicService.setId(dto.getPublicServiceId()); + post.setPublicService(publicService); + post.setTitle(dto.getTitle()); + post.setContent(dto.getContent()); + post.setPostType(dto.getPostType() != null ? PostType.valueOf(dto.getPostType()) : null); + post.setServiceUrl(dto.getServiceUrl()); + post.setUrlTitle(dto.getUrlTitle()); + post.setUrlPath(dto.getUrlPath()); + post.setTags(dto.getTags()); + post.setCategoryId(dto.getCategoryId()); + + if (dto.getImageUrls() != null) { + List images = dto.getImageUrls().stream() + .map(url -> { + PostImage img = new PostImage(); + img.setImageUrl(url); + img.setPost(post); + return img; + }).collect(Collectors.toList()); + post.setPostImages(images); + } + + return post; + } + + default List mapPostImages(Post post) { + if (post.getPostImages() == null) return null; + return post.getPostImages().stream() + .map(PostImage::getImageUrl) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/repository/AnswerRepository.java b/src/main/java/com/hyetaekon/hyetaekon/post/repository/AnswerRepository.java deleted file mode 100644 index ce1102a..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/post/repository/AnswerRepository.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.hyetaekon.hyetaekon.post.repository; - -public class AnswerRepository { -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java b/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java new file mode 100644 index 0000000..2734c4e --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java @@ -0,0 +1,14 @@ +package com.hyetaekon.hyetaekon.post.repository; + +import com.hyetaekon.hyetaekon.post.entity.Post; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface PostRepository extends JpaRepository { + List findByCategoryId(Long categoryId); // 추가됨 + + boolean existsByUser_IdAndDeletedAtIsNull(Long userId); +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/service/AnswerService.java b/src/main/java/com/hyetaekon/hyetaekon/post/service/AnswerService.java deleted file mode 100644 index 5df41f5..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/post/service/AnswerService.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.hyetaekon.hyetaekon.post.service; - -public class AnswerService { -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java b/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java new file mode 100644 index 0000000..ae576b8 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java @@ -0,0 +1,76 @@ +package com.hyetaekon.hyetaekon.post.service; + +import com.hyetaekon.hyetaekon.post.dto.PostDto; +import com.hyetaekon.hyetaekon.post.entity.Post; +import com.hyetaekon.hyetaekon.post.entity.PostType; +import com.hyetaekon.hyetaekon.post.mapper.PostMapper; +import com.hyetaekon.hyetaekon.post.repository.PostRepository; +import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; +import com.hyetaekon.hyetaekon.user.entity.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class PostService { + + private final PostRepository postRepository; + private final PostMapper postMapper; + + public List getAllPosts() { + return postRepository.findAll() + .stream() + .map(postMapper::toDto) + .collect(Collectors.toList()); + } + + public PostDto getPostById(Long id) { + Post post = postRepository.findById(id).orElseThrow(() -> new RuntimeException("게시글을 찾을 수 없습니다.")); + return postMapper.toDto(post); + } + + public List getPostsByCategoryId(Long categoryId) { // 추가됨 + return postRepository.findByCategoryId(categoryId) + .stream() + .map(postMapper::toDto) + .collect(Collectors.toList()); + } + + public PostDto createPost(PostDto postDto) { + Post post = postMapper.toEntity(postDto); + Post savedPost = postRepository.save(post); + return postMapper.toDto(savedPost); + } + + public PostDto updatePost(Long id, PostDto postDto) { + Post post = postRepository.findById(id).orElseThrow(() -> new RuntimeException("게시글을 찾을 수 없습니다.")); + User user = new User(); + user.setId(postDto.getUserId()); + post.setUser(user); + + PublicService publicService = new PublicService(); + publicService.setId(postDto.getPublicServiceId()); + post.setPublicService(publicService); + + post.setTitle(postDto.getTitle()); + post.setContent(postDto.getContent()); + post.setPostType(PostType.valueOf(postDto.getPostType())); + post.setDeletedAt(postDto.getDeletedAt()); + post.setServiceUrl(postDto.getServiceUrl()); + post.setRecommendCnt(postDto.getRecommendCnt()); + post.setViewCount(postDto.getViewCount()); + post.setUrlTitle(postDto.getUrlTitle()); + post.setUrlPath(postDto.getUrlPath()); + post.setTags(postDto.getTags()); + post.setCategoryId(postDto.getCategoryId()); // 추가됨 + Post updatedPost = postRepository.save(post); + return postMapper.toDto(updatedPost); + } + + public void deletePost(Long id) { + postRepository.deleteById(id); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/PublicServiceController.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/PublicServiceController.java index ec89b62..ec24017 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/PublicServiceController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/PublicServiceController.java @@ -4,6 +4,7 @@ import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; import com.hyetaekon.hyetaekon.publicservice.entity.ServiceCategory; import com.hyetaekon.hyetaekon.publicservice.service.PublicServiceHandler; +import com.hyetaekon.hyetaekon.common.util.AuthenticateUser; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Positive; @@ -23,32 +24,50 @@ @RequiredArgsConstructor public class PublicServiceController { private final PublicServiceHandler publicServiceHandler; - // private final AuthenticateUser authenticateUser; + private final AuthenticateUser authenticateUser; + // 전체 공공서비스 목록 조회(페이징, 정렬, 필터링) + @GetMapping + public ResponseEntity> getAllServices( + @RequestParam(required = false) String sort, + @RequestParam(required = false) List specialGroups, + @RequestParam(required = false) List familyTypes, + @RequestParam(required = false) List categories, + @RequestParam(name = "page", defaultValue = "0") @Min(0) int page, + @RequestParam(name = "size", defaultValue = "9") @Positive @Max(30) int size) { + + Long userId = authenticateUser.authenticateUserId(); + + return ResponseEntity.ok(publicServiceHandler.getAllServices( + sort, specialGroups, familyTypes, categories, PageRequest.of(page, size), userId)); + } // 서비스 분야별 공공서비스 목록 조회 - @GetMapping("/{category}") + @GetMapping("/category/{category}") public ResponseEntity> getServicesByCategory ( @PathVariable("category") String categoryName, @RequestParam(name = "page", defaultValue = "0") @Min(0) int page, @RequestParam(name = "size", defaultValue = "9") @Positive @Max(30) int size) { - + Long userId = authenticateUser.authenticateUserId(); ServiceCategory category = publicServiceHandler.getServiceCategory(categoryName); - return ResponseEntity.ok(publicServiceHandler.getServicesByCategory(category, PageRequest.of(page, size))); + return ResponseEntity.ok(publicServiceHandler.getServicesByCategory(category, PageRequest.of(page, size), userId)); } // 공공서비스 상세 조회 - @GetMapping("/{serviceId}") + @GetMapping("/detail/{serviceId}") public ResponseEntity getServiceDetail (@PathVariable("serviceId") Long serviceId) { - return ResponseEntity.ok(publicServiceHandler.getServiceDetail(serviceId)); + Long userId = authenticateUser.authenticateUserId(); + + return ResponseEntity.ok(publicServiceHandler.getServiceDetail(serviceId, userId)); } - // 인기 서비스 목록 조회(조회수) -> 6개 리스트 고정 - // TODO: 유저 인증 + // 인기 서비스 목록 조회(북마크 수) -> 6개 고정 @GetMapping("/popular") - public ResponseEntity> getPopolarServices() { - return ResponseEntity.ok(publicServiceHandler.getPopularServices()); // 최대 6개로 제한 + public ResponseEntity> getPopularServices() { + Long userId = authenticateUser.authenticateUserId(); + + return ResponseEntity.ok(publicServiceHandler.getPopularServices(userId)); // 최대 6개로 제한 } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/PublicServiceDetailResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/PublicServiceDetailResponseDto.java index 4e981fb..c48c6eb 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/PublicServiceDetailResponseDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/PublicServiceDetailResponseDto.java @@ -13,36 +13,32 @@ public class PublicServiceDetailResponseDto { private Long publicServiceId; private String serviceName; - private String summaryPurpose; - private String governingAgency; - private String department; - private String userType; - private String contactInfo; - private String applicationDeadline; - private String supportType; - private String serviceCategory; - private List specialGroup; - private List familyType; + private String servicePurpose; // 서비스 목적 + private int views; private int bookmarkCnt; + private boolean bookmarked; // 북마크 여부 - // 지원 대상 + // 담당부서 + // TODO: 지역정보 + private String governingAgency; // 소관기관명 + private String contactInfo; // 문의처 + + // 지원대상 private String supportTarget; // 지원 대상 private String selectionCriteria; // 선정 기준 - // 서비스 내용 - private String servicePurpose; // 서비스 목적 + // 지원내용 private String supportDetail; // 지원 내용 - //private String supportType; // 지원 유형(공통) + private String supportType; // 지원 유형 - // 신청 방법 + // 신청방법 private String applicationMethod; // 신청 방법 - //private String applicationDeadline; // 신청 기한(공통) - private String requiredDocuments; // 구비 서류 + private String applicationDeadline; // 신청 기한 + // private String requiredDocuments; // 구비 서류 // 추가 정보 - //private String contactInfo; // 문의처(공통) private String onlineApplicationUrl; // 온라인 경로 url - private String relatedLaws; // 관련 법률 + // private String relatedLaws; // 관련 법률 } diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/PublicServiceListResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/PublicServiceListResponseDto.java index db12855..1a217de 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/PublicServiceListResponseDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/PublicServiceListResponseDto.java @@ -15,23 +15,10 @@ public class PublicServiceListResponseDto { private String serviceName; private String summaryPurpose; - // 담당부처 - private String governingAgency; // 소관기관명 - private String department; // 부서명 - - - private String userType; // 사용자 구분 - private String contactInfo; // 문의처 - private String applicationDeadline; // 신청 기한 - private String supportType; // 지원유형 - - // 해시태그 private String serviceCategory; // 서비스 분야 private List specialGroup; // 특수 대상 그룹 private List familyType; // 가구 형태 - private int views; // 조회수 - private int bookmarkCnt; // 북마크수 - + private boolean bookmarked; // 북마크 여부 } diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/BusinessType.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/BusinessType.java index 8a874f0..831b383 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/BusinessType.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/BusinessType.java @@ -19,7 +19,7 @@ public class BusinessType { @JoinColumn(name = "public_service_id", nullable = false) private PublicService publicService; - // TODO: 사업체 형태 - 회원 정보 + // 사업체 형태 - 회원 정보 @Convert(converter = BusinessTypeConverter.class) private BusinessTypeEnum businessTypeEnum; } \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/FamilyType.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/FamilyType.java index 0d1d7f7..cd198e9 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/FamilyType.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/FamilyType.java @@ -19,7 +19,7 @@ public class FamilyType { @JoinColumn(name = "public_service_id", nullable = false) private PublicService publicService; - // TODO: 가구 형태 - 검색 + 해시태그 + // 가구 형태 - 검색 + 해시태그 @Convert(converter = FamilyTypeConverter.class) private FamilyTypeEnum familyTypeEnum; } \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/FamilyTypeEnum.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/FamilyTypeEnum.java index edcc653..7d5878b 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/FamilyTypeEnum.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/FamilyTypeEnum.java @@ -7,7 +7,7 @@ @Getter @AllArgsConstructor public enum FamilyTypeEnum implements CodeEnum { - IS_NOT_APPLICABLE("해당사항 없음", "JA0410"), + // IS_NOT_APPLICABLE("해당사항 없음", "JA0410"), IS_MULTI_CHILDREN_FAMILY("다자녀가구", "JA0411"), IS_NON_HOUSING_HOUSEHOLD("무주택세대", "JA0412"), IS_NEW_RESIDENCE("신규전입", "JA0413"), diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/Occupation.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/Occupation.java index 75d5ada..db43a76 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/Occupation.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/Occupation.java @@ -19,7 +19,7 @@ public class Occupation { @JoinColumn(name = "public_service_id", nullable = false) private PublicService publicService; - // TODO: 대상 직종 - 회원 정보 + // 대상 직종 - 회원 정보 @Convert(converter = OccupationConverter.class) private OccupationEnum occupationEnum; } \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/PublicService.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/PublicService.java index 0d13f94..5e8057f 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/PublicService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/PublicService.java @@ -1,5 +1,6 @@ package com.hyetaekon.hyetaekon.publicservice.entity; +import com.hyetaekon.hyetaekon.bookmark.entity.Bookmark; import jakarta.persistence.*; import lombok.*; @@ -13,7 +14,6 @@ @Builder @NoArgsConstructor @AllArgsConstructor -@Table(name = "public_service") public class PublicService { @Id private Long id; @@ -21,7 +21,7 @@ public class PublicService { @Column(name = "service_name", nullable = false, length = 255) private String serviceName; // 서비스명 - // TODO: 서비스 분야 - 카테고리 + 해시태그 + // 서비스 분야 - 카테고리 + 해시태그 @Enumerated(EnumType.STRING) @Column(nullable = false) private ServiceCategory serviceCategory; // 서비스 분야 @@ -134,14 +134,18 @@ public class PublicService { @Builder.Default private List businessTypes = new ArrayList<>(); + @OneToMany(mappedBy = "publicService", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List bookmarks = new ArrayList<>(); - public void updateBookmarkCntUp() { + public void increaseBookmarkCount() { bookmarkCnt++; } - public void updateBookmarkCntDown() { + public void decreaseBookmarkCount() { bookmarkCnt--; } public void updateViewsUp() { views++; } + } diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/SpecialGroup.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/SpecialGroup.java index 0a92254..c720ae8 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/SpecialGroup.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/SpecialGroup.java @@ -9,7 +9,7 @@ @Builder @NoArgsConstructor @AllArgsConstructor -@Table(name = "spcial_group") +@Table(name = "special_group") public class SpecialGroup { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -19,7 +19,7 @@ public class SpecialGroup { @JoinColumn(name = "public_service_id", nullable = false) private PublicService publicService; - // TODO: 특수 대상 그룹 - 검색 + 해시태그 + // 특수 대상 그룹 - 검색 + 해시태그 @Convert(converter = SpecialGroupConverter.class) // 명시적 선언 private SpecialGroupEnum specialGroupEnum; } diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/PublicServiceRepository.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/PublicServiceRepository.java index 52520e2..54c4701 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/PublicServiceRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/PublicServiceRepository.java @@ -1,11 +1,15 @@ package com.hyetaekon.hyetaekon.publicservice.repository; +import com.hyetaekon.hyetaekon.publicservice.entity.FamilyTypeEnum; import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; import com.hyetaekon.hyetaekon.publicservice.entity.ServiceCategory; +import com.hyetaekon.hyetaekon.publicservice.entity.SpecialGroupEnum; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -15,9 +19,27 @@ public interface PublicServiceRepository extends JpaRepository { Page findByServiceCategory(ServiceCategory category, Pageable pageable); - List findTop6ByOrderByViewsDesc(); + List findTop6ByOrderByBookmarkCntDesc(); Optional findById(long serviceId); int deleteByIdNotIn(List Ids); + + @Query("SELECT DISTINCT ps FROM PublicService ps " + + "LEFT JOIN ps.specialGroups sg " + + "LEFT JOIN ps.familyTypes ft " + + "WHERE (:#{#categories == null || #categories.isEmpty()} = true OR ps.serviceCategory IN :categories) " + + "AND (:#{#specialGroupEnums == null || #specialGroupEnums.isEmpty()} = true OR sg.specialGroupEnum IN :specialGroupEnums) " + + "AND (:#{#familyTypeEnums == null || #familyTypeEnums.isEmpty()} = true OR ft.familyTypeEnum IN :familyTypeEnums)") + Page findWithFilters( + @Param("categories") List categories, + @Param("specialGroupEnums") List specialGroupEnums, + @Param("familyTypeEnums") List familyTypeEnums, + Pageable pageable + ); + + // 사용자의 북마크 공공서비스 목록 페이지 + @Query("SELECT p FROM PublicService p JOIN p.bookmarks b WHERE b.user.id = :userId ORDER BY b.createdAt DESC") + Page findByBookmarks_User_Id(@Param("userId") Long userId, Pageable pageable); + } diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java index a3400d4..0a59a48 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java @@ -1,20 +1,28 @@ package com.hyetaekon.hyetaekon.publicservice.service; +import com.hyetaekon.hyetaekon.bookmark.repository.BookmarkRepository; +import com.hyetaekon.hyetaekon.common.exception.ErrorCode; +import com.hyetaekon.hyetaekon.common.exception.GlobalException; import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceDetailResponseDto; import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; +import com.hyetaekon.hyetaekon.publicservice.entity.FamilyTypeEnum; import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; import com.hyetaekon.hyetaekon.publicservice.entity.ServiceCategory; +import com.hyetaekon.hyetaekon.publicservice.entity.SpecialGroupEnum; import com.hyetaekon.hyetaekon.publicservice.mapper.PublicServiceMapper; import com.hyetaekon.hyetaekon.publicservice.repository.PublicServiceRepository; import com.hyetaekon.hyetaekon.publicservice.util.PublicServiceValidate; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +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.NoSuchElementException; import java.util.stream.Collectors; @Service @@ -24,34 +32,170 @@ public class PublicServiceHandler { private final PublicServiceRepository publicServiceRepository; private final PublicServiceMapper publicServiceMapper; private final PublicServiceValidate publicServiceValidate; + private final BookmarkRepository bookmarkRepository; - - public Page getServicesByCategory(ServiceCategory category, Pageable pageable) { + // 서비스분야별 서비스목록 조회(페이지) + public Page getServicesByCategory(ServiceCategory category, Pageable pageable, Long userId) { Page services = publicServiceRepository.findByServiceCategory(category, pageable); - return services.map(publicServiceMapper::toListDto); + + return services.map(service -> { + PublicServiceListResponseDto dto = publicServiceMapper.toListDto(service); + if (userId != 0L) { + // 로그인한 사용자면 북마크 여부 확인 + dto.setBookmarked(bookmarkRepository.existsByUserIdAndPublicServiceId(userId, service.getId())); + } + return dto; + }); } - public PublicServiceDetailResponseDto getServiceDetail(Long serviceId) { + // 서비스 상세 조회 + @Transactional + public PublicServiceDetailResponseDto getServiceDetail(Long serviceId, Long userId) { PublicService service = publicServiceValidate.validateServiceById(serviceId); // 조회수 증가 service.updateViewsUp(); publicServiceRepository.save(service); - return publicServiceMapper.toDetailDto(service); + PublicServiceDetailResponseDto dto = publicServiceMapper.toDetailDto(service); + + if (userId != 0L) { + // 로그인한 사용자면 북마크 여부 확인 + dto.setBookmarked(bookmarkRepository.existsByUserIdAndPublicServiceId(userId, service.getId())); + } + + return dto; } - public List getPopularServices() { - // publicServiceValidate.validateUserById(userId); + // 인기 서비스 목록 조회(6개 고정) + public List getPopularServices(Long userId) { - List services = publicServiceRepository.findTop6ByOrderByViewsDesc(); + // 북마크 수 기준으로 상위 6개 서비스 조회 + List services = publicServiceRepository.findTop6ByOrderByBookmarkCntDesc(); return services.stream() - .map(publicServiceMapper::toListDto) + .map(service -> { + PublicServiceListResponseDto dto = publicServiceMapper.toListDto(service); + // 로그인한 사용자는 북마크 여부 확인 + if (userId == 0L) { + dto.setBookmarked(bookmarkRepository.existsByUserIdAndPublicServiceId(userId, service.getId())); + } + return dto; + }) .collect(Collectors.toList()); } public ServiceCategory getServiceCategory(String categoryName) { return publicServiceValidate.validateServiceCategory(categoryName); } + + // 공공서비스 전체 목록 조회 (정렬 및 필터링 적용) + public Page getAllServices( + String sort, + List specialGroups, + List familyTypes, + List categories, + Pageable pageable, + Long userId) { + + // 정렬 기준 설정 (기본값: 가나다순) + Sort.Direction direction = Sort.Direction.ASC; + String sortField = "serviceName"; + + if (sort != null) { + switch (sort.toLowerCase()) { + case "bookmark": + sortField = "bookmarkCnt"; + direction = Sort.Direction.DESC; + break; + case "view": + sortField = "views"; + direction = Sort.Direction.DESC; + break; + default: + // 기본 가나다순 유지 + break; + } + } + + // 페이지 요청 객체 재생성 (정렬 기준 적용) + PageRequest pageRequest = PageRequest.of( + pageable.getPageNumber(), + pageable.getPageSize(), + Sort.by(direction, sortField) + ); + + // 필터링 조건에 따른 서비스 조회 + Page services; + + if ((specialGroups != null && !specialGroups.isEmpty()) || + (familyTypes != null && !familyTypes.isEmpty()) || + (categories != null && !categories.isEmpty())) { + + List categoryEnums = new ArrayList<>(); + if (categories != null) { + for (String category : categories) { + categoryEnums.add(publicServiceValidate.validateServiceCategory(category)); + } + } + + List specialGroupEnums = new ArrayList<>(); + if (specialGroups != null) { + for (String group : specialGroups) { + try { + SpecialGroupEnum enumValue = SpecialGroupEnum.valueOf(group); + specialGroupEnums.add(enumValue); + } catch (IllegalArgumentException e) { + throw new GlobalException(ErrorCode.INVALID_ENUM_CODE); + } + } + } + + List familyTypeEnums = new ArrayList<>(); + if (familyTypes != null) { + for (String type : familyTypes) { + try { + FamilyTypeEnum enumValue = FamilyTypeEnum.valueOf(type); + familyTypeEnums.add(enumValue); + } catch (IllegalArgumentException e) { + throw new GlobalException(ErrorCode.INVALID_ENUM_CODE); + } + } + } + + services = publicServiceRepository.findWithFilters( + categoryEnums, + specialGroupEnums, + familyTypeEnums, + pageRequest + ); + } else { + services = publicServiceRepository.findAll(pageRequest); + } + + // DTO 변환 및 북마크 여부 설정 + return services.map(service -> { + PublicServiceListResponseDto dto = publicServiceMapper.toListDto(service); + if (userId != 0L) { + // 로그인한 사용자면 북마크 여부 확인 + dto.setBookmarked(bookmarkRepository.existsByUserIdAndPublicServiceId(userId, service.getId())); + } + return dto; + }); + } + + public Page getBookmarkedServices(Long userId, Pageable pageable) { + Page bookmarkedServices = publicServiceRepository.findByBookmarks_User_Id(userId, pageable); + + List serviceDtos = bookmarkedServices.getContent().stream() + .map(service -> { + PublicServiceListResponseDto dto = publicServiceMapper.toListDto(service); + dto.setBookmarked(true); + return dto; + }) + .collect(Collectors.toList()); + + return new PageImpl<>(serviceDtos, pageable, bookmarkedServices.getTotalElements()); + + } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/util/PublicServiceValidate.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/util/PublicServiceValidate.java index 5953758..c7bb87f 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/util/PublicServiceValidate.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/util/PublicServiceValidate.java @@ -10,22 +10,22 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import java.util.NoSuchElementException; @Component @RequiredArgsConstructor public class PublicServiceValidate { public final PublicServiceRepository publicServiceRepository; + public final UserRepository userRepository; public PublicService validateServiceById(Long serviceId) { return publicServiceRepository.findById(serviceId) .orElseThrow(() -> new GlobalException(ErrorCode.SERVICE_NOT_FOUND_BY_ID)); } - /*public User validateUserById(Long userId) { + public User validateUserById(Long userId) { return userRepository.findById(userId) .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); - }*/ + } public ServiceCategory validateServiceCategory(String categoryName) { try { diff --git a/src/main/java/com/hyetaekon/hyetaekon/recommend/controller/RecommendController.java b/src/main/java/com/hyetaekon/hyetaekon/recommend/controller/RecommendController.java index 85caad2..fb913c0 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/recommend/controller/RecommendController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/recommend/controller/RecommendController.java @@ -1,4 +1,15 @@ package com.hyetaekon.hyetaekon.recommend.controller; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequestMapping("/api/posts/{postId}/recommend") +@RequiredArgsConstructor public class RecommendController { + + } diff --git a/src/main/java/com/hyetaekon/hyetaekon/recommend/entity/Recommend.java b/src/main/java/com/hyetaekon/hyetaekon/recommend/entity/Recommend.java index 6aa7276..6ec90f0 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/recommend/entity/Recommend.java +++ b/src/main/java/com/hyetaekon/hyetaekon/recommend/entity/Recommend.java @@ -1,4 +1,31 @@ package com.hyetaekon.hyetaekon.recommend.entity; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.hyetaekon.hyetaekon.post.entity.Post; +import com.hyetaekon.hyetaekon.user.entity.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor public class Recommend { -} + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JsonIgnore + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @ManyToOne + @JoinColumn(name = "user_id", nullable = false) + private User user; +} \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/recommend/repository/RecommendRepository.java b/src/main/java/com/hyetaekon/hyetaekon/recommend/repository/RecommendRepository.java index 2e7512a..fe5dd34 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/recommend/repository/RecommendRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/recommend/repository/RecommendRepository.java @@ -1,4 +1,16 @@ package com.hyetaekon.hyetaekon.recommend.repository; -public class RecommendRepository { +import com.hyetaekon.hyetaekon.recommend.entity.Recommend; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface RecommendRepository extends JpaRepository { + boolean existsByUserIdAndPostId(Long userId, Long postId); + + Optional findByUserIdAndPostId(Long userId, Long postId); + + int countByPostId(Long postId); } diff --git a/src/main/java/com/hyetaekon/hyetaekon/recommend/service/RecommendService.java b/src/main/java/com/hyetaekon/hyetaekon/recommend/service/RecommendService.java index 10aa4ad..bbd312d 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/recommend/service/RecommendService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/recommend/service/RecommendService.java @@ -1,4 +1,18 @@ package com.hyetaekon.hyetaekon.recommend.service; +import com.hyetaekon.hyetaekon.post.repository.PostRepository; +import com.hyetaekon.hyetaekon.recommend.repository.RecommendRepository; +import com.hyetaekon.hyetaekon.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor public class RecommendService { + private final RecommendRepository recommendRepository; + private final UserRepository userRepository; + private final PostRepository postRepository; + } diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/controller/AuthController.java b/src/main/java/com/hyetaekon/hyetaekon/user/controller/AuthController.java new file mode 100644 index 0000000..fc3d080 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/controller/AuthController.java @@ -0,0 +1,74 @@ +package com.hyetaekon.hyetaekon.user.controller; + +import com.hyetaekon.hyetaekon.common.exception.ErrorCode; +import com.hyetaekon.hyetaekon.common.exception.GlobalException; +import com.hyetaekon.hyetaekon.common.jwt.JwtToken; +import com.hyetaekon.hyetaekon.common.util.CookieUtil; +import com.hyetaekon.hyetaekon.user.dto.UserSignInRequestDto; +import com.hyetaekon.hyetaekon.user.dto.UserTokenResponseDto; +import com.hyetaekon.hyetaekon.user.service.AuthService; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + + +@Slf4j +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class AuthController { + @Value("${jwt.refresh-expired}") + private Long refreshTokenExpired; + + private final AuthService authService; + + // 로그인 처리 + @PostMapping("/login") + public ResponseEntity login(@RequestBody UserSignInRequestDto userSignInRequestDto, + HttpServletResponse response) { + + JwtToken jwtToken = authService.login(userSignInRequestDto); + CookieUtil.setCookie(response, "refreshToken", jwtToken.getRefreshToken(),refreshTokenExpired); + + return ResponseEntity.ok(new UserTokenResponseDto(jwtToken.getAccessToken())); + } + + // 로그아웃 API + @PostMapping("/logout") + public ResponseEntity logout( + @RequestHeader("Authorization") String authHeader, + @CookieValue(name = "refreshToken", required = false) String refreshToken, + HttpServletResponse response + ) { + // 헤더에서 Access Token 추출 + String accessToken = authHeader.replace("Bearer ", ""); + + // 로그아웃 로직 - AccessToken: Blacklist 등록, RefreshToken: redis에서 삭제 및 쿠키 제거 + authService.logout(accessToken, refreshToken, response); + + return ResponseEntity.noContent().build(); // 204 No Content 응답 + } + + // 토큰 재발급 + @GetMapping("/token/refresh") + public ResponseEntity refreshAccessToken( + @CookieValue(name = "refreshToken", required = false) String refreshToken, + HttpServletResponse response) { + + if (refreshToken == null) { + throw new GlobalException(ErrorCode.NO_TOKEN); + } + + JwtToken jwtToken = authService.refresh(refreshToken); + CookieUtil.setCookie(response, "refreshToken", jwtToken.getRefreshToken(), refreshTokenExpired); + + // JWT 토큰 정보 반환 + return ResponseEntity.ok(new UserTokenResponseDto(jwtToken.getAccessToken())); + } + + + +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserAdminController.java b/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserAdminController.java new file mode 100644 index 0000000..6af4604 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserAdminController.java @@ -0,0 +1,80 @@ +package com.hyetaekon.hyetaekon.user.controller; + +import com.hyetaekon.hyetaekon.user.dto.admin.UserAdminResponseDto; +import com.hyetaekon.hyetaekon.user.dto.admin.UserReportResponseDto; +import com.hyetaekon.hyetaekon.user.dto.admin.UserSuspendRequestDto; +import com.hyetaekon.hyetaekon.user.service.UserAdminService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/admin") +@RequiredArgsConstructor +public class UserAdminController { + private final UserAdminService userAdminService; + + /** + * 회원 목록 조회 + */ + @GetMapping("/users") + public ResponseEntity> getAllUsers( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + return ResponseEntity.ok(userAdminService.getAllUsers(page, size)); + } + + /** + * 회원 정지 + */ + @PostMapping("/users/{userId}/suspend") + public ResponseEntity suspendUser( + @PathVariable Long userId, + @RequestBody UserSuspendRequestDto requestDto) { + userAdminService.suspendUser(userId, requestDto); + return ResponseEntity.ok().build(); + } + + /** + * 정지 해제 + */ + @PutMapping("/users/{userId}/unsuspend") + public ResponseEntity unsuspendUser(@PathVariable Long userId) { + userAdminService.unsuspendUser(userId); + return ResponseEntity.ok().build(); + } + + /** + * 정지 회원 목록 조회 + */ + @GetMapping("/users/suspended") + public ResponseEntity> getSuspendedUsers( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + return ResponseEntity.ok(userAdminService.getSuspendedUsers(page, size)); + } + + /** + * 탈퇴 회원 목록 조회 + */ + @GetMapping("/users/withdrawn") + public ResponseEntity> getWithdrawnUsers( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + return ResponseEntity.ok(userAdminService.getWithdrawnUsers(page, size)); + } + + /** + * 신고 내역 조회 + */ + @GetMapping("/users/reports") + public ResponseEntity> getUserReports( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + return ResponseEntity.ok(userAdminService.getUserReports(page, size)); + } + +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserAuthController.java b/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserAuthController.java deleted file mode 100644 index 2031396..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserAuthController.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.hyetaekon.hyetaekon.user.controller; - -public class UserAuthController { -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserController.java b/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserController.java index f41b3bf..6546a5d 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserController.java @@ -1,4 +1,128 @@ package com.hyetaekon.hyetaekon.user.controller; +import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails; +import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; +import com.hyetaekon.hyetaekon.publicservice.service.PublicServiceHandler; +import com.hyetaekon.hyetaekon.user.dto.*; +import com.hyetaekon.hyetaekon.user.service.UserService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@Validated +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor public class UserController { + private final UserService userService; + private final PublicServiceHandler publicServiceHandler; + + // 회원 가입 api + @PostMapping("/signup") + public ResponseEntity registerUser(@RequestBody @Valid UserSignUpRequestDto userSignUpRequestDto) { + UserSignUpResponseDto userSignUpResponseDto = userService.registerUser(userSignUpRequestDto); + log.debug("회원가입 성공: realId={}", userSignUpRequestDto.getRealId()); + return ResponseEntity.status(HttpStatus.CREATED).body(userSignUpResponseDto); + } + + // 회원 정보 조회 api + @GetMapping("/users/me") + public ResponseEntity getMyInfo(@AuthenticationPrincipal CustomUserDetails userDetails) { + Long userId = userDetails.getId(); + UserResponseDto userInfo = userService.getMyInfo(userId); + return ResponseEntity.ok(userInfo); + } + + // 회원 정보 수정 api + @PutMapping("/users/me/profile") + public ResponseEntity updateMyProfile( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody @Valid UserProfileUpdateDto profileUpdateDto + ) { + Long userId = userDetails.getId(); + return ResponseEntity.ok(userService.updateUserProfile(userId, profileUpdateDto)); + } + + // 비밀번호 변경 API + @PutMapping("/users/me/password") + public ResponseEntity updateMyPassword( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody @Valid UserPasswordUpdateDto passwordUpdateDto + ) { + Long userId = userDetails.getId(); + userService.updateUserPassword(userId, passwordUpdateDto); + return ResponseEntity.ok().build(); + } + + // 회원 탈퇴 + @DeleteMapping("/users/me") + public ResponseEntity deleteUser( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @RequestBody String deleteReason, + @CookieValue(name = "refreshToken", required = false) String refreshToken, + @RequestHeader("Authorization") String authHeader + ) { + String accessToken = authHeader.replace("Bearer ", ""); + userService.deleteUser(customUserDetails.getId(), deleteReason, accessToken, refreshToken); + + return ResponseEntity.noContent().build(); + } + + // 중복 확인 api + @GetMapping("/users/check-duplicate") + public boolean checkDuplicate( + @RequestParam(value = "type") String type, + @RequestParam(value = "value") String value) { + + return userService.checkDuplicate(type, value); + } + + // 북마크한 서비스 목록 조회 + @GetMapping("/users/me/bookmarked") + public ResponseEntity> getBookmarkedServices( + @RequestParam(name = "page", defaultValue = "0") @Min(0) int page, + @RequestParam(name = "size", defaultValue = "9") @Positive @Max(30) int size, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.ok(publicServiceHandler.getBookmarkedServices( + userDetails.getId(), PageRequest.of(page, size)) + ); + } + + /** + * 작성한 게시글 목록 조회 + */ +// @GetMapping("/me/posts") +// @PreAuthorize("hasRole('USER')") +// public ResponseEntity>> getMyPosts( +// @RequestParam(required = false) String postType, +// @RequestParam(defaultValue = "0") int page, +// @RequestParam(defaultValue = "10") int size) { +// Page posts = userService.getMyPosts(postType, PageRequest.of(page, size)); +// return ResponseEntity.ok(ApiResponseDto.success(posts)); +// } +// + /** + * 작성한 댓글 목록 조회 + */ +// @GetMapping("/me/comments") +// @PreAuthorize("hasRole('USER')") +// public ResponseEntity>> getMyComments( +// @RequestParam(defaultValue = "0") int page, +// @RequestParam(defaultValue = "10") int size) { +// Page comments = userService.getMyComments(PageRequest.of(page, size)); +// return ResponseEntity.ok(ApiResponseDto.success(comments)); +// } + + } diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserDto.java b/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserDto.java deleted file mode 100644 index 32ad416..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserDto.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.hyetaekon.hyetaekon.user.dto; - -public class UserDto { -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserPasswordUpdateDto.java b/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserPasswordUpdateDto.java new file mode 100644 index 0000000..c0e91d8 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserPasswordUpdateDto.java @@ -0,0 +1,23 @@ +package com.hyetaekon.hyetaekon.user.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +@Builder +public class UserPasswordUpdateDto { + @NotBlank(message = "현재 비밀번호는 필수입니다.") + private String currentPassword; + + @NotBlank(message = "새 비밀번호는 필수입니다.") + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,20}$", + message = "비밀번호는 8자 이상 20자 이하여야 하며, 알파벳, 숫자, 특수문자를 포함해야 합니다.") + private String newPassword; + + @NotBlank(message = "새 비밀번호 확인은 필수입니다.") + private String confirmPassword; +} \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserProfileUpdateDto.java b/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserProfileUpdateDto.java new file mode 100644 index 0000000..3304649 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserProfileUpdateDto.java @@ -0,0 +1,22 @@ +package com.hyetaekon.hyetaekon.user.dto; + +import jakarta.validation.constraints.Pattern; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; + +// 개인정보수정 - 그 외 정보 변경 항목 +@Getter +@Builder +public class UserProfileUpdateDto { + @Pattern(regexp = "^[a-zA-Z0-9가-힣]{1,8}$", message = "닉네임은 알파벳, 숫자, 한글만 포함할 수 있습니다.") + private String nickname; + + private String name; // 이름 + private LocalDate birthAt; // 생년월일 + private String gender; // 성별(남자/여자) + private String city; // 지역(시/도) + private String state; // 지역(시/군/구) + private String job; // 직업 +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserResponseDto.java new file mode 100644 index 0000000..be92f79 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserResponseDto.java @@ -0,0 +1,28 @@ +package com.hyetaekon.hyetaekon.user.dto; + +import lombok.Builder; +import lombok.Getter; +import com.hyetaekon.hyetaekon.user.entity.Role; + +import java.time.LocalDate; + + +@Getter +@Builder +public class UserResponseDto { + private Long id; // 사용자 ID + private String name; // 사용자 이름 + private String realId; // 아이디 + private String nickname; // 닉네임 + private Role role; // 권한 (USER/ADMIN 등) + + private LocalDate birthAt; // 생년월일 + private String gender; // 성별 + private String city; // 시/도 + private String state; // 시/군/구 + private String job; // 직업 + + private String levelName; // 회원 등급 + private int point; // 회원 포인트 + private int remainPoint; // 승급까지 남은 포인트 +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserSignInRequestDto.java b/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserSignInRequestDto.java new file mode 100644 index 0000000..692c35b --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserSignInRequestDto.java @@ -0,0 +1,24 @@ +package com.hyetaekon.hyetaekon.user.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserSignInRequestDto { + + @NotBlank(message = "아이디는 공백일 수 없습니다.") + private String realId; + + @NotBlank(message = "비밀번호는 공백일 수 없습니다.") + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,20}$", + message = "비밀번호는 8자 이상 20자 이하여야 하며, 알파벳, 숫자, 특수문자를 포함해야 합니다.") + private String password; + +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserSignUpRequestDto.java b/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserSignUpRequestDto.java new file mode 100644 index 0000000..ca14714 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserSignUpRequestDto.java @@ -0,0 +1,50 @@ +package com.hyetaekon.hyetaekon.user.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserSignUpRequestDto { + + @NotBlank(message = "아이디는 공백일 수 없습니다.") + private String realId; + + @NotBlank(message = "비밀번호는 공백일 수 없습니다.") + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,20}$", + message = "비밀번호는 8자 이상 20자 이하여야 하며, 알파벳, 숫자, 특수문자를 포함해야 합니다.") + private String password; // 평문 비밀번호 + + @NotBlank(message = "비밀번호 확인은 공백일 수 없습니다.") + private String confirmPassword; + + @NotBlank(message = "이름은 공백일 수 없습니다.") + private String name; + + @NotBlank(message = "닉네임은 공백일 수 없습니다.") + @Pattern(regexp = "^[a-zA-Z0-9가-힣]{1,8}$", + message = "닉네임은 1자 이상 8자 이하여야 하며, 알파벳, 숫자, 한글만 포함할 수 있습니다.") + private String nickname; + + @NotNull(message = "생년월일은 공백일 수 없습니다.") + private LocalDate birthAt; + + @NotBlank(message = "성별은 공백일 수 없습니다.") + private String gender; + + @NotNull(message = "지역(시/도)은 공백일 수 없습니다.") + private String city; + @NotNull(message = "지역(시/군/구)은 공백일 수 없습니다.") + private String state; + + private String job; // 직업 +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserSignUpResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserSignUpResponseDto.java new file mode 100644 index 0000000..14eedac --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserSignUpResponseDto.java @@ -0,0 +1,13 @@ +package com.hyetaekon.hyetaekon.user.dto; + +import lombok.Builder; +import lombok.Getter; + + +@Getter +@Builder +public class UserSignUpResponseDto { + private Long id; // 사용자 ID + private String realId; // 아이디 + private String nickname; // 닉네임 +} \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserTokenResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserTokenResponseDto.java new file mode 100644 index 0000000..a490c5f --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/dto/UserTokenResponseDto.java @@ -0,0 +1,10 @@ +package com.hyetaekon.hyetaekon.user.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class UserTokenResponseDto { + private String accessToken; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/dto/admin/UserAdminResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/user/dto/admin/UserAdminResponseDto.java new file mode 100644 index 0000000..cbb31d0 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/dto/admin/UserAdminResponseDto.java @@ -0,0 +1,33 @@ +package com.hyetaekon.hyetaekon.user.dto.admin; + +import com.hyetaekon.hyetaekon.user.entity.Role; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Getter +@Builder +public class UserAdminResponseDto { + private Long id; + private String realId; + private String nickname; + private String name; + + private String gender; + private LocalDate birthAt; + private String city; + private String state; + + private String levelName; + private int point; + + private Role role; + private LocalDateTime createdAt; + private LocalDateTime deletedAt; + private String deleteReason; + private LocalDateTime suspendStartAt; + private LocalDateTime suspendEndAt; + private String suspendReason; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/dto/admin/UserReportResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/user/dto/admin/UserReportResponseDto.java new file mode 100644 index 0000000..5a86b46 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/dto/admin/UserReportResponseDto.java @@ -0,0 +1,19 @@ +package com.hyetaekon.hyetaekon.user.dto.admin; + +import lombok.Getter; +import lombok.Builder; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class UserReportResponseDto { + private Long id; + private String reporterNickname; + private String reportedNickname; + private String reason; + private String content; + private String status; + private LocalDateTime createdAt; + private LocalDateTime processedAt; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/dto/admin/UserSuspendRequestDto.java b/src/main/java/com/hyetaekon/hyetaekon/user/dto/admin/UserSuspendRequestDto.java new file mode 100644 index 0000000..aaba737 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/dto/admin/UserSuspendRequestDto.java @@ -0,0 +1,24 @@ +package com.hyetaekon.hyetaekon.user.dto.admin; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserSuspendRequestDto { + @NotNull(message = "정지 시작 시간은 필수입니다.") + private LocalDateTime suspendStartAt; + + @NotNull(message = "정지 종료 시간은 필수입니다.") + private LocalDateTime suspendEndAt; + + @NotNull(message = "정지 사유는 필수입니다.") + private String suspendReason; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/entity/PointActionType.java b/src/main/java/com/hyetaekon/hyetaekon/user/entity/PointActionType.java new file mode 100644 index 0000000..2a07a24 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/entity/PointActionType.java @@ -0,0 +1,17 @@ +package com.hyetaekon.hyetaekon.user.entity; + +import lombok.Getter; + +@Getter +public enum PointActionType { + POST_CREATION(20), // 게시글 작성 (20점) + FIRST_POST_CREATION(100), // 첫 게시글 작성 (100점) + ANSWER_CREATION(10), // 답변 작성 (10점) + ANSWER_ACCEPTED(50); // 답변이 채택됨 (50점) + + private final int points; + + PointActionType(int points) { + this.points = points; + } +} \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/entity/User.java b/src/main/java/com/hyetaekon/hyetaekon/user/entity/User.java index 51a8bfb..babb41b 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/user/entity/User.java +++ b/src/main/java/com/hyetaekon/hyetaekon/user/entity/User.java @@ -1,14 +1,157 @@ package com.hyetaekon.hyetaekon.user.entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; +import com.hyetaekon.hyetaekon.UserInterest.entity.UserInterest; +import com.hyetaekon.hyetaekon.bookmark.entity.Bookmark; +import com.hyetaekon.hyetaekon.recommend.entity.Recommend; +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Setter +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(name = "real_id", nullable = false, length = 100) + private String realId; + + @Column(name = "password", nullable = false, length = 255) + private String password; + + @Column(name = "name", length = 50) + private String name; + + @Column(name = "nickname", length = 50) + private String nickname; + + @Column(name = "birth_at") + private LocalDate birthAt; + + @Column(name = "gender", length = 10) + private String gender; + + @Column(name = "city", length = 100) + private String city; + + @Column(name = "state", length = 100) + private String state; + + // TODO: BusinessType, Occupation 고려 + @Column(name = "job", length = 50) + private String job; + + @Enumerated(EnumType.STRING) + @Column(name = "level", length = 50) + private UserLevel level; + + // TODO: 게시글 작성, 댓글 및 질문글 답변 시 적용 + @Column(name = "point") + private int point; + + @Enumerated(EnumType.STRING) + @Column(name = "role", nullable = false) + private Role role; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Column(name = "delete_reason", columnDefinition = "TEXT") + private String deleteReason; + + @Column(name = "suspend_start_at") + private LocalDateTime suspendStartAt; + + @Column(name = "suspend_end_at") + private LocalDateTime suspendEndAt; + + @Column(name = "suspend_reason", columnDefinition = "TEXT") + private String suspendReason; + + @Builder.Default + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List bookmarks = new ArrayList<>(); + + @Builder.Default + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List recommends = new ArrayList<>(); + + @Builder.Default + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List interests = new ArrayList<>(); + + // 회원 탈퇴 로직 + public void deleteUser(String deleteReason) { + this.deletedAt = LocalDateTime.now(); + this.deleteReason = deleteReason; + } + + // 회원 닉네임 변경 + public void updateNickname(String newNickname) { + this.nickname = newNickname; + } + + + // 회원 비밀번호 변경 + public void updatePassword(String newPassword) { + this.password = newPassword; + } + + // 회원 이름 변경 + public void updateName(String newName) { + this.name = newName; + } + + // 회원 생년월일 변경 + public void updateBirthAt(LocalDate newBirthAt) { + this.birthAt = newBirthAt; + } + + // 회원 성별 변경 + public void updateGender(String newGender) { + this.gender = newGender; + } + + // 회원 지역(시/도) 변경 + public void updateCity(String newCity) { + this.city = newCity; + } + + // 회원 지역(시/군/구) 변경 + public void updateState(String newState) { + this.state = newState; + } + + // 회원 직업 정보 변경 + public void updateJob(String newJob) { + this.job = newJob; + } + + // 회원 등급 Enum 변경 + public void updateLevel(UserLevel level) { + this.level = level; + } + + // 점수 추가 + public void addPoint(int amount) { + this.point += amount; + } + // 점수 감점 (0점 이상으로) + public void subtractPoint(int amount) { + this.point = Math.max(0, this.point - amount); + } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/entity/UserLevel.java b/src/main/java/com/hyetaekon/hyetaekon/user/entity/UserLevel.java new file mode 100644 index 0000000..432079b --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/entity/UserLevel.java @@ -0,0 +1,32 @@ +package com.hyetaekon.hyetaekon.user.entity; + +import lombok.Getter; + +@Getter +public enum UserLevel { + QUESTION_MARK("물음표", 0, 99), + EGG("알", 100, 299), + CHICK("병아리", 300, 499), + CHICKEN("닭", 500, 699), + EAGLE("독수리", 700, 999), + CLOUD("구름", 1000, Integer.MAX_VALUE); + + private final String name; + private final int minPoint; + private final int maxPoint; + + UserLevel(String name, int minPoint, int maxPoint) { + this.name = name; + this.minPoint = minPoint; + this.maxPoint = maxPoint; + } + + public static UserLevel fromPoint(int point) { + for (UserLevel level : values()) { + if (point >= level.minPoint && point <= level.maxPoint) { + return level; + } + } + return QUESTION_MARK; // 기본값 + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/entity/UserReport.java b/src/main/java/com/hyetaekon/hyetaekon/user/entity/UserReport.java new file mode 100644 index 0000000..b63b3ac --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/entity/UserReport.java @@ -0,0 +1,45 @@ +package com.hyetaekon.hyetaekon.user.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class UserReport { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + // 신고한 사용자 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reporter_id") + private User reporter; + + // 신고당한 사용자 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reported_id") + private User reported; + + @Column(name = "reason", length = 200) + private String reason; + + @Column(name = "content", columnDefinition = "TEXT") + private String content; + + @Column(name = "status", length = 20) + private String status; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "processed_at") + private LocalDateTime processedAt; +} \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/mapper/UserAdminMapper.java b/src/main/java/com/hyetaekon/hyetaekon/user/mapper/UserAdminMapper.java new file mode 100644 index 0000000..7d2b56b --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/mapper/UserAdminMapper.java @@ -0,0 +1,22 @@ +package com.hyetaekon.hyetaekon.user.mapper; + +import com.hyetaekon.hyetaekon.user.dto.admin.UserAdminResponseDto; +import com.hyetaekon.hyetaekon.user.dto.admin.UserReportResponseDto; +import com.hyetaekon.hyetaekon.user.entity.UserReport; +import com.hyetaekon.hyetaekon.user.entity.User; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface UserAdminMapper { + + // User Entity -> 관리자용 회원 정보 DTO 변환 + @Mapping(target = "levelName", expression = "java(user.getLevel().getName())") + UserAdminResponseDto toAdminResponseDto(User user); + + // UserReport Entity -> 신고 내역 DTO 변환 + @Mapping(source = "reporter.nickname", target = "reporterNickname") + @Mapping(source = "reported.nickname", target = "reportedNickname") + UserReportResponseDto toReportResponseDto(UserReport userReport); +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/mapper/UserMapper.java b/src/main/java/com/hyetaekon/hyetaekon/user/mapper/UserMapper.java index 4864359..91c1100 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/user/mapper/UserMapper.java +++ b/src/main/java/com/hyetaekon/hyetaekon/user/mapper/UserMapper.java @@ -1,4 +1,36 @@ package com.hyetaekon.hyetaekon.user.mapper; +import com.hyetaekon.hyetaekon.user.dto.UserResponseDto; +import com.hyetaekon.hyetaekon.user.dto.UserSignUpResponseDto; +import com.hyetaekon.hyetaekon.user.entity.User; +import com.hyetaekon.hyetaekon.user.entity.UserLevel; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) public interface UserMapper { + + /*// 회원가입 요청 DTO -> User Entity 변환 + @Mapping(target = "role", constant = "ROLE_USER") // 기본 Role 설정 + User toEntity(UserSignUpRequestDto dto);*/ + + // User Entity -> 회원가입 응답 DTO 변환 + UserSignUpResponseDto toSignUpResponseDto(User user); + + // User Entity -> 회원 정보 조회 DTO 변환 + @Mapping(target = "levelName", expression = "java(user.getLevel().getName())") + @Mapping(target = "remainPoint", expression = "java(calculateRemainPoint(user))") + UserResponseDto toResponseDto(User user); + + default int calculateRemainPoint(User user) { + UserLevel currentLevel = user.getLevel(); + if (currentLevel == UserLevel.CLOUD) { + // 이미 최고 레벨인 경우 + return 0; + } + + // 현재 레벨의 최대 포인트와 사용자 현재 포인트의 차이를 계산 + return currentLevel.getMaxPoint() - user.getPoint() + 1; + } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/repository/UserReportRepository.java b/src/main/java/com/hyetaekon/hyetaekon/user/repository/UserReportRepository.java new file mode 100644 index 0000000..4a99e23 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/repository/UserReportRepository.java @@ -0,0 +1,11 @@ +package com.hyetaekon.hyetaekon.user.repository; + + +import com.hyetaekon.hyetaekon.user.entity.UserReport; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserReportRepository extends JpaRepository { + // 기본 CRUD 메서드 사용 +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/repository/UserRepository.java b/src/main/java/com/hyetaekon/hyetaekon/user/repository/UserRepository.java index 3dde561..2c7c20d 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/user/repository/UserRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/user/repository/UserRepository.java @@ -1,4 +1,44 @@ package com.hyetaekon.hyetaekon.user.repository; -public class UserRepository { +import com.hyetaekon.hyetaekon.user.entity.User; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + // user id로 검색 + Optional findByIdAndDeletedAtIsNull(Long id); + + // user realId로 검색 + Optional findByRealIdAndDeletedAtIsNull(String realId); + + // 아이디 또는 닉네임 중복 여부 확인 + @Query("SELECT u FROM User u WHERE (u.realId = :realId OR u.nickname = :nickname) AND u.deletedAt IS NULL") + Optional findByRealIdOrNicknameAndDeletedAtIsNull(@Param("realId") String realId, @Param("nickname") String nickname); + + // 닉네임 중복 확인 + boolean existsByNickname(String nickname); + + // 이메일 중복 확인 + boolean existsByRealIdAndDeletedAtIsNull(String realId); + + // 정지된 회원 목록 조회 + @Query("SELECT u FROM User u WHERE u.suspendEndAt > :now AND u.deletedAt IS NULL") + Page findSuspendedUsers(Pageable pageable, @Param("now") LocalDateTime now); + + // 메서드 오버로딩: 현재 시간을 자동으로 설정 + default Page findSuspendedUsers(Pageable pageable) { + return findSuspendedUsers(pageable, LocalDateTime.now()); + } + + // 탈퇴한 회원 목록 조회 + @Query("SELECT u FROM User u WHERE u.deletedAt IS NOT NULL") + Page findWithdrawnUsers(Pageable pageable); } diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/service/AuthService.java b/src/main/java/com/hyetaekon/hyetaekon/user/service/AuthService.java new file mode 100644 index 0000000..5a262a3 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/service/AuthService.java @@ -0,0 +1,93 @@ +package com.hyetaekon.hyetaekon.user.service; + +import com.hyetaekon.hyetaekon.common.exception.ErrorCode; +import com.hyetaekon.hyetaekon.common.exception.GlobalException; +import com.hyetaekon.hyetaekon.common.jwt.*; +import com.hyetaekon.hyetaekon.common.util.CookieUtil; +import com.hyetaekon.hyetaekon.user.dto.UserSignInRequestDto; +import com.hyetaekon.hyetaekon.user.entity.User; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + private final AuthenticationManagerBuilder authenticationManagerBuilder; + private final JwtTokenProvider jwtTokenProvider; + private final PasswordEncoder passwordEncoder; + private final RefreshTokenService refreshTokenService; + private final UserService userService; + private final BlacklistService blacklistService; + + // 로그인 + @Transactional + public JwtToken login(UserSignInRequestDto signInRequestDto) { + User user = validateUser(signInRequestDto); + Authentication authentication = authenticateUser(user.getRealId(), signInRequestDto.getPassword()); + return jwtTokenProvider.generateToken(authentication); + } + + // 토큰 재발급 + @Transactional + public JwtToken refresh(String refreshToken) { + + RefreshToken tokenDetails = refreshTokenService.getRefreshToken(refreshToken) + .orElseThrow(() -> new GlobalException(ErrorCode.INVALID_TOKEN)); + + String realId = tokenDetails.getRealId(); + + User user = userService.findUserByRealId(realId); + Authentication authentication = createAuthentication(user); + + // 기존에 있던 리프레시 토큰은 DB에서 제거 + refreshTokenService.deleteRefreshToken(refreshToken); + + return jwtTokenProvider.generateToken(authentication); + + } + + // 로그 아웃 + @Transactional + public void logout(String accessToken, String refreshToken, HttpServletResponse response) { + blacklistService.addToBlacklist(accessToken); + refreshTokenService.deleteRefreshToken(refreshToken); + CookieUtil.deleteCookie(response, "refreshToken"); + } + + // realId, password를 사용해서 유저 확인 + private User validateUser(UserSignInRequestDto signInRequestDto) { + User user = userService.findUserByRealId(signInRequestDto.getRealId()); + + if (!passwordEncoder.matches(signInRequestDto.getPassword(), user.getPassword())) { + throw new GlobalException(ErrorCode.INVALID_PASSWORD); + } + return user; + } + + // 이메일, authentication 생성 + private Authentication authenticateUser(String realId, String password) { + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(realId, password); + + return authenticationManagerBuilder.getObject().authenticate(authenticationToken); + } + + + // User 객체를 사용해 authentication 생성 + private Authentication createAuthentication(User user) { + CustomUserDetails customUserDetails = new CustomUserDetails( + user.getId(), user.getRealId(), user.getNickname(), user.getRole(), user.getPassword(),user.getName()); + + return new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); + } + + +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/service/UserAdminService.java b/src/main/java/com/hyetaekon/hyetaekon/user/service/UserAdminService.java new file mode 100644 index 0000000..c175027 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/service/UserAdminService.java @@ -0,0 +1,120 @@ +package com.hyetaekon.hyetaekon.user.service; + +import com.hyetaekon.hyetaekon.common.exception.ErrorCode; +import com.hyetaekon.hyetaekon.common.exception.GlobalException; +import com.hyetaekon.hyetaekon.user.dto.admin.UserAdminResponseDto; +import com.hyetaekon.hyetaekon.user.dto.admin.UserReportResponseDto; +import com.hyetaekon.hyetaekon.user.dto.admin.UserSuspendRequestDto; +import com.hyetaekon.hyetaekon.user.entity.User; +import com.hyetaekon.hyetaekon.user.entity.UserReport; +import com.hyetaekon.hyetaekon.user.mapper.UserAdminMapper; +import com.hyetaekon.hyetaekon.user.repository.UserReportRepository; +import com.hyetaekon.hyetaekon.user.repository.UserRepository; +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.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserAdminService { + private final UserRepository userRepository; + private final UserReportRepository userReportRepository; + private final UserAdminMapper userAdminMapper; + + /** + * 모든 회원 목록 조회 (페이징) + */ + @Transactional(readOnly = true) + public Page getAllUsers(int page, int size) { + Pageable pageable = PageRequest.of(page, size, Sort.by("id").descending()); + Page userPage = userRepository.findAll(pageable); + return userPage.map(userAdminMapper::toAdminResponseDto); + } + + /** + * 회원 정지 + */ + @Transactional + public void suspendUser(Long userId, UserSuspendRequestDto requestDto) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + // 현재 시간보다 종료 시간이 빠른지 검사 + if (requestDto.getSuspendEndAt().isBefore(LocalDateTime.now())) { + throw new GlobalException(ErrorCode.INVALID_SUSPEND_TIME); + } + + // 시작 시간이 종료 시간보다 늦은지 검사 + if (requestDto.getSuspendStartAt().isAfter(requestDto.getSuspendEndAt())) { + throw new GlobalException(ErrorCode.INVALID_SUSPEND_TIME); + } + + user.setSuspendStartAt(requestDto.getSuspendStartAt()); + user.setSuspendEndAt(requestDto.getSuspendEndAt()); + user.setSuspendReason(requestDto.getSuspendReason()); + + userRepository.save(user); + log.info("회원 {} 정지 처리 완료", userId); + } + + /** + * 정지 해제 + */ + @Transactional + public void unsuspendUser(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + // 정지 상태가 아닌 경우 + if (user.getSuspendEndAt() == null || user.getSuspendEndAt().isBefore(LocalDateTime.now())) { + throw new GlobalException(ErrorCode.NOT_SUSPENDED_USER); + } + + user.setSuspendStartAt(null); + user.setSuspendEndAt(null); + user.setSuspendReason(null); + + userRepository.save(user); + log.info("회원 {} 정지 해제 완료", userId); + } + + /** + * 정지 회원 목록 조회 + */ + @Transactional(readOnly = true) + public Page getSuspendedUsers(int page, int size) { + Pageable pageable = PageRequest.of(page, size, Sort.by("suspendEndAt").descending()); + Page userPage = userRepository.findSuspendedUsers(pageable); + return userPage.map(userAdminMapper::toAdminResponseDto); + } + + /** + * 탈퇴 회원 목록 조회 + */ + @Transactional(readOnly = true) + public Page getWithdrawnUsers(int page, int size) { + Pageable pageable = PageRequest.of(page, size, Sort.by("deletedAt").descending()); + Page userPage = userRepository.findWithdrawnUsers(pageable); + return userPage.map(userAdminMapper::toAdminResponseDto); + } + + /** + * 신고 내역 조회 + */ + @Transactional(readOnly = true) + public Page getUserReports(int page, int size) { + Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); + Page reportPage = userReportRepository.findAll(pageable); + return reportPage.map(userAdminMapper::toReportResponseDto); + } + + +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/service/UserAuthService.java b/src/main/java/com/hyetaekon/hyetaekon/user/service/UserAuthService.java deleted file mode 100644 index ede16c2..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/user/service/UserAuthService.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.hyetaekon.hyetaekon.user.service; - -public class UserAuthService { -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/service/UserLevelService.java b/src/main/java/com/hyetaekon/hyetaekon/user/service/UserLevelService.java new file mode 100644 index 0000000..577954f --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/service/UserLevelService.java @@ -0,0 +1,23 @@ +package com.hyetaekon.hyetaekon.user.service; + +import com.hyetaekon.hyetaekon.user.entity.User; +import com.hyetaekon.hyetaekon.user.entity.UserLevel; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class UserLevelService { + + @Transactional + public void checkAndUpdateLevel(User user) { + int currentPoint = user.getPoint(); + UserLevel appropriateLevel = UserLevel.fromPoint(currentPoint); + + // 현재 레벨과 적절한 레벨이 다른 경우에만 업데이트 + if (appropriateLevel != user.getLevel()) { + user.updateLevel(appropriateLevel); + // 레벨 업 이벤트 발행 가능 (옵션) + } + } + +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/service/UserPointService.java b/src/main/java/com/hyetaekon/hyetaekon/user/service/UserPointService.java new file mode 100644 index 0000000..2dd70db --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/user/service/UserPointService.java @@ -0,0 +1,46 @@ +package com.hyetaekon.hyetaekon.user.service; + +import com.hyetaekon.hyetaekon.common.exception.ErrorCode; +import com.hyetaekon.hyetaekon.common.exception.GlobalException; +import com.hyetaekon.hyetaekon.post.repository.PostRepository; +import com.hyetaekon.hyetaekon.user.entity.PointActionType; +import com.hyetaekon.hyetaekon.user.entity.User; +import com.hyetaekon.hyetaekon.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserPointService { + private final UserRepository userRepository; + private final UserLevelService userLevelService; + private final PostRepository postRepository; // 게시글 저장소 추가 필요 + + @Transactional + public void addPointForAction(Long userId, PointActionType actionType) { + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + int pointToAdd = actionType.getPoints(); + + // 게시글 작성인 경우 첫 게시글 여부 확인 + if (actionType == PointActionType.POST_CREATION) { + boolean isFirstPost = !postRepository.existsByUser_IdAndDeletedAtIsNull(userId); + if (isFirstPost) { + // 첫 게시글인 경우 FIRST_POST_CREATION 포인트 적용 + pointToAdd = PointActionType.FIRST_POST_CREATION.getPoints(); + log.info("사용자 {}의 첫 게시글 작성으로 {}점 획득", userId, pointToAdd); + } + } + + user.addPoint(pointToAdd); + + // 레벨 체크 및 업데이트 + userLevelService.checkAndUpdateLevel(user); + + userRepository.save(user); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/service/UserService.java b/src/main/java/com/hyetaekon/hyetaekon/user/service/UserService.java index 2f001c3..6c47b6c 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/user/service/UserService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/user/service/UserService.java @@ -1,4 +1,206 @@ package com.hyetaekon.hyetaekon.user.service; +import com.hyetaekon.hyetaekon.common.exception.ErrorCode; +import com.hyetaekon.hyetaekon.common.exception.GlobalException; +import com.hyetaekon.hyetaekon.common.jwt.BlacklistService; +import com.hyetaekon.hyetaekon.common.jwt.RefreshTokenService; +import com.hyetaekon.hyetaekon.user.dto.*; +import com.hyetaekon.hyetaekon.user.entity.Role; +import com.hyetaekon.hyetaekon.user.entity.User; +import com.hyetaekon.hyetaekon.user.entity.UserLevel; +import com.hyetaekon.hyetaekon.user.mapper.UserMapper; +import com.hyetaekon.hyetaekon.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor public class UserService { + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final UserMapper userMapper; + private final RefreshTokenService refreshTokenService; + private final BlacklistService blacklistService; + + + // 회원 가입 + // TODO: Occupation, BusinessType 직업 정보 재확인 + @Transactional + public UserSignUpResponseDto registerUser(UserSignUpRequestDto userSignUpRequestDto) { + // 이메일 또는 닉네임 중복 검사 + Optional existingUser = userRepository.findByRealIdOrNicknameAndDeletedAtIsNull( + userSignUpRequestDto.getRealId(), + userSignUpRequestDto.getNickname() + ); + + // 비밀번호와 비밀번호 확인 일치 여부 검사 + if (!userSignUpRequestDto.getPassword().equals(userSignUpRequestDto.getConfirmPassword())) { + throw new GlobalException(ErrorCode.PASSWORD_CONFIRM_MISMATCH); + } + + if (existingUser.isPresent()) { + User user = existingUser.get(); // NPE 방지 + if (user.getRealId().equals(userSignUpRequestDto.getRealId())) { + throw new GlobalException(ErrorCode.DUPLICATED_REAL_ID); + } + if (user.getNickname().equals(userSignUpRequestDto.getNickname())) { + throw new GlobalException(ErrorCode.DUPLICATED_NICKNAME); + } + } + + String encodedPassword = passwordEncoder.encode(userSignUpRequestDto.getPassword()); + + // 추가 필드를 포함한 User 객체 생성 + User newUser = User.builder() + .realId(userSignUpRequestDto.getRealId()) + .nickname(userSignUpRequestDto.getNickname()) + .password(encodedPassword) + .name(userSignUpRequestDto.getName()) + .birthAt(userSignUpRequestDto.getBirthAt()) + .gender(userSignUpRequestDto.getGender()) + .city(userSignUpRequestDto.getCity()) + .state(userSignUpRequestDto.getState()) + .job(userSignUpRequestDto.getJob()) + .role(Role.ROLE_USER) + .level(UserLevel.QUESTION_MARK) + .point(0) // 초기 포인트 설정 + .createdAt(LocalDateTime.now()) // 생성 시간 설정 + .build(); + + User savedUser = userRepository.save(newUser); + log.debug("회원 가입 - 이메일: {}", savedUser.getRealId()); + + return userMapper.toSignUpResponseDto(savedUser); + } + + // 회원 정보 조회 + @Transactional(readOnly = true) + public UserResponseDto getMyInfo(Long userId) { + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + return userMapper.toResponseDto(user); + } + + // 이메일로 회원 검색 + @Transactional(readOnly = true) + public User findUserByRealId(String realId) { + return userRepository.findByRealIdAndDeletedAtIsNull(realId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_REAL_ID)); + + } + + + // 회원 정보 수정(닉네임, 이름, 성별, 생년월일, 지역, 직업) + @Transactional + public UserResponseDto updateUserProfile(Long userId, UserProfileUpdateDto profileUpdateDto) { + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + // 닉네임 업데이트 + if (profileUpdateDto.getNickname() != null && !profileUpdateDto.getNickname().isBlank()) { + // 현재 닉네임과 다를 경우에만 중복 체크 + if (!user.getNickname().equals(profileUpdateDto.getNickname()) && + userRepository.existsByNickname(profileUpdateDto.getNickname())) { + throw new GlobalException(ErrorCode.DUPLICATED_NICKNAME); + } + user.updateNickname(profileUpdateDto.getNickname()); + } + + // 이름 업데이트 + if (profileUpdateDto.getName() != null && !profileUpdateDto.getName().isBlank()) { + user.updateName(profileUpdateDto.getName()); + } + + // 생년월일 업데이트 + if (profileUpdateDto.getBirthAt() != null) { + user.updateBirthAt(profileUpdateDto.getBirthAt()); + } + + // 성별 업데이트 + if (profileUpdateDto.getGender() != null && !profileUpdateDto.getGender().isBlank()) { + user.updateGender(profileUpdateDto.getGender()); + } + + // 지역(시/도) 업데이트 + if (profileUpdateDto.getCity() != null && !profileUpdateDto.getCity().isBlank()) { + user.updateCity(profileUpdateDto.getCity()); + } + + // 지역(시/군/구) 업데이트 + if (profileUpdateDto.getState() != null && !profileUpdateDto.getState().isBlank()) { + user.updateState(profileUpdateDto.getState()); + } + + // 직업 업데이트 (필요시) + if (profileUpdateDto.getJob() != null && !profileUpdateDto.getJob().isBlank()) { + user.updateJob(profileUpdateDto.getJob()); + } + + User updatedUser = userRepository.save(user); + log.debug("회원 프로필 정보 업데이트 - ID: {}", userId); + return userMapper.toResponseDto(updatedUser); + } + + // 비밀번호 변경 + @Transactional + public void updateUserPassword(Long userId, UserPasswordUpdateDto passwordUpdateDto) { + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + // 현재 비밀번호 검증 + if (!passwordEncoder.matches(passwordUpdateDto.getCurrentPassword(), user.getPassword())) { + throw new GlobalException(ErrorCode.PASSWORD_MISMATCH); + } + + // 새 비밀번호와 확인 비밀번호 일치 여부 확인 + if (!passwordUpdateDto.getNewPassword().equals(passwordUpdateDto.getConfirmPassword())) { + throw new GlobalException(ErrorCode.PASSWORD_CONFIRM_MISMATCH); + } + + // 새 비밀번호가 현재 비밀번호와 같은지 확인 + if (passwordEncoder.matches(passwordUpdateDto.getNewPassword(), user.getPassword())) { + throw new GlobalException(ErrorCode.PASSWORD_SAME_AS_OLD); + } + + // 비밀번호 업데이트 + user.updatePassword(passwordEncoder.encode(passwordUpdateDto.getNewPassword())); + userRepository.save(user); + log.debug("회원 비밀번호 변경 완료 - ID: {}", userId); + } + + // 회원 탈퇴 + @Transactional + public void deleteUser(Long userId, String deleteReason, String accessToken, String refreshToken) { + // 사용자 정보 조회 + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + // redis에서 리프레시 토큰 삭제 + refreshTokenService.deleteRefreshToken(refreshToken); + // access token 블랙리스트에 등록 + blacklistService.addToBlacklist(accessToken); + + // 사용자 탈퇴 처리 (소프트 삭제) + user.deleteUser(deleteReason); + log.debug("회원 탈퇴 - 이메일: {} , 탈퇴 사유: {}", userId, deleteReason); + + } + + // 중복 확인(회원 가입시 아이디, 닉네임 부분) + public boolean checkDuplicate(String type, String value) { + return switch (type.toLowerCase()) { + case "realid" -> userRepository.existsByRealIdAndDeletedAtIsNull(value); + case "nickname" -> userRepository.existsByNickname(value); + default -> throw new IllegalArgumentException("잘못된 타입 입력값입니다."); + }; + } + } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index d72573a..6ccf93e 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -1,14 +1,11 @@ -#spring: -# data: -# redis: -# host: localhost -# port: 6379 -# timeout: 2000 -# password: -# -#app: -# front-url: http://localhost:3000 -# -#jwt: -# access-expired: 3600 # 1시간 -# refresh-expired: 86400 # 1일 \ No newline at end of file +spring: + datasource: + url: ${MYSQL_DEV_URL} # MySQL 데이터베이스 URL + data: + redis: + host: localhost + +jwt: + access-expired: 3600 # 1시간 + refresh-expired: 86400 # 1일 + diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 33054ff..7259009 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -1,11 +1,12 @@ -#spring: -# data: -# redis: -# password: ${REDIS_PASSWORD:} -# -#app: -# front-url: https://hyetaek-on.site -# -#jwt: -# access-expired: 1800 # 30분 -# refresh-expired: 432000 # 5일 \ No newline at end of file +spring: + datasource: + url: ${MYSQL_PROD_URL} # MySQL 데이터베이스 URL + data: + redis: + host: hyetaekon-redis + password: ${REDIS_PASSWORD} + + +jwt: + access-expired: 1800 # 30분 + refresh-expired: 432000 # 5일 \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8d7de81..315d5b6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,47 +1,40 @@ spring: -# data: + data: # mongodb: # uri: mongodb+srv://${MONGODB_USERNAME}:${MONGODB_PASSWORD}@${MONGODB_URL}/${MONGODB_NAME}?retryWrites=true&w=majority&appName=HyetaekOn # auto-index-creation: true -# redis: -# host: hyetaekon-redis -# port: 6379 -# timeout: 2000 -# password: + redis: + port: 6379 + timeout: 2000 + password: datasource: - url: jdbc:h2:mem:testdb # H2 인메모리 데이터베이스 (테스트용) - driver-class-name: org.h2.Driver - username: sa - password: '' - h2: - console: - enabled: true # H2 웹 콘솔 활성화 - path: /h2-console # H2 콘솔 URL 경로 (기본값: /h2-console) + username: ${MYSQL_USERNAME} + password: ${MYSQL_PASSWORD} + hikari: + connection-timeout: 30000 # 30초 연결 제한 +# h2: +# console: +# enabled: true # H2 웹 콘솔 활성화 +# path: /h2-console # H2 콘솔 URL 경로 (기본값: /h2-console) jpa: - defer-datasource-initialization: true show-sql: true # SQL 로그 출력 hibernate: - ddl-auto: update + ddl-auto: update # create # update properties: hibernate: + dialect: org.hibernate.dialect.MySQL8Dialect format_sql: true # SQL 로그를 보기 좋게 포맷 auto_quote_keyword: true # 예약어를 자동으로 따옴표 처리 order_inserts: true order_updates: true jdbc: batch_size: 1000 - sql: - init: - mode: always # data.sql 실행 - - servlet: - multipart: - max-file-size: 10MB # 단일 파일의 최대 크기 - max-request-size: 100MB # 전체 요청의 최대 크기 - profiles: active: dev, s3Bucket # logback-spring SpringProfile 설정 및 AWS S3 Bucket 설정 public-data: public-service: ${PUBLIC_SERVICE_INFO_KEY} + +jwt: + secret-key: ${JWT_SECRET_KEY}