Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +24 to +25

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 프로젝트에 테스트 코드가 존재하는지 확인
fd -e java . -p 'src/test' | head -20

Repository: Partition-app/Backend

Length of output: 123


🏁 Script executed:

cat -n .github/workflows/cicd.yml

Repository: Partition-app/Backend

Length of output: 2007


🏁 Script executed:

fd -e java . -path '*/src/test/*' | wc -l

Repository: Partition-app/Backend

Length of output: 252


🏁 Script executed:

fd -e java . -path '*/src/test/*'

Repository: Partition-app/Backend

Length of output: 233


CI/CD 파이프라인에서 테스트가 완전히 건너뛰어지고 있습니다

-x test 플래그로 테스트가 제외되고 있으며, 워크플로우에 테스트를 실행하는 다른 단계가 없습니다. 테스트 코드가 존재함에도 불구하고 검증 없이 코드가 빌드되고 배포되고 있으므로, 별도의 테스트 단계를 추가하여 모든 테스트가 CI 단계에서 실행되도록 수정해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/cicd.yml around lines 24 - 25, The CI step named "Build
jar" currently skips tests by using the Gradle flag "./gradlew clean build -x
test"; remove the "-x test" and instead add a distinct workflow job or step that
runs Gradle tests (e.g., "./gradlew test" or "./gradlew check") before the build
step so all test suites execute in CI; update the "Build jar" step to run the
full build without excluding tests and ensure the new test step fails the
pipeline on test failures.


- 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
Comment on lines +33 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Docker 이미지에 버전 태그 추가 필요

latest 태그만 사용하면 롤백이 어렵고 어떤 커밋이 배포되었는지 추적할 수 없습니다. 커밋 SHA나 타임스탬프 기반 태그를 추가하는 것이 좋습니다.

♻️ 버전 태그 추가 제안
       - name: Build and push Docker image
         uses: docker/build-push-action@v6
         with:
           context: .
           push: true
-          tags: ${{ secrets.DOCKERHUB_USERNAME }}/partition-spring:latest
+          tags: |
+            ${{ secrets.DOCKERHUB_USERNAME }}/partition-spring:latest
+            ${{ secrets.DOCKERHUB_USERNAME }}/partition-spring:${{ github.sha }}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/cicd.yml around lines 33 - 38, The workflow currently
pushes the Docker image only with the static tag "latest" using
docker/build-push-action@v6; update the tags entry to include a versioned tag
(e.g., commit SHA or timestamp) in addition to latest so deployments are
traceable and rollbacks are possible. Modify the tags input for the build-push
action to emit at least two tags such as ${{ secrets.DOCKERHUB_USERNAME
}}/partition-spring:latest and ${{ secrets.DOCKERHUB_USERNAME
}}/partition-spring:${{ github.sha }} (or use ${{ github.run_number }} / a
timestamp) so each build is uniquely identifiable while preserving the latest
tag.


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
Original file line number Diff line number Diff line change
@@ -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, "카카오 로그인에 실패했습니다.");
Comment on lines +11 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: AuthErrorCode가 실제로 사용되는지 확인

# AuthErrorCode 사용처 검색
rg -n "AuthErrorCode\." --type java

Repository: Partition-app/Backend

Length of output: 47


🏁 Script executed:

#!/bin/bash
# Check if AuthErrorCode class exists and is defined
find . -name "AuthErrorCode.java" -type f

# Search for AuthErrorCode imports in Java files
rg "import.*AuthErrorCode" --type java

# Search for AuthErrorCode references (any form)
rg "AuthErrorCode" --type java

# Look for how CustomException is actually used
rg "CustomException" --type java -A 2 -B 2 | head -50

# Check AuthService for token-related error handling
find . -name "AuthService.java" -type f -exec cat {} \;

Repository: Partition-app/Backend

Length of output: 12985


🏁 Script executed:

#!/bin/bash
# Check JwtTokenProvider to see token validation handling
find . -name "JwtTokenProvider.java" -type f -exec cat {} \;

# Check if there are any exception handlers/controllers that might use AuthErrorCode
rg "UserErrorCode|ScheduleErrorCode" --type java -l | head -20

# Search for token validation in filter/interceptor
find . -name "*Filter.java" -o -name "*Interceptor.java" | xargs cat 2>/dev/null | head -100

Repository: Partition-app/Backend

Length of output: 6177


정의된 에러 코드가 실제로 사용되지 않고 있습니다.

코드 전체를 검토한 결과, AuthErrorCode가 정의되었지만 어디에서도 import되거나 참조되지 않습니다. AuthService.kakaoLogin()JwtTokenProvider.validateToken()에서는 IllegalArgumentException, RuntimeException 등 일반적인 예외를 직접 throw하거나 로그만 남기고 있으며, CustomException과 함께 AuthErrorCode를 사용하지 않습니다.

반면 UserService, ScheduleService 등 다른 서비스들은 일관되게 CustomException(ErrorCode) 패턴을 따르고 있습니다. 토큰 검증 실패 또는 카카오 로그인 실패 시 정의된 AuthErrorCode를 활용하여 일관된 에러 응답 처리를 구현하시기 바랍니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/com/partition/domain/auth/exception/AuthErrorCode.java` around
lines 11 - 18, AuthErrorCode enum is defined but unused; update
AuthService.kakaoLogin() and JwtTokenProvider.validateToken() to throw
CustomException with the appropriate AuthErrorCode values instead of raw
IllegalArgumentException/RuntimeException or only logging; import AuthErrorCode
and replace each generic throw/log path with new
CustomException(AuthErrorCode.EMPTY_ACCESS_TOKEN / INVALID_ACCESS_TOKEN /
EXPIRED_ACCESS_TOKEN / EMPTY_REFRESH_TOKEN / INVALID_REFRESH_TOKEN /
EXPIRED_REFRESH_TOKEN / KAKAO_LOGIN_FAILED) as applicable so token validation
and Kakao-login failures follow the same CustomException(ErrorCode) pattern used
by UserService and ScheduleService.


private final int status;
private final String message;
}
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<List<CalendarMonthlyResponse>>> getMonthlyCalendar(
@AuthenticationPrincipal CustomUserDetails userDetails,
@RequestParam int year,
@RequestParam int month) {

Long userId = Long.parseLong(userDetails.getUsername());
List<CalendarMonthlyResponse> result = calendarService.getMonthlyCalendar(userId, year, month);

return ResponseEntity.ok(
ApiResponse.onSuccess("200", "월간 캘린더 조회 성공", result)
);
}

@Operation(summary = "일간 상세 조회", description = "특정 날짜의 상세 일정(집안일, 일정) 목록을 반환합니다.")
@GetMapping("/daily")
public ResponseEntity<ApiResponse<List<CalendarDailyResponse>>> getDailyCalendar(
@AuthenticationPrincipal CustomUserDetails userDetails,
@RequestParam LocalDate date) { // yyyy-MM-dd 형식 자동 매핑

Long userId = Long.parseLong(userDetails.getUsername());
List<CalendarDailyResponse> result = calendarService.getDailyCalendar(userId, date);

return ResponseEntity.ok(
ApiResponse.onSuccess("200", "일간 상세 조회 성공", result)
);
}
}
Original file line number Diff line number Diff line change
@@ -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)

}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<CalendarMonthlyResponse> 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<Schedule> schedules = scheduleRepository.findAllByHouseholdIdAndDateRange(householdId, startDate, endDate);
List<Chore> chores = choreRepository.findAllByHouseholdIdAndDateRange(householdId, startDate, endDate);
List<UtilityBill> bills = utilityBillRepository.findAllByHouseholdIdAndDueDateBetween(householdId, startDate, endDate);

Map<LocalDate, MonthlyCounts> 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<CalendarDailyResponse> 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<CalendarDailyResponse> 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<CalendarDailyResponse> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<String>> 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);
Comment on lines +35 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

배정 기간 상한이 없어 한 번의 호출이 너무 커질 수 있습니다.

Line 36-43은 역순 날짜만 막고 기간 길이 자체는 제한하지 않습니다. 지금 구현은 곧바로 FastAPI 호출과 결과 저장으로 이어지기 때문에, 수개월/수년 범위 요청 하나가 외부 서비스 부하와 대량 쓰기를 동시에 만들 수 있습니다. 최대 허용 기간을 명시적으로 제한해 두는 편이 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/com/partition/domain/chore/controller/ChoreController.java`
around lines 35 - 43, Validate and cap the requested periodDays in
ChoreController before calling assignmentService.assignChores: compute
periodDays from startDate and endDate, check that it is between 1 and a defined
MAX_PERIOD_DAYS (introduce a constant like MAX_PERIOD_DAYS), and if it exceeds
the max throw an IllegalArgumentException or return a 400-level error with a
clear message; do this validation right after computing periodDays and before
invoking assignmentService.assignChores so overly large ranges are
rejected/limited early.


return ResponseEntity.ok(
ApiResponse.onSuccess("200", "집안일 배정이 완료되었습니다.", "Success")
);
}
}
Loading