From 4b9c54ad444cc5e9bb74fb78edf41ac8408c7f54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A7=80=EC=9B=90?= Date: Sat, 4 Apr 2026 21:44:58 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20add=20supply=20api=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/exception/AuthErrorCode.java | 22 +++ .../controller/CalendarController.java | 55 +++++++ .../dto/response/CalendarDailyResponse.java | 19 +++ .../dto/response/CalendarMonthlyResponse.java | 19 +++ .../calender/service/CalendarService.java | 117 ++++++++++++++ .../chore/controller/ChoreController.java | 49 ++++++ .../chore/dto/request/AssignmentRequest.java | 49 ++++++ .../dto/response/AssignmentResponse.java | 27 ++++ .../chore/exception/ChoreErrorCode.java | 16 ++ .../chore/repository/ChoreRepository.java | 26 +++ .../repository/HouseholdChoreRepository.java | 10 ++ .../chore/service/ChoreAssignmentService.java | 149 ++++++++++++++++++ .../controller/HouseholdController.java | 77 +++++++++ .../dto/request/CreateHouseholdRequest.java | 13 ++ .../dto/request/JoinHouseholdRequest.java | 13 ++ .../exception/HouseholdErrorCode.java | 18 +++ .../dto/request/UserPreferenceRequest.java | 34 ++++ .../UserChorePreferenceRepository.java | 20 +++ .../service/UserPreferenceService.java | 48 ++++++ .../controller/ScheduleController.java | 74 +++++++++ .../schedule/dto/request/ScheduleRequest.java | 19 +++ .../dto/request/ScheduleUpdateRequest.java | 14 ++ .../schedule/exception/ScheduleErrorCode.java | 17 ++ .../repository/ScheduleRepository.java | 28 ++++ .../schedule/service/ScheduleService.java | 65 ++++++++ .../user/controller/UserController.java | 64 ++++++++ .../user/dto/request/UpdateUserRequest.java | 13 ++ .../domain/user/exception/UserErrorCode.java | 21 +++ .../domain/user/service/UserService.java | 25 +++ .../repository/UtilityBillRepository.java | 13 ++ src/main/java/com/partition/entity/Chore.java | 40 +++++ .../com/partition/entity/HouseholdChore.java | 50 ++++++ .../java/com/partition/entity/Schedule.java | 50 ++++++ .../partition/entity/UserChorePreference.java | 37 +++++ .../com/partition/entity/UtilityBill.java | 29 ++++ .../com/partition/entity/enums/ChoreType.java | 24 +++ .../global/exception/BaseErrorCode.java | 7 + .../global/exception/GlobalErrorCode.java | 15 ++ 38 files changed, 1386 insertions(+) create mode 100644 src/main/java/com/partition/domain/auth/exception/AuthErrorCode.java create mode 100644 src/main/java/com/partition/domain/calender/controller/CalendarController.java create mode 100644 src/main/java/com/partition/domain/calender/dto/response/CalendarDailyResponse.java create mode 100644 src/main/java/com/partition/domain/calender/dto/response/CalendarMonthlyResponse.java create mode 100644 src/main/java/com/partition/domain/calender/service/CalendarService.java create mode 100644 src/main/java/com/partition/domain/chore/controller/ChoreController.java create mode 100644 src/main/java/com/partition/domain/chore/dto/request/AssignmentRequest.java create mode 100644 src/main/java/com/partition/domain/chore/dto/response/AssignmentResponse.java create mode 100644 src/main/java/com/partition/domain/chore/exception/ChoreErrorCode.java create mode 100644 src/main/java/com/partition/domain/chore/repository/ChoreRepository.java create mode 100644 src/main/java/com/partition/domain/chore/repository/HouseholdChoreRepository.java create mode 100644 src/main/java/com/partition/domain/chore/service/ChoreAssignmentService.java create mode 100644 src/main/java/com/partition/domain/household/controller/HouseholdController.java create mode 100644 src/main/java/com/partition/domain/household/dto/request/CreateHouseholdRequest.java create mode 100644 src/main/java/com/partition/domain/household/dto/request/JoinHouseholdRequest.java create mode 100644 src/main/java/com/partition/domain/household/exception/HouseholdErrorCode.java create mode 100644 src/main/java/com/partition/domain/preference/dto/request/UserPreferenceRequest.java create mode 100644 src/main/java/com/partition/domain/preference/repository/UserChorePreferenceRepository.java create mode 100644 src/main/java/com/partition/domain/preference/service/UserPreferenceService.java create mode 100644 src/main/java/com/partition/domain/schedule/controller/ScheduleController.java create mode 100644 src/main/java/com/partition/domain/schedule/dto/request/ScheduleRequest.java create mode 100644 src/main/java/com/partition/domain/schedule/dto/request/ScheduleUpdateRequest.java create mode 100644 src/main/java/com/partition/domain/schedule/exception/ScheduleErrorCode.java create mode 100644 src/main/java/com/partition/domain/schedule/repository/ScheduleRepository.java create mode 100644 src/main/java/com/partition/domain/schedule/service/ScheduleService.java create mode 100644 src/main/java/com/partition/domain/user/controller/UserController.java create mode 100644 src/main/java/com/partition/domain/user/dto/request/UpdateUserRequest.java create mode 100644 src/main/java/com/partition/domain/user/exception/UserErrorCode.java create mode 100644 src/main/java/com/partition/domain/user/service/UserService.java create mode 100644 src/main/java/com/partition/domain/utilitybill/repository/UtilityBillRepository.java create mode 100644 src/main/java/com/partition/entity/Chore.java create mode 100644 src/main/java/com/partition/entity/HouseholdChore.java create mode 100644 src/main/java/com/partition/entity/Schedule.java create mode 100644 src/main/java/com/partition/entity/UserChorePreference.java create mode 100644 src/main/java/com/partition/entity/UtilityBill.java create mode 100644 src/main/java/com/partition/entity/enums/ChoreType.java create mode 100644 src/main/java/com/partition/global/exception/BaseErrorCode.java create mode 100644 src/main/java/com/partition/global/exception/GlobalErrorCode.java diff --git a/src/main/java/com/partition/domain/auth/exception/AuthErrorCode.java b/src/main/java/com/partition/domain/auth/exception/AuthErrorCode.java new file mode 100644 index 0000000..a895e5a --- /dev/null +++ b/src/main/java/com/partition/domain/auth/exception/AuthErrorCode.java @@ -0,0 +1,22 @@ +package com.partition.domain.auth.exception; + +import com.partition.global.exception.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum AuthErrorCode implements BaseErrorCode { // 인터페이스 구현 + + // 21xx: Auth 관련 + EMPTY_ACCESS_TOKEN(400, "액세스 토큰이 없습니다."), + INVALID_ACCESS_TOKEN(401, "유효하지 않은 액세스 토큰입니다."), + EXPIRED_ACCESS_TOKEN(401, "액세스 토큰이 만료되었습니다."), + EMPTY_REFRESH_TOKEN(400, "리프레시 토큰이 없습니다."), + INVALID_REFRESH_TOKEN(401, "유효하지 않은 리프레시 토큰입니다."), + EXPIRED_REFRESH_TOKEN(401, "리프레시 토큰이 만료되었습니다."), + KAKAO_LOGIN_FAILED(502, "카카오 로그인에 실패했습니다."); + + private final int status; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/partition/domain/calender/controller/CalendarController.java b/src/main/java/com/partition/domain/calender/controller/CalendarController.java new file mode 100644 index 0000000..8c95d84 --- /dev/null +++ b/src/main/java/com/partition/domain/calender/controller/CalendarController.java @@ -0,0 +1,55 @@ +package com.partition.domain.calender.controller; + +import com.partition.domain.calender.dto.response.CalendarDailyResponse; +import com.partition.domain.calender.dto.response.CalendarMonthlyResponse; +import com.partition.domain.calender.service.CalendarService; +import com.partition.domain.common.dto.response.ApiResponse; +import com.partition.global.config.security.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.util.List; + +@RestController +@RequestMapping("/api/calendars") +@RequiredArgsConstructor +public class CalendarController { + + private final CalendarService calendarService; + + @Operation(summary = "월간 캘린더 조회", description = "특정 월의 날짜별 일정(일정, 집안일, 공과금) 개수를 반환합니다.") + @GetMapping("/monthly") + public ResponseEntity>> getMonthlyCalendar( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestParam int year, + @RequestParam int month) { + + Long userId = Long.parseLong(userDetails.getUsername()); + List result = calendarService.getMonthlyCalendar(userId, year, month); + + return ResponseEntity.ok( + ApiResponse.onSuccess("200", "월간 캘린더 조회 성공", result) + ); + } + + @Operation(summary = "일간 상세 조회", description = "특정 날짜의 상세 일정(집안일, 일정) 목록을 반환합니다.") + @GetMapping("/daily") + public ResponseEntity>> getDailyCalendar( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestParam LocalDate date) { // yyyy-MM-dd 형식 자동 매핑 + + Long userId = Long.parseLong(userDetails.getUsername()); + List result = calendarService.getDailyCalendar(userId, date); + + return ResponseEntity.ok( + ApiResponse.onSuccess("200", "일간 상세 조회 성공", result) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/partition/domain/calender/dto/response/CalendarDailyResponse.java b/src/main/java/com/partition/domain/calender/dto/response/CalendarDailyResponse.java new file mode 100644 index 0000000..6f16123 --- /dev/null +++ b/src/main/java/com/partition/domain/calender/dto/response/CalendarDailyResponse.java @@ -0,0 +1,19 @@ +package com.partition.domain.calender.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CalendarDailyResponse { + private String category; // "CHORE" or "SCHEDULE" + private Long id; // 각 엔티티의 PK + private String title; // 제목 (내용) + private String assigneeName; // 담당자 또는 작성자 이름 + private Boolean isCompleted; // 완료 여부 (일정은 false) + +} \ No newline at end of file diff --git a/src/main/java/com/partition/domain/calender/dto/response/CalendarMonthlyResponse.java b/src/main/java/com/partition/domain/calender/dto/response/CalendarMonthlyResponse.java new file mode 100644 index 0000000..1be5f82 --- /dev/null +++ b/src/main/java/com/partition/domain/calender/dto/response/CalendarMonthlyResponse.java @@ -0,0 +1,19 @@ +package com.partition.domain.calender.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CalendarMonthlyResponse { + private LocalDate date; + private long choreCount; + private long scheduleCount; + private long utilityBillsCount; +} \ No newline at end of file diff --git a/src/main/java/com/partition/domain/calender/service/CalendarService.java b/src/main/java/com/partition/domain/calender/service/CalendarService.java new file mode 100644 index 0000000..f5c3aa0 --- /dev/null +++ b/src/main/java/com/partition/domain/calender/service/CalendarService.java @@ -0,0 +1,117 @@ +package com.partition.domain.calender.service; + +import com.partition.domain.calender.dto.response.CalendarDailyResponse; +import com.partition.domain.calender.dto.response.CalendarMonthlyResponse; +import com.partition.domain.chore.repository.ChoreRepository; +import com.partition.domain.schedule.repository.ScheduleRepository; +import com.partition.domain.user.exception.UserErrorCode; +import com.partition.domain.user.repository.UserRepository; +import com.partition.domain.utilitybill.repository.UtilityBillRepository; +import com.partition.entity.Chore; +import com.partition.entity.Schedule; +import com.partition.entity.User; +import com.partition.entity.UtilityBill; +import com.partition.global.exception.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Service +@RequiredArgsConstructor +public class CalendarService { + + private final UserRepository userRepository; + private final ScheduleRepository scheduleRepository; + private final ChoreRepository choreRepository; + private final UtilityBillRepository utilityBillRepository; + + // 월간 캘린더 조회 + @Transactional(readOnly = true) + public List getMonthlyCalendar(Long userId, int year, int month) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + if (user.getHouseholdId() == null) return new ArrayList<>(); + Long householdId = user.getHouseholdId(); + + LocalDate startDate = LocalDate.of(year, month, 1); + LocalDate endDate = startDate.withDayOfMonth(startDate.lengthOfMonth()); + + List schedules = scheduleRepository.findAllByHouseholdIdAndDateRange(householdId, startDate, endDate); + List chores = choreRepository.findAllByHouseholdIdAndDateRange(householdId, startDate, endDate); + List bills = utilityBillRepository.findAllByHouseholdIdAndDueDateBetween(householdId, startDate, endDate); + + Map countMap = new HashMap<>(); + + for (Schedule s : schedules) countMap.computeIfAbsent(s.getDate(), k -> new MonthlyCounts()).scheduleCount++; + for (Chore c : chores) countMap.computeIfAbsent(c.getDate(), k -> new MonthlyCounts()).choreCount++; + for (UtilityBill b : bills) countMap.computeIfAbsent(b.getDueDate(), k -> new MonthlyCounts()).utilityBillsCount++; + + return countMap.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(entry -> CalendarMonthlyResponse.builder() + .date(entry.getKey()) + .scheduleCount(entry.getValue().scheduleCount) + .choreCount(entry.getValue().choreCount) + .utilityBillsCount(entry.getValue().utilityBillsCount) + .build()) + .collect(Collectors.toList()); + } + + // 일간 상세 조회 (집안일 + 일정 통합 리스트) + @Transactional(readOnly = true) + public List getDailyCalendar(Long userId, LocalDate date) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + if (user.getHouseholdId() == null) { + return new ArrayList<>(); + } + Long householdId = user.getHouseholdId(); + + // 해당 날짜의 집안일(Chore) 조회 -> DTO 변환 + List chores = choreRepository.findAllByHouseholdIdAndDateRange(householdId, date, date) + .stream() + .map(chore -> CalendarDailyResponse.builder() + .category("CHORE") + .id(chore.getId()) + .title(chore.getType().getDescription()) + .assigneeName( + Optional.ofNullable(chore.getAssignee()) + .map(User::getName) + .orElse(null) + ) + .isCompleted(chore.isCompleted()) + .build()) + .toList(); + + // 해당 날짜의 일정(Schedule) 조회 -> DTO 변환 + List schedules = scheduleRepository.findAllByHouseholdIdAndDateRange(householdId, date, date) + .stream() + .map(schedule -> CalendarDailyResponse.builder() + .category("SCHEDULE") + .id(schedule.getId()) + .title(schedule.getContent()) + .assigneeName(schedule.getUser().getName()) + .isCompleted(false) + .build()) + .toList(); + + // 두 리스트 합치기 (정렬: CHORE -> SCHEDULE) + return Stream.concat(chores.stream(), schedules.stream()) + .sorted(Comparator.comparing(CalendarDailyResponse::getCategory)) + .collect(Collectors.toList()); + } + + // 내부 클래스: 날짜별 개수 집계용 + private static class MonthlyCounts { + long scheduleCount = 0; + long choreCount = 0; + long utilityBillsCount = 0; + } +} \ No newline at end of file diff --git a/src/main/java/com/partition/domain/chore/controller/ChoreController.java b/src/main/java/com/partition/domain/chore/controller/ChoreController.java new file mode 100644 index 0000000..6a838b7 --- /dev/null +++ b/src/main/java/com/partition/domain/chore/controller/ChoreController.java @@ -0,0 +1,49 @@ +package com.partition.domain.chore.controller; + +import com.partition.domain.chore.service.ChoreAssignmentService; +import com.partition.domain.common.dto.response.ApiResponse; +import com.partition.global.config.security.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; + +@RestController +@RequestMapping("/api/chores") +@RequiredArgsConstructor +public class ChoreController { + + private final ChoreAssignmentService assignmentService; + + @Operation(summary = "집안일 자동 배정 요청", description = "AI 알고리즘(FastAPI)을 호출하여 지정된 기간 동안의 집안일을 배정하고 결과를 저장합니다.") + @PostMapping("/auto-assign") + public ResponseEntity> autoAssignChores( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestParam LocalDate startDate, + @RequestParam LocalDate endDate) { + + // 토큰에서 요청자 ID 추출 + Long userId = Long.parseLong(userDetails.getUsername()); + + // 시작일과 종료일 사이의 기간(일수) 계산 (종료일 포함이므로 +1) + int periodDays = (int) ChronoUnit.DAYS.between(startDate, endDate) + 1; + + if (periodDays < 1) { + throw new IllegalArgumentException("종료일은 시작일보다 같거나 이후여야 합니다."); + } + + // 서비스 호출 (FastAPI 통신 -> DB 저장) + assignmentService.assignChores(userId, startDate, periodDays); + + return ResponseEntity.ok( + ApiResponse.onSuccess("200", "집안일 배정이 완료되었습니다.", "Success") + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/partition/domain/chore/dto/request/AssignmentRequest.java b/src/main/java/com/partition/domain/chore/dto/request/AssignmentRequest.java new file mode 100644 index 0000000..ad1510a --- /dev/null +++ b/src/main/java/com/partition/domain/chore/dto/request/AssignmentRequest.java @@ -0,0 +1,49 @@ +package com.partition.domain.chore.dto.request; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AssignmentRequest { + private Long householdId; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") + private LocalDate startDate; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") + private LocalDate endDate; + private List users; + private List chores; + private List preferences; + + @Getter + @AllArgsConstructor + public static class UserDto { + private Long id; + private String name; + } + + @Getter + @AllArgsConstructor + public static class ChoreInfo { + private Long id; // HouseholdChore PK + private String name; // DISH_WASHING + private int difficulty; // 1~5 + private int frequency; + } + + @Getter + @AllArgsConstructor + public static class PreferenceDto { + private Long userId; + private Long choreId; // HouseholdChore PK + private int preference; // 1~5 + } +} \ No newline at end of file diff --git a/src/main/java/com/partition/domain/chore/dto/response/AssignmentResponse.java b/src/main/java/com/partition/domain/chore/dto/response/AssignmentResponse.java new file mode 100644 index 0000000..f1d3158 --- /dev/null +++ b/src/main/java/com/partition/domain/chore/dto/response/AssignmentResponse.java @@ -0,0 +1,27 @@ +package com.partition.domain.chore.dto.response; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDate; +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +public class AssignmentResponse { + + + private Long householdId; + private List assignments; + + @Getter + @NoArgsConstructor + @Setter + public static class AssignmentResult { + private Long userId; + private Long choreId; // HouseholdChore의 ID (설정값 ID) + private LocalDate date; // "2025-11-21" 문자열이 LocalDate로 자동 매핑됨 + } +} diff --git a/src/main/java/com/partition/domain/chore/exception/ChoreErrorCode.java b/src/main/java/com/partition/domain/chore/exception/ChoreErrorCode.java new file mode 100644 index 0000000..c561b69 --- /dev/null +++ b/src/main/java/com/partition/domain/chore/exception/ChoreErrorCode.java @@ -0,0 +1,16 @@ +package com.partition.domain.chore.exception; + +import com.partition.global.exception.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ChoreErrorCode implements BaseErrorCode { + ASSIGNMENT_API_ERROR(502, "집안일 배정 AI 응답 오류입니다."), + HOUSEHOLD_ID_MISMATCH(401, "집 ID가 일치하지 않습니다."), + ASSIGNMENT_API_UNAVAILABLE(503, "집안일 배정 AI 서버를 호출할 수 없습니다."); + + private final int status; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/partition/domain/chore/repository/ChoreRepository.java b/src/main/java/com/partition/domain/chore/repository/ChoreRepository.java new file mode 100644 index 0000000..c2d702d --- /dev/null +++ b/src/main/java/com/partition/domain/chore/repository/ChoreRepository.java @@ -0,0 +1,26 @@ +package com.partition.domain.chore.repository; + +import com.partition.entity.Chore; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; + +public interface ChoreRepository extends JpaRepository { + + @Query("SELECT c FROM Chore c WHERE c.assignee.householdId = :householdId AND c.date BETWEEN :startDate AND :endDate") + List findAllByHouseholdIdAndDateRange( + @Param("householdId") Long householdId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); + + // 일간 상세 조회용 (특정 날짜) - assignee 정보 함께 가져오기(Fetch Join) + @Query("SELECT c FROM Chore c JOIN FETCH c.assignee WHERE c.assignee.householdId = :householdId AND c.date = :date") + List findAllByHouseholdIdAndDate( + @Param("householdId") Long householdId, + @Param("date") LocalDate date + ); +} \ No newline at end of file diff --git a/src/main/java/com/partition/domain/chore/repository/HouseholdChoreRepository.java b/src/main/java/com/partition/domain/chore/repository/HouseholdChoreRepository.java new file mode 100644 index 0000000..33bf22d --- /dev/null +++ b/src/main/java/com/partition/domain/chore/repository/HouseholdChoreRepository.java @@ -0,0 +1,10 @@ +package com.partition.domain.chore.repository; + +import com.partition.entity.HouseholdChore; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface HouseholdChoreRepository extends JpaRepository { + List findByHouseholdId(Long householdId); +} \ No newline at end of file diff --git a/src/main/java/com/partition/domain/chore/service/ChoreAssignmentService.java b/src/main/java/com/partition/domain/chore/service/ChoreAssignmentService.java new file mode 100644 index 0000000..3d26eed --- /dev/null +++ b/src/main/java/com/partition/domain/chore/service/ChoreAssignmentService.java @@ -0,0 +1,149 @@ +package com.partition.domain.chore.service; + +import com.partition.domain.chore.dto.request.AssignmentRequest; +import com.partition.domain.chore.dto.response.AssignmentResponse; +import com.partition.domain.chore.exception.ChoreErrorCode; +import com.partition.domain.chore.repository.ChoreRepository; +import com.partition.domain.chore.repository.HouseholdChoreRepository; +import com.partition.domain.preference.repository.UserChorePreferenceRepository; +import com.partition.domain.user.exception.UserErrorCode; +import com.partition.domain.user.repository.UserRepository; +import com.partition.entity.Chore; +import com.partition.entity.HouseholdChore; +import com.partition.entity.User; +import com.partition.entity.UserChorePreference; +import com.partition.global.exception.CustomException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ChoreAssignmentService { + + private final RestTemplate restTemplate; + private final UserRepository userRepository; + private final HouseholdChoreRepository householdChoreRepository; + private final UserChorePreferenceRepository preferenceRepository; + private final ChoreRepository choreRepository; + + @Value("${fastapi.url}") + private String FASTAPI_URL; + + public void assignChores(Long userId, LocalDate startDate, int periodDays) { + // 요청자 및 그룹 확인 (단순 조회이므로 트랜잭션 없어도 무방하거나, readOnly 트랜잭션 사용 가능) + User requester = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + if (requester.getHouseholdId() == null) { + throw new CustomException(UserErrorCode.HAVE_NO_GROUP); + } + Long householdId = requester.getHouseholdId(); + LocalDate endDate = startDate.plusDays(periodDays - 1); + + // 데이터 수집 + List members = userRepository.findByHouseholdId(householdId); + List householdChores = householdChoreRepository.findByHouseholdId(householdId); + List preferences = preferenceRepository.findAllByHouseholdId(householdId); + + // DTO 변환 + AssignmentRequest request = createRequest(householdId, startDate, endDate, members, householdChores, preferences); + + // FastAPI 호출 (트랜잭션 밖에서 수행 -> DB 커넥션 점유 시간 단축) + log.info("FastAPI로 배정 요청 전송: householdId={}", householdId); + AssignmentResponse response; + try { + response = restTemplate.postForObject(FASTAPI_URL, request, AssignmentResponse.class); + + if (response == null || response.getAssignments() == null) { + throw new CustomException(ChoreErrorCode.ASSIGNMENT_API_ERROR); + } + } catch (RestClientException e) { + log.error("FastAPI 호출 실패: {}", e.getMessage(), e); + throw new CustomException(ChoreErrorCode.ASSIGNMENT_API_UNAVAILABLE); + } + + // 결과 저장 (별도 트랜잭션으로 실행) + saveAssignmentsInTransaction(response, members, householdChores); + + log.info("집안일 배정 완료: 총 {}건", response.getAssignments().size()); + } + + // 저장 로직만 트랜잭션으로 묶음 + @Transactional + public void saveAssignmentsInTransaction(AssignmentResponse response, List members, List householdChores) { + // 검색 성능을 위한 Map 생성 + Map userMap = members.stream().collect(Collectors.toMap(User::getId, u -> u)); + Map choreMap = householdChores.stream().collect(Collectors.toMap(HouseholdChore::getId, c -> c)); + + for (AssignmentResponse.AssignmentResult result : response.getAssignments()) { + User assignee = userMap.get(result.getUserId()); + HouseholdChore hhChore = choreMap.get(result.getChoreId()); + + if (assignee == null || hhChore == null) { + log.warn("유효하지 않은 배정 결과 건너뜀: userId={}, choreId={}", result.getUserId(), result.getChoreId()); + continue; + } + + Chore chore = Chore.builder() + .assignee(assignee) + .type(hhChore.getChoreType()) + .date(result.getDate()) + .build(); + + choreRepository.save(chore); + } + } + + private AssignmentRequest createRequest(Long householdId, LocalDate startDate, LocalDate endDate, + List members, List chores, List preferences) { + + List userDtos = members.stream() + .map(u -> new AssignmentRequest.UserDto(u.getId(), u.getName())) + .toList(); + + List choreDtos = chores.stream() + .map(c -> new AssignmentRequest.ChoreInfo( + c.getId(), + c.getChoreType().name(), + c.getDifficulty(), + c.getFrequency() + )) + .toList(); + + Map typeToIdMap = chores.stream() + .collect(Collectors.toMap( + c -> c.getChoreType().name(), + HouseholdChore::getId, + (existing, replacement) -> existing // 중복 키 발생 시 기존 값 유지 (Duplicate Key Exception 방지) + )); + + List prefDtos = preferences.stream() + .filter(p -> typeToIdMap.containsKey(p.getChoreType().name())) + .map(p -> new AssignmentRequest.PreferenceDto( + p.getUser().getId(), + typeToIdMap.get(p.getChoreType().name()), + p.getScore() + )) + .toList(); + + return AssignmentRequest.builder() + .householdId(householdId) + .startDate(startDate) + .endDate(endDate) + .users(userDtos) + .chores(choreDtos) + .preferences(prefDtos) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/partition/domain/household/controller/HouseholdController.java b/src/main/java/com/partition/domain/household/controller/HouseholdController.java new file mode 100644 index 0000000..bee606e --- /dev/null +++ b/src/main/java/com/partition/domain/household/controller/HouseholdController.java @@ -0,0 +1,77 @@ +package com.partition.domain.household.controller; + +import com.partition.domain.common.dto.response.ApiResponse; +import com.partition.domain.household.dto.request.CreateHouseholdRequest; +import com.partition.domain.household.dto.request.JoinHouseholdRequest; +import com.partition.domain.household.service.HouseholdService; +import com.partition.entity.Household; +import com.partition.global.config.security.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/households") +@RequiredArgsConstructor +public class HouseholdController { + + private final HouseholdService householdService; + + @Operation(summary = "새로운 그룹 만들기", description = "그룹을 생성하고 유저를 방장(LEADER)으로 설정합니다.") + @PostMapping + public ResponseEntity>> createHousehold( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody @Valid CreateHouseholdRequest request) { + + // 토큰에서 유저 ID 추출 + Long userId = Long.parseLong(userDetails.getUsername()); + + // 서비스 호출 + Household household = householdService.createHousehold(userId, request.getName()); + + // 결과 반환 (초대코드 및 그룹 ID 포함) + return ResponseEntity.ok( + ApiResponse.onSuccess( + "200", + "그룹 생성 성공", + Map.of( + "householdId", household.getId(), + "inviteCode", household.getInviteCode() + ) + ) + ); + } + + + // 기존 그룹 참여 API + @Operation(summary = "그룹 참여하기", description = "초대 코드를 입력하여 기존 그룹에 참여합니다.") + @PostMapping("/join") + public ResponseEntity>> joinHousehold( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody @Valid JoinHouseholdRequest request) { + + Long userId = Long.parseLong(userDetails.getUsername()); + + // 서비스 호출 + Household household = householdService.joinHousehold(userId, request.getInviteCode()); + + return ResponseEntity.ok( + ApiResponse.onSuccess( + "200", + "그룹 참여 성공", + Map.of( + "householdId", household.getId(), + "householdName", household.getName() + ) + ) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/partition/domain/household/dto/request/CreateHouseholdRequest.java b/src/main/java/com/partition/domain/household/dto/request/CreateHouseholdRequest.java new file mode 100644 index 0000000..47ed831 --- /dev/null +++ b/src/main/java/com/partition/domain/household/dto/request/CreateHouseholdRequest.java @@ -0,0 +1,13 @@ +package com.partition.domain.household.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class CreateHouseholdRequest { + + @NotBlank(message = "그룹 이름은 필수입니다.") + private String name; +} diff --git a/src/main/java/com/partition/domain/household/dto/request/JoinHouseholdRequest.java b/src/main/java/com/partition/domain/household/dto/request/JoinHouseholdRequest.java new file mode 100644 index 0000000..8003d4f --- /dev/null +++ b/src/main/java/com/partition/domain/household/dto/request/JoinHouseholdRequest.java @@ -0,0 +1,13 @@ +package com.partition.domain.household.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class JoinHouseholdRequest { + + @NotBlank(message = "초대 코드는 필수입니다.") + private String inviteCode; +} \ No newline at end of file diff --git a/src/main/java/com/partition/domain/household/exception/HouseholdErrorCode.java b/src/main/java/com/partition/domain/household/exception/HouseholdErrorCode.java new file mode 100644 index 0000000..56619d5 --- /dev/null +++ b/src/main/java/com/partition/domain/household/exception/HouseholdErrorCode.java @@ -0,0 +1,18 @@ +package com.partition.domain.household.exception; + +import com.partition.global.exception.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum HouseholdErrorCode implements BaseErrorCode { + + HOUSEHOLD_NOT_FOUND(404, "해당 그룹을 찾을 수 없습니다."), + INVALID_INVITE_CODE(400, "유효하지 않은 초대 코드입니다."), + ALREADY_JOINED(409, "이미 그룹에 소속되어 있습니다."), + INVITE_CODE_GENERATION_FAILED(500, "초대 코드 생성에 실패했습니다. 잠시 후 다시 시도해주세요."); + + private final int status; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/partition/domain/preference/dto/request/UserPreferenceRequest.java b/src/main/java/com/partition/domain/preference/dto/request/UserPreferenceRequest.java new file mode 100644 index 0000000..6df5407 --- /dev/null +++ b/src/main/java/com/partition/domain/preference/dto/request/UserPreferenceRequest.java @@ -0,0 +1,34 @@ +package com.partition.domain.preference.dto.request; + +import com.partition.entity.enums.ChoreType; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +public class UserPreferenceRequest { + + @Valid + @NotNull(message = "선호도 리스트는 필수입니다.") + private List preferences; + + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class PreferenceDto { + @NotNull(message = "집안일 종류는 필수입니다.") + private ChoreType choreType; + + @NotNull(message = "점수는 필수입니다.") + @Min(value = 1, message = "점수는 1점 이상이어야 합니다.") + @Max(value = 5, message = "점수는 5점 이하이어야 합니다.") + private Integer score; + } +} diff --git a/src/main/java/com/partition/domain/preference/repository/UserChorePreferenceRepository.java b/src/main/java/com/partition/domain/preference/repository/UserChorePreferenceRepository.java new file mode 100644 index 0000000..3392cf5 --- /dev/null +++ b/src/main/java/com/partition/domain/preference/repository/UserChorePreferenceRepository.java @@ -0,0 +1,20 @@ +package com.partition.domain.preference.repository; + +import com.partition.entity.UserChorePreference; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +public interface UserChorePreferenceRepository extends JpaRepository { + // 유저의 기존 선호도 데이터를 모두 삭제 (재등록 시 초기화용) + @Modifying + @Transactional + void deleteByUserId(Long userId); + + @Query("SELECT ucp FROM UserChorePreference ucp WHERE ucp.user.householdId = :householdId") + List findAllByHouseholdId(@Param("householdId") Long householdId); +} diff --git a/src/main/java/com/partition/domain/preference/service/UserPreferenceService.java b/src/main/java/com/partition/domain/preference/service/UserPreferenceService.java new file mode 100644 index 0000000..0ceb235 --- /dev/null +++ b/src/main/java/com/partition/domain/preference/service/UserPreferenceService.java @@ -0,0 +1,48 @@ +package com.partition.domain.preference.service; + +import com.partition.domain.preference.dto.request.UserPreferenceRequest; +import com.partition.domain.preference.repository.UserChorePreferenceRepository; +import com.partition.domain.user.exception.UserErrorCode; +import com.partition.domain.user.repository.UserRepository; +import com.partition.entity.User; +import com.partition.entity.UserChorePreference; +import com.partition.global.exception.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class UserPreferenceService { + + private final UserRepository userRepository; + private final UserChorePreferenceRepository preferenceRepository; + + @Transactional + public List savePreferences(Long userId, UserPreferenceRequest request) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + // 중복 방지를 위해 해당 유저의 기존 선호도 데이터 삭제 + preferenceRepository.deleteByUserId(userId); + + List savedList = new ArrayList<>(); + + // 새로운 선호도 리스트 저장 + for (UserPreferenceRequest.PreferenceDto dto : request.getPreferences()) { + UserChorePreference preference = UserChorePreference.builder() + .user(user) + .choreType(dto.getChoreType()) + .score(dto.getScore()) + .build(); + + preferenceRepository.save(preference); + savedList.add(dto); // 반환할 리스트에 추가 + } + + return savedList; // 저장된 결과 그대로 반환 + } +} \ No newline at end of file diff --git a/src/main/java/com/partition/domain/schedule/controller/ScheduleController.java b/src/main/java/com/partition/domain/schedule/controller/ScheduleController.java new file mode 100644 index 0000000..fd3ff63 --- /dev/null +++ b/src/main/java/com/partition/domain/schedule/controller/ScheduleController.java @@ -0,0 +1,74 @@ +package com.partition.domain.schedule.controller; + +import com.partition.domain.common.dto.response.ApiResponse; +import com.partition.domain.schedule.dto.request.ScheduleRequest; +import com.partition.domain.schedule.dto.request.ScheduleUpdateRequest; +import com.partition.domain.schedule.service.ScheduleService; +import com.partition.global.config.security.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/schedules") +@RequiredArgsConstructor +public class ScheduleController { + + private final ScheduleService scheduleService; + + @Operation(summary = "일정 등록", description = "새로운 일정을 등록합니다.") + @PostMapping + public ResponseEntity>> createSchedule( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody @Valid ScheduleRequest request) { + + // 토큰에서 유저 ID 추출 + Long userId = Long.parseLong(userDetails.getUsername()); + + // 서비스 호출 (ID 반환) + Long scheduleId = scheduleService.createSchedule(userId, request); + + // 결과 반환 + return ResponseEntity.ok( + ApiResponse.onSuccess("200", "일정 등록 성공", Map.of("scheduleId", scheduleId)) + ); + } + + @Operation(summary = "일정 수정", description = "기존 일정을 수정합니다. 변경하고 싶은 필드만 보내세요.") + @PatchMapping("/{scheduleId}") + public ResponseEntity>> updateSchedule( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long scheduleId, + @RequestBody ScheduleUpdateRequest request) { + + Long userId = Long.parseLong(userDetails.getUsername()); + scheduleService.updateSchedule(userId, scheduleId, request); + + return ResponseEntity.ok( + ApiResponse.onSuccess("200", "일정 수정 성공", Map.of("scheduleId", scheduleId)) + ); + } + + @Operation(summary = "일정 삭제", description = "일정을 삭제합니다.") + @DeleteMapping("/{scheduleId}") + public ResponseEntity>> deleteSchedule( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long scheduleId) { + + // 유저 ID 추출 + Long userId = Long.parseLong(userDetails.getUsername()); + + // 서비스 호출 (권한 체크 및 삭제) + scheduleService.deleteSchedule(userId, scheduleId); + + // 성공 응답 (삭제된 ID 반환) + return ResponseEntity.ok( + ApiResponse.onSuccess("200", "일정 삭제 성공", Map.of("scheduleId", scheduleId)) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/partition/domain/schedule/dto/request/ScheduleRequest.java b/src/main/java/com/partition/domain/schedule/dto/request/ScheduleRequest.java new file mode 100644 index 0000000..5fc4987 --- /dev/null +++ b/src/main/java/com/partition/domain/schedule/dto/request/ScheduleRequest.java @@ -0,0 +1,19 @@ +package com.partition.domain.schedule.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Getter +@NoArgsConstructor +public class ScheduleRequest { + + @NotBlank(message = "내용은 필수입니다.") + private String content; + + @NotNull(message = "날짜는 필수입니다.") + private LocalDate date; +} \ No newline at end of file diff --git a/src/main/java/com/partition/domain/schedule/dto/request/ScheduleUpdateRequest.java b/src/main/java/com/partition/domain/schedule/dto/request/ScheduleUpdateRequest.java new file mode 100644 index 0000000..856548d --- /dev/null +++ b/src/main/java/com/partition/domain/schedule/dto/request/ScheduleUpdateRequest.java @@ -0,0 +1,14 @@ +package com.partition.domain.schedule.dto.request; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Getter +@NoArgsConstructor +public class ScheduleUpdateRequest { + // 수정할 때 값이 안 넘어오면(null이면) 기존 값을 유지하기 위해 Validation 어노테이션을 뺐습니다. + private String content; + private LocalDate date; +} \ No newline at end of file diff --git a/src/main/java/com/partition/domain/schedule/exception/ScheduleErrorCode.java b/src/main/java/com/partition/domain/schedule/exception/ScheduleErrorCode.java new file mode 100644 index 0000000..0c0fbd2 --- /dev/null +++ b/src/main/java/com/partition/domain/schedule/exception/ScheduleErrorCode.java @@ -0,0 +1,17 @@ +package com.partition.domain.schedule.exception; + +import com.partition.global.exception.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ScheduleErrorCode implements BaseErrorCode { + + SCHEDULE_NOT_FOUND(404, "해당 일정을 찾을 수 없습니다."), + NO_PERMISSION_TO_MODIFY(403, "일정을 수정할 권한이 없습니다."), + NO_PERMISSION_TO_DELETE(403, "일정을 삭제할 권한이 없습니다."); + + private final int status; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/partition/domain/schedule/repository/ScheduleRepository.java b/src/main/java/com/partition/domain/schedule/repository/ScheduleRepository.java new file mode 100644 index 0000000..3f44411 --- /dev/null +++ b/src/main/java/com/partition/domain/schedule/repository/ScheduleRepository.java @@ -0,0 +1,28 @@ +package com.partition.domain.schedule.repository; + +import com.partition.entity.Schedule; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; + +public interface ScheduleRepository extends JpaRepository { + + // 해당 그룹(Household)에 속한 모든 유저의 일정 조회 + // User -> Household 관계를 통해 조인 + @Query("SELECT s FROM Schedule s WHERE s.user.householdId = :householdId AND s.date BETWEEN :startDate AND :endDate") + List findAllByHouseholdIdAndDateRange( + @Param("householdId") Long householdId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); + + // 일간 상세 조회용 (특정 날짜) + @Query("SELECT s FROM Schedule s JOIN FETCH s.user WHERE s.user.householdId = :householdId AND s.date = :date") + List findAllByHouseholdIdAndDate( + @Param("householdId") Long householdId, + @Param("date") LocalDate date + ); +} \ No newline at end of file diff --git a/src/main/java/com/partition/domain/schedule/service/ScheduleService.java b/src/main/java/com/partition/domain/schedule/service/ScheduleService.java new file mode 100644 index 0000000..90fbb0e --- /dev/null +++ b/src/main/java/com/partition/domain/schedule/service/ScheduleService.java @@ -0,0 +1,65 @@ +package com.partition.domain.schedule.service; + +import com.partition.domain.schedule.dto.request.ScheduleRequest; +import com.partition.domain.schedule.dto.request.ScheduleUpdateRequest; +import com.partition.domain.schedule.exception.ScheduleErrorCode; +import com.partition.domain.schedule.repository.ScheduleRepository; +import com.partition.domain.user.exception.UserErrorCode; +import com.partition.domain.user.repository.UserRepository; +import com.partition.entity.Schedule; +import com.partition.entity.User; +import com.partition.global.exception.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class ScheduleService { + + private final ScheduleRepository scheduleRepository; + private final UserRepository userRepository; + + // 일정 생성 + public Long createSchedule(Long userId, ScheduleRequest request) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + // Request에 없는 title, type, time 등은 제외하고 content, date만 저장 + Schedule schedule = Schedule.builder() + .user(user) + .content(request.getContent()) + .date(request.getDate()) + .build(); + + return scheduleRepository.save(schedule).getId(); + } + + // 일정 수정 + public void updateSchedule(Long userId, Long scheduleId, ScheduleUpdateRequest request) { + Schedule schedule = scheduleRepository.findById(scheduleId) + .orElseThrow(() -> new CustomException(ScheduleErrorCode.SCHEDULE_NOT_FOUND)); + + // 작성자 본인 확인 + if (!schedule.getUser().getId().equals(userId)) { + throw new CustomException(ScheduleErrorCode.NO_PERMISSION_TO_MODIFY); + } + + // 내용과 날짜만 업데이트 (값이 null이면 엔티티 내부에서 무시됨) + schedule.update(request.getContent(), request.getDate(), null); + } + + // 일정 삭제 + public void deleteSchedule(Long userId, Long scheduleId) { + Schedule schedule = scheduleRepository.findById(scheduleId) + .orElseThrow(() -> new CustomException(ScheduleErrorCode.SCHEDULE_NOT_FOUND)); + + // 작성자 본인 확인 + if (!schedule.getUser().getId().equals(userId)) { + throw new CustomException(ScheduleErrorCode.NO_PERMISSION_TO_DELETE); + } + + scheduleRepository.delete(schedule); + } +} \ No newline at end of file diff --git a/src/main/java/com/partition/domain/user/controller/UserController.java b/src/main/java/com/partition/domain/user/controller/UserController.java new file mode 100644 index 0000000..f8c2c74 --- /dev/null +++ b/src/main/java/com/partition/domain/user/controller/UserController.java @@ -0,0 +1,64 @@ +package com.partition.domain.user.controller; + +import com.partition.domain.common.dto.response.ApiResponse; +import com.partition.domain.preference.dto.request.UserPreferenceRequest; +import com.partition.domain.preference.service.UserPreferenceService; +import com.partition.domain.user.dto.request.UpdateUserRequest; +import com.partition.domain.user.service.UserService; +import com.partition.global.config.security.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/users") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + private final UserPreferenceService userPreferenceService; + + + @Operation(summary = "내 이름(닉네임) 변경", description = "가입 후 이름을 설정할 때 사용합니다.") + @PatchMapping("/me") + public ResponseEntity> updateMyName( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody @Valid UpdateUserRequest request) { + + Long userId = Long.parseLong(userDetails.getUsername()); + + // 서비스 호출 + userService.updateName(userId, request.getName()); + + // 성공 응답 생성 + return ResponseEntity.ok( + ApiResponse.onSuccess( + "200", + "회원 이름이 정상적으로 변경되었습니다.", + null + ) + ); + } + + @Operation(summary = "집안일 선호도 등록 (온보딩)", description = "온보딩 단계에서 집안일별 점수(1~5)를 저장합니다.") + @PostMapping("/me/preferences") + public ResponseEntity>> savePreferences( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody @Valid UserPreferenceRequest request) { + + Long userId = Long.parseLong(userDetails.getUsername()); + + // 서비스 호출 및 저장된 리스트 반환 + List savedPreferences = userPreferenceService.savePreferences(userId, request); + + return ResponseEntity.ok( + ApiResponse.onSuccess("200", "선호도 등록 성공", savedPreferences) + ); + } + +} \ No newline at end of file diff --git a/src/main/java/com/partition/domain/user/dto/request/UpdateUserRequest.java b/src/main/java/com/partition/domain/user/dto/request/UpdateUserRequest.java new file mode 100644 index 0000000..0495763 --- /dev/null +++ b/src/main/java/com/partition/domain/user/dto/request/UpdateUserRequest.java @@ -0,0 +1,13 @@ +package com.partition.domain.user.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class UpdateUserRequest { + + @NotBlank(message = "이름은 필수입니다.") + private String name; // 변경할 닉네임 +} \ No newline at end of file diff --git a/src/main/java/com/partition/domain/user/exception/UserErrorCode.java b/src/main/java/com/partition/domain/user/exception/UserErrorCode.java new file mode 100644 index 0000000..8925841 --- /dev/null +++ b/src/main/java/com/partition/domain/user/exception/UserErrorCode.java @@ -0,0 +1,21 @@ +package com.partition.domain.user.exception; + +import com.partition.global.exception.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum UserErrorCode implements BaseErrorCode { + + // User 관련 + USER_NOT_FOUND(404, "존재하지 않는 회원입니다."), + DUPLICATED_EMAIL(409, "이미 가입된 이메일입니다."), + DUPLICATED_NICKNAME(409, "이미 사용중인 닉네임입니다."), + MISSING_REQUIRED_VALUE(400, "필수 항목이 누락되었습니다."), + HAVE_NO_GROUP(404, "그룹에 속해있지 않습니다."); + + + private final int status; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/partition/domain/user/service/UserService.java b/src/main/java/com/partition/domain/user/service/UserService.java new file mode 100644 index 0000000..4b5bc75 --- /dev/null +++ b/src/main/java/com/partition/domain/user/service/UserService.java @@ -0,0 +1,25 @@ +package com.partition.domain.user.service; + +import com.partition.domain.user.exception.UserErrorCode; +import com.partition.domain.user.repository.UserRepository; +import com.partition.entity.User; +import com.partition.global.exception.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + + @Transactional + public void updateName(Long userId, String newName) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + // User 엔티티에 있는 updateName 편의 메서드를 호출 + user.updateName(newName); + } +} \ No newline at end of file diff --git a/src/main/java/com/partition/domain/utilitybill/repository/UtilityBillRepository.java b/src/main/java/com/partition/domain/utilitybill/repository/UtilityBillRepository.java new file mode 100644 index 0000000..b099e0e --- /dev/null +++ b/src/main/java/com/partition/domain/utilitybill/repository/UtilityBillRepository.java @@ -0,0 +1,13 @@ +package com.partition.domain.utilitybill.repository; + +import com.partition.entity.UtilityBill; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; + +public interface UtilityBillRepository extends JpaRepository { + + // 공과금은 Household와 직접 연관됨 + List findAllByHouseholdIdAndDueDateBetween(Long householdId, LocalDate startDate, LocalDate endDate); +} \ No newline at end of file diff --git a/src/main/java/com/partition/entity/Chore.java b/src/main/java/com/partition/entity/Chore.java new file mode 100644 index 0000000..794aa80 --- /dev/null +++ b/src/main/java/com/partition/entity/Chore.java @@ -0,0 +1,40 @@ +package com.partition.entity; + +import com.partition.entity.enums.ChoreType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Getter +@Table(name = "chores") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Chore extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User assignee; // 담당자 + + @Enumerated(EnumType.STRING) + private ChoreType type; + + private LocalDate date; // 수행 날짜 + + private boolean isCompleted; // 완료 여부 + + @Builder + public Chore(User assignee, ChoreType type, LocalDate date) { + this.assignee = assignee; + this.type = type; + this.date = date; + this.isCompleted = false; + } +} \ No newline at end of file diff --git a/src/main/java/com/partition/entity/HouseholdChore.java b/src/main/java/com/partition/entity/HouseholdChore.java new file mode 100644 index 0000000..e5175da --- /dev/null +++ b/src/main/java/com/partition/entity/HouseholdChore.java @@ -0,0 +1,50 @@ +package com.partition.entity; + +import com.partition.entity.enums.ChoreType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "household_chores") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class HouseholdChore { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "household_id", nullable = false) + private Household household; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ChoreType choreType; + + @Column(nullable = false) + private Integer difficulty; // 1-5 (기본값 3) + + @Column(nullable = false) + private Integer frequency; // 빈도 (주당 횟수) + + @Builder + public HouseholdChore(Household household, ChoreType choreType, Integer difficulty, Integer frequency) { + this.household = household; + this.choreType = choreType; + this.difficulty = difficulty; + this.frequency = frequency; + } + + // 나중에 난이도 수정 API에서 사용 + public void updateDifficulty(Integer difficulty) { + this.difficulty = difficulty; + } + + public void updateFrequency(Integer frequency) { + this.frequency = frequency; + } +} \ No newline at end of file diff --git a/src/main/java/com/partition/entity/Schedule.java b/src/main/java/com/partition/entity/Schedule.java new file mode 100644 index 0000000..82089a8 --- /dev/null +++ b/src/main/java/com/partition/entity/Schedule.java @@ -0,0 +1,50 @@ +package com.partition.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalTime; + +@Entity +@Getter +@Table(name = "schedules") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Schedule extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "schedule_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(nullable = false) + private LocalDate date; + + private LocalTime time; // 시간은 선택 (null 가능) + + @Builder + public Schedule(User user, String content, LocalDate date, LocalTime time) { + this.user = user; + this.content = content; + this.date = date; + this.time = time; + } + + // 내용 수정 메서드 + public void update(String content, LocalDate date, LocalTime time) { + if (content != null) this.content = content; + if (date != null) this.date = date; + // 시간은 null로 업데이트(삭제)하고 싶을 수도 있으므로 그대로 할당 + this.time = time; + } +} \ No newline at end of file diff --git a/src/main/java/com/partition/entity/UserChorePreference.java b/src/main/java/com/partition/entity/UserChorePreference.java new file mode 100644 index 0000000..9c00d51 --- /dev/null +++ b/src/main/java/com/partition/entity/UserChorePreference.java @@ -0,0 +1,37 @@ +package com.partition.entity; + +import com.partition.entity.enums.ChoreType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "user_chore_preferences") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserChorePreference { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ChoreType choreType; + + @Column(nullable = false) + private Integer score; // 1 ~ 5 점 + + @Builder + public UserChorePreference(User user, ChoreType choreType, Integer score) { + this.user = user; + this.choreType = choreType; + this.score = score; + } +} \ No newline at end of file diff --git a/src/main/java/com/partition/entity/UtilityBill.java b/src/main/java/com/partition/entity/UtilityBill.java new file mode 100644 index 0000000..80c7578 --- /dev/null +++ b/src/main/java/com/partition/entity/UtilityBill.java @@ -0,0 +1,29 @@ +package com.partition.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Getter +@Table(name = "utility_bills") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UtilityBill { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "household_id") + private Household household; + + private String title; // 월세, 전기세 등 + + private LocalDate dueDate; // 납부일 + + private boolean isPaid; // 납부 여부 +} \ No newline at end of file diff --git a/src/main/java/com/partition/entity/enums/ChoreType.java b/src/main/java/com/partition/entity/enums/ChoreType.java new file mode 100644 index 0000000..8596411 --- /dev/null +++ b/src/main/java/com/partition/entity/enums/ChoreType.java @@ -0,0 +1,24 @@ +package com.partition.entity.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ChoreType { + DISH_WASHING("설거지 하기", 3, 7), // 난이도 3, 주당 7회 + COOKING("요리 하기", 3, 7), + LAUNDRY("빨래 하기", 3, 3), + FOODTRASH("음식물 쓰레기 버리기", 3, 4), + TRASH("일반 쓰레기 버리기", 3, 2), + RECYCLING("재활용 쓰레기 버리기", 3, 1), + VACUUM("청소기 돌리기", 3, 3), + MOPPING("바닥 닦기", 3, 2), + WINDOW("창문, 창틀 닦기", 3, 1), + BATHROOM("화장실 청소하기", 3, 1), + FRIDGE("냉장고 청소하기", 3, 1); + + private final String description; + private final int defaultDifficulty; + private final int defaultFrequency; // 주당 기본 빈도 +} \ No newline at end of file diff --git a/src/main/java/com/partition/global/exception/BaseErrorCode.java b/src/main/java/com/partition/global/exception/BaseErrorCode.java new file mode 100644 index 0000000..76ed08d --- /dev/null +++ b/src/main/java/com/partition/global/exception/BaseErrorCode.java @@ -0,0 +1,7 @@ +package com.partition.global.exception; + +public interface BaseErrorCode { + int getStatus(); // HTTP 상태 코드 (400, 401, 404 등) + String getMessage(); // 에러 메시지 + String name(); // Enum의 이름 (AUTH_2101 등) - Enum이 기본적으로 가지고 있음 +} \ No newline at end of file diff --git a/src/main/java/com/partition/global/exception/GlobalErrorCode.java b/src/main/java/com/partition/global/exception/GlobalErrorCode.java new file mode 100644 index 0000000..6d9adaf --- /dev/null +++ b/src/main/java/com/partition/global/exception/GlobalErrorCode.java @@ -0,0 +1,15 @@ +package com.partition.global.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum GlobalErrorCode implements BaseErrorCode { + + INTERNAL_SERVER_ERROR(500, "서버 내부 오류입니다."), + INVALID_INPUT_VALUE(400, "입력값이 올바르지 않습니다."); + + private final int status; + private final String message; +} \ No newline at end of file From 14e56f8e59de56649419f3e809b52b3633203e1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A7=80=EC=9B=90?= Date: Sat, 4 Apr 2026 22:08:38 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EC=98=88=EC=A0=84=20api=EB=93=A4?= =?UTF-8?q?=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cicd.yml | 61 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .github/workflows/cicd.yml diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml new file mode 100644 index 0000000..12480d2 --- /dev/null +++ b/.github/workflows/cicd.yml @@ -0,0 +1,61 @@ +name: Partition CI/CD + +on: + push: + branches: [ main, develop ] + +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build jar + run: ./gradlew clean build -x test + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/partition-spring:latest + + deploy: + needs: build-and-push + runs-on: ubuntu-latest + + steps: + - name: Deploy to EC2 via SSH + uses: appleboy/ssh-action@v1.1.0 + with: + host: ${{ secrets.EC2_HOST }} + username: ubuntu + key: ${{ secrets.EC2_PRIVATE_KEY }} + script: | + cd /home/ubuntu/partition + + # 지금 푸시된 브랜치 기준으로 체크아웃 (main or develop) + git fetch origin + git checkout ${{ github.ref_name }} + git pull origin ${{ github.ref_name }} + + # 최신 이미지 받아서 컨테이너 재시작 + sudo docker compose pull + sudo docker compose up -d \ No newline at end of file