Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
03ce5ba
feat: 일정 기록 기능 추가 및 캘린더 통합
wooxexn May 13, 2026
b759480
test: 일정 기록 서비스 테스트 추가
wooxexn May 14, 2026
73af109
refactor: CUSTOM 알림을 당일 시간 기반으로 변경 (notificationCustomDays 제거)
wooxexn May 16, 2026
0afb05f
test: 일정 알림 추가/삭제/변경 시나리오 테스트 추가
wooxexn May 16, 2026
3621088
feat: 일정 CUSTOM 알림에 분 단위 설정 추가 (notificationCustomMinutes)
wooxexn May 16, 2026
58c8007
refactor: 캘린더 조회에서 일정을 schedules 필드로 분리
wooxexn May 16, 2026
2fe8223
refactor: 일일 기록 조회에서 일정을 schedules 필드로 분리
wooxexn May 16, 2026
6806838
refactor: 일일 기록 개수 제한을 전체 합산 방식으로 변경
wooxexn May 16, 2026
22d129a
docs: ReDoc 문서화 업데이트
wooxexn May 16, 2026
b7ce400
fix: ErrorCode 기록 제한 메시지 통합
wooxexn May 16, 2026
b98e022
fix: 캘린더 조회 시 일정이 타입 필터와 무관하게 표시되도록 수정
wooxexn May 23, 2026
c47ba35
test: 캘린더 일정 조회 통합 테스트 추가
wooxexn May 23, 2026
499c12b
fix: 일정 변경 시 캘린더 캐시가 갱신되도록 수정
wooxexn May 24, 2026
29d5d6b
feat: 생성 제한 조회 API 및 일정 생성 제한 추가
wooxexn May 24, 2026
3853e28
refactor: 생성 제한 조회 API 응답 형식을 플랫하게 변경
wooxexn May 24, 2026
9cf845c
feat: 알림 설정 및 히스토리에 일정 알림 기능 추가
wooxexn May 30, 2026
3c75294
test: 일정 알림 설정 및 히스토리 기능 테스트 추가
wooxexn May 30, 2026
85a1daa
feat: 일정 알림 스케줄러 구현
wooxexn May 30, 2026
406281a
test: 일정 알림 스케줄러 테스트 추가
wooxexn May 30, 2026
f72e434
docs: 일정 생성/알림 API 문서 업데이트 및 OpenAPI 스키마 개선
wooxexn May 31, 2026
5f7a5b2
docs: 일정 API 에러 응답 문서 추가
wooxexn May 31, 2026
aa5da5b
fix: 로그인 시 탈퇴 사용자 자동 복구 로직 추가
wooxexn Jun 1, 2026
5820d90
refactor: 캘린더 일정 표시 필드 개선 (size → extraScheduleCount)
wooxexn Jun 3, 2026
6d53bc0
fix: 반복 일정이 정확한 날짜에 표시되도록 수정
wooxexn Jun 3, 2026
7d52cab
fix: 일일 기록 조회 시 반복 일정 포함하도록 수정
wooxexn Jun 3, 2026
bb7c554
fix: 반복 일정 알림이 반복될 때마다 발송되도록 수정
wooxexn Jun 3, 2026
a2408df
docs: 반복 일정 및 일정 표시 필드 변경사항 문서 업데이트
wooxexn Jun 3, 2026
0933b0c
perf: 반복 일정 조회 쿼리 최적화 (DB 레벨 필터링)
wooxexn Jun 9, 2026
43b42fb
refactor: Repository 레이어 트랜잭션 관리 제거
wooxexn Jun 9, 2026
2629e9f
perf: 목표 진행률 계산 쿼리 최적화 (N번 → 1번)
wooxexn Jun 9, 2026
95ff19a
refactor: 매직 넘버 및 문자열 상수화 (가독성 개선)
wooxexn Jun 9, 2026
a217e0d
perf: 데이터베이스 인덱스 추가 (쿼리 성능 개선)
wooxexn Jun 9, 2026
9d93e58
perf: 운동 목표 진행률 계산 쿼리 최적화 (N번 → 1번)
wooxexn Jun 9, 2026
7f08b39
perf: 습관 목표 진행률 계산 쿼리 최적화 (N번 → 1번)
wooxexn Jun 9, 2026
1da45d7
refactor: Exercise/Habit 서비스 매직 넘버 상수화
wooxexn Jun 9, 2026
77368c0
refactor: Collectors.toList()를 Stream.toList()로 개선
wooxexn Jun 9, 2026
acc803d
refactor: Repository 레이어 Stream.toList() 적용
wooxexn Jun 9, 2026
1cd997a
perf: 운동 기록 개수 조회 쿼리 최적화
wooxexn Jun 9, 2026
f158057
perf: 습관 기록 존재 여부 확인 쿼리 최적화
wooxexn Jun 9, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -88,19 +88,20 @@ public AuthApplicationService(SocialLoginService socialLoginService,
public SocialLoginResult socialLogin(SocialLoginCommand command) {
SocialUserInfo socialUserInfo = socialLoginService.getUserInfo(command.getSocialType(), command.getAccessToken());

// socialId로 기존 사용자 조회 (단순하고 안정적)
Optional<UserResponse> existingUser = userRegistrationService.findBySocialLogin(command.getSocialType(), socialUserInfo.getSocialId());
// 1. 탈퇴한 사용자 복구 시도 (7일 이내면 자동 복구)
Optional<UserResponse> restoredUser = userLifecycleService.restoreWithdrawnUser(
command.getSocialType(),
socialUserInfo.getSocialId()
);

UserResponse user;
boolean isNewUser = false;

if (existingUser.isPresent()) {
user = existingUser.get();

// 탈퇴 사용자 복구는 UserLifecycleService에서 처리됨 (이미 위에서 처리됨)
log.info("기존 사용자 로그인: userId={}, socialType={}", user.getId(), command.getSocialType());
if (restoredUser.isPresent()) {
user = restoredUser.get();
log.info("사용자 로그인 (탈퇴 복구 포함): userId={}, socialType={}", user.getId(), command.getSocialType());
} else {
// 신규 사용자 가입
// 2. 복구할 사용자가 없으면 신규 가입
user = createNewUser(socialUserInfo, command.getSocialType());
isNewUser = true;
log.info("신규 사용자 가입: userId={}, socialType={}", user.getId(), command.getSocialType());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,54 +35,75 @@ public CalendarController(RecordApplicationService recordApplicationService) {

@Operation(summary = "캘린더 조회", description = """
월별 기록 현황을 조회합니다. 타입별 필터링이 가능합니다.

### 캘린더 표시 로직 (v1.8.6) - 프론트 분기처리 최적화

**과거 날짜**: 작성된 기록만 표시, 미작성은 빈 배열
- 작성된 기록: `records: [실제기록객체]` → 색상 아이콘
- 미작성 기록: `records: []` → 프론트에서 `length === 0 && 과거날짜`로 회색 아이콘 처리
- 장점: 플레이스홀더 객체 없어서 타입 분기처리 불필요

**현재 날짜 (오늘)**: 작성된 기록만 표시, 미작성은 빈 배열
- 작성된 기록: `records: [실제기록객체]` → 색상/회색 아이콘
- 미작성 기록: `records: []` → 프론트에서 `length === 0 && 현재날짜`로 빈 공간 처리
- 습관: 3단계 시스템 (미작성=빈공간, 작성=isCompleted:false, 완료=isCompleted:true)
- 홈 화면: `records.length >= 2`로 간단한 작성 제한 체크

**미래 날짜**: 완전 빈 배열
- `records: []` → 프론트에서 `length === 0 && 미래날짜`로 빈 공간 처리
- DB에 미래 자동생성 기록이 있어도 API 응답에서 제외

### 메인 기록 타입 표시 (v1.9.0)
- 각 날짜에 `mainRecordTypeForDate` 필드가 추가되어 해당 날짜의 메인 기록 타입을 표시합니다
- **목표 기간 내**: 해당 목표의 메인 기록 타입 반환 (HABIT/EXERCISE/DAILY)
- **목표 미설정 기간**: `null` 반환 (목표가 설정되지 않은 기간)
- 클라이언트는 이 값을 기준으로 캘린더 아이콘을 결정할 수 있습니다

### 습관 기록 특별 처리 (v1.9.1)
- **메인 습관 자동 생성**: 메인 습관 기록 생성 시 목표 종료일까지 DB에 자동 생성
- **오늘까지만 응답**: DB의 미래 기록은 API에서 제외 (실제 행동 기반)
- **메인 습관 표시 로직** (mainRecordTypeForDate = HABIT인 경우):
* 미작성 (자동생성 그대로): records 빈 배열
* 작성 (사용자 수정): isCompleted=false로 표시
* 작성 (사용자 수정): isCompleted=false로 표시
* 완료: isCompleted=true로 표시
- **다른 타입 사용자**: 모든 습관 기록 표시 (서브 기록으로)

### isCompleted 필드 상태
- **습관 기록**: 실제 완료 체크 여부 (true=완료, false=미완료, null=미작성)
- **일상/운동 기록**: 기록 존재 자체가 완료 (항상 true)

### 사용자 타입별 특징
**습관 타입 사용자 (HABIT)**:
- 과거: 모든 습관 기록 + 미작성 회색 표시
- 현재: 작성된 습관만 표시 (3단계: 빈공간→회색→색상)
- 미래: 표시하지 않음

**운동/일상 타입 사용자 (EXERCISE/DAILY)**:
- 과거: 모든 기록 + 미작성 회색 표시
- 과거: 모든 기록 + 미작성 회색 표시
- 현재: 작성된 기록만 표시
- 미래: 표시하지 않음
- 메인 기록 + 서브 습관 기록 조합 가능

### 일정 표시 로직 (v1.5.1)

**일정 요약 정보** (schedules 필드):
- **title**: 대표 일정명 (첫 번째 일정)
- **extraScheduleCount**: 추가 일정 개수 (표시되지 않은 일정 수)
* 일정 1개: `null` ("+N" 표시 안 함)
* 일정 2개: `1` ("+1" 표시)
* 일정 3개: `2` ("+2" 표시)
- **color**: 대표 일정 색상

**반복 일정 표시**:
- **NONE**: 반복 없음 (startDate ~ endDate 범위만 표시)
- **DAY**: 매일 반복 (startDate부터 매일 표시)
- **WEEK**: 매주 반복 (startDate와 같은 요일에만 표시)
- **MONTH**: 매월 반복 (startDate와 같은 날짜에만 표시, 31일 일정은 30일까지 있는 달 자동 스킵)
- **YEAR**: 매년 반복 (startDate와 같은 월-일에만 표시, 2월 29일 일정은 평년 자동 스킵)

**반복 종료일** (repeatEndsOn):
- 설정 시: 해당 날짜까지만 반복 (이후 캘린더에서 제외)
- 미설정 시: 계속 반복
""",
security = @SecurityRequirement(name = "bearerAuth"))
@io.swagger.v3.oas.annotations.responses.ApiResponse(
Expand Down Expand Up @@ -112,12 +133,18 @@ public CalendarController(RecordApplicationService recordApplicationService) {
"type": "HABIT",
"isCompleted": true
}
]
],
"schedules": {
"title": "회의",
"extraScheduleCount": 2,
"color": "BLUE"
}
},
{
"date": "2025-11-18",
"mainRecordTypeForDate": "HABIT",
"records": []
"records": [],
"schedules": null
},
{
"date": "2025-11-19",
Expand All @@ -128,12 +155,18 @@ public CalendarController(RecordApplicationService recordApplicationService) {
"type": "HABIT",
"isCompleted": false
}
]
],
"schedules": {
"title": "점심 약속",
"extraScheduleCount": null,
"color": "PINK"
}
},
{
"date": "2025-11-20",
"mainRecordTypeForDate": "HABIT",
"records": []
"records": [],
"schedules": null
}
]
}
Expand Down Expand Up @@ -166,12 +199,18 @@ public CalendarController(RecordApplicationService recordApplicationService) {
"type": "HABIT",
"isCompleted": true
}
]
],
"schedules": null
},
{
"date": "2025-11-18",
"mainRecordTypeForDate": "EXERCISE",
"records": []
"records": [],
"schedules": {
"title": "병원 예약",
"extraScheduleCount": 1,
"color": "GREEN"
}
},
{
"date": "2025-11-19",
Expand All @@ -182,12 +221,14 @@ public CalendarController(RecordApplicationService recordApplicationService) {
"type": "HABIT",
"isCompleted": false
}
]
],
"schedules": null
},
{
"date": "2025-11-20",
"mainRecordTypeForDate": "EXERCISE",
"records": []
"records": [],
"schedules": null
}
]
}
Expand Down Expand Up @@ -215,12 +256,18 @@ public CalendarController(RecordApplicationService recordApplicationService) {
"type": "DAILY",
"isCompleted": true
}
]
],
"schedules": null
},
{
"date": "2025-10-23",
"mainRecordTypeForDate": null,
"records": []
"records": [],
"schedules": {
"title": "여행",
"extraScheduleCount": 4,
"color": "ORANGE"
}
},
{
"date": "2025-11-08",
Expand All @@ -231,7 +278,8 @@ public CalendarController(RecordApplicationService recordApplicationService) {
"type": "HABIT",
"isCompleted": false
}
]
],
"schedules": null
}
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.recordmanagement.habitlog.global.config.exception.ErrorCode;
import com.recordmanagement.habitlog.domain.exercise.domain.model.ExerciseRecord;
import com.recordmanagement.habitlog.domain.exercise.domain.model.ExerciseRecordId;
import com.recordmanagement.habitlog.domain.exercise.domain.repository.ExerciseRecordQueryRepository;
import com.recordmanagement.habitlog.domain.exercise.domain.repository.ExerciseRecordRepository;
import com.recordmanagement.habitlog.domain.record.domain.repository.RecordRepository;
import com.recordmanagement.habitlog.domain.habit.domain.repository.HabitRecordRepository;
Expand All @@ -21,15 +22,19 @@

import java.time.LocalDate;
import java.util.List;
import java.util.stream.Collectors;

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class ExerciseRecordApplicationService {


// 비즈니스 상수
private static final int MAX_DAILY_RECORDS = 2;
private static final int MAX_RECORD_TYPES_PER_DAY = 2;

private final ExerciseRecordRepository exerciseRecordRepository;
private final ExerciseRecordQueryRepository exerciseRecordQueryRepository;
private final RecordRepository recordRepository;
private final HabitRecordRepository habitRecordRepository;
private final S3FileService s3FileService;
Expand All @@ -38,20 +43,22 @@ public class ExerciseRecordApplicationService {

@CacheEvict(value = "calendar", allEntries = true)
public ExerciseRecordResponse createExerciseRecord(CreateExerciseRecordCommand command) {
log.info("운동기록 생성 시작: userId=[{}], exerciseType=[{}], recordDate=[{}]",
log.info("운동기록 생성 시작: userId=[{}], exerciseType=[{}], recordDate=[{}]",
command.userId().getValue(), command.exerciseType(), command.recordDate());

// 하루 최대 2개 운동기록 제한 검증

// 하루 최대 2개 기록 제한 검증 (전체 타입 합쳐서)
int totalRecordCount = getTotalRecordCount(command.userId(), command.recordDate());

if (totalRecordCount >= MAX_DAILY_RECORDS) {
throw new CustomException(ErrorCode.EXERCISE_RECORD_LIMIT_EXCEEDED);
}

// 전체 기록 종류 최대 2가지 제한 검증
int exerciseRecordCount = exerciseRecordRepository.countByUserIdAndRecordDate(
command.userId(),
command.userId(),
command.recordDate()
);

if (exerciseRecordCount >= 2) {
throw new CustomException(ErrorCode.EXERCISE_RECORD_LIMIT_EXCEEDED);
}

// 전체 기록 종류 최대 2가지 제한 검증 (운동기록이 없는 경우에만)

if (exerciseRecordCount == 0) {
validateRecordTypeLimit(command.userId(), command.recordDate());
}
Expand Down Expand Up @@ -137,11 +144,11 @@ public DailyExerciseRecordResponse getDailyExerciseRecords(String userIdValue, L
List<ExerciseRecordResponse> responseList = exerciseRecords.stream()
.map(this::toResponse)
.map(this::updateImageUrls)
.collect(Collectors.toList());
log.info("일일 운동기록 조회 완료: userId=[{}], date=[{}], count=[{}]",
.toList();

log.info("일일 운동기록 조회 완료: userId=[{}], date=[{}], count=[{}]",
userIdValue, date, responseList.size());

return new DailyExerciseRecordResponse(date, responseList);
}

Expand All @@ -157,11 +164,11 @@ public List<ExerciseRecordResponse> getExerciseRecordsBetween(String userIdValue
List<ExerciseRecordResponse> responseList = exerciseRecords.stream()
.map(this::toResponse)
.map(this::updateImageUrls)
.collect(Collectors.toList());
log.info("기간별 운동기록 조회 완료: userId=[{}], startDate=[{}], endDate=[{}], count=[{}]",
.toList();

log.info("기간별 운동기록 조회 완료: userId=[{}], startDate=[{}], endDate=[{}], count=[{}]",
userIdValue, startDate, endDate, responseList.size());

return responseList;
}

Expand Down Expand Up @@ -214,6 +221,17 @@ private ExerciseRecordResponse updateImageUrls(ExerciseRecordResponse response)
return response.withUpdatedImageUrls(updatedUrls);
}

/**
* 하루 전체 기록 개수 조회 (DAILY + EXERCISE + HABIT 합계)
*/
private int getTotalRecordCount(UserId userId, LocalDate recordDate) {
int dailyCount = recordRepository.countByUserIdAndRecordDateAndType(userId, recordDate, RecordType.DAILY);
int exerciseCount = exerciseRecordRepository.countByUserIdAndRecordDate(userId, recordDate);
int habitCount = habitRecordRepository.countByUserIdAndRecordDate(userId, recordDate);

return dailyCount + exerciseCount + habitCount;
}

/**
* 하루에 등록할 수 있는 기록 종류가 최대 2가지인지 검증
*/
Expand All @@ -230,7 +248,7 @@ private void validateRecordTypeLimit(UserId userId, LocalDate recordDate) {
if (habitCount > 0) recordTypeCount++;

// 이미 2가지 기록 종류가 있다면 운동기록을 추가할 수 없음
if (recordTypeCount >= 2) {
if (recordTypeCount >= MAX_RECORD_TYPES_PER_DAY) {
throw new CustomException(ErrorCode.RECORD_TYPE_LIMIT_EXCEEDED);
}
}
Expand Down Expand Up @@ -280,24 +298,11 @@ private void updateGoalProgress(UserId userId) {
/**
* 운동 목표의 완료일수 계산
* 하루에 운동 기록이 하나라도 있으면 완료로 간주
* (성능 최적화: N번 쿼리 대신 1번 DISTINCT COUNT 쿼리)
*/
private int calculateCompletedDaysForExercise(UserId userId,
private int calculateCompletedDaysForExercise(UserId userId,
java.time.LocalDate startDate, java.time.LocalDate endDate) {
int completedDays = 0;
java.time.LocalDate currentDate = startDate;

while (!currentDate.isAfter(endDate)) {
// 해당 날짜에 운동 기록이 있는지 확인
var exerciseRecords = exerciseRecordRepository.findByUserIdAndRecordDate(userId, currentDate);
boolean hasRecord = !exerciseRecords.isEmpty();

if (hasRecord) {
completedDays++;
}

currentDate = currentDate.plusDays(1);
}

return completedDays;
return exerciseRecordQueryRepository.countDistinctRecordDatesByUserIdAndDateRange(
userId, startDate, endDate);
}
}
Loading
Loading