diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..a3497ae2 Binary files /dev/null and b/.DS_Store differ diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index e03a2fdf..0ad88e12 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -1,118 +1,119 @@ -#name: Auto Label -# -#on: -# issues: -# types: [opened, edited] -# pull_request_target: -# types: [opened, edited, reopened, synchronize] -# -#permissions: -# issues: write -# -#jobs: -# label: -# runs-on: ubuntu-latest -# steps: -# - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b -# with: -# script: | -# const isIssue = context.eventName === 'issues'; -# const item = isIssue -# ? context.payload.issue -# : context.payload.pull_request; -# -# const title = item.title.toLowerCase(); -# const body = (item.body || '').toLowerCase(); -# const author = item.user.login; -# -# const labels = new Set(); -# -# const titleRules = [ -# { -# label: '✨ feat', -# patterns: [/\[feat\]/, /(?:^|[^a-z0-9])feat:/] -# }, -# { -# label: '🛠️ fix', -# patterns: [/\[fix\]/, /(?:^|[^a-z0-9])fix:/] -# }, -# { -# label: '🚨 hotfix', -# patterns: [/\[hotfix\]/, /(?:^|[^a-z0-9])hotfix:/] -# }, -# { -# label: '📃 docs', -# patterns: [/\[docs\]/, /(?:^|[^a-z0-9])docs:/] -# }, -# { -# label: '🔁 refactor', -# patterns: [/\[refactor\]/, /(?:^|[^a-z0-9])refactor:/] -# }, -# { -# label: '📝 chore', -# patterns: [/\[chore\]/, /(?:^|[^a-z0-9])chore:/] -# }, -# { -# label: '🧪 test', -# patterns: [/\[test\]/, /(?:^|[^a-z0-9])test:/] -# }, -# { -# label: '✏️ style', -# patterns: [/\[style\]/, /(?:^|[^a-z0-9])style:/] -# }, -# { -# label: '✒️ comment', -# patterns: [/\[comment\]/, /(?:^|[^a-z0-9])comment:/] -# }, -# { -# label: '📄 rename', -# patterns: [/\[rename\]/, /(?:^|[^a-z0-9])rename:/] -# }, -# { -# label: '♻️ remove', -# patterns: [/\[remove\]/, /(?:^|[^a-z0-9])remove:/] -# } -# ]; -# -# for (const rule of titleRules) { -# if (rule.patterns.some(pattern => pattern.test(title))) { -# labels.add(rule.label); -# } -# } -# -# const authorLabels = { -# 'Jy000n': '🌸 자윤', -# 'aneykrap': '🌵 예나', -# 'laura-jung': '🍀️ 윤아' -# }; -# -# const managedLabels = new Set([ -# ...titleRules.map(rule => rule.label), -# ...Object.values(authorLabels) -# ]); -# -# if (authorLabels[author]) { -# labels.add(authorLabels[author]); -# } -# -# const currentLabels = (item.labels || []).map(label => label.name || label); -# const labelsToRemove = currentLabels.filter(label => managedLabels.has(label)); -# -# await Promise.all(labelsToRemove.map(label => github.rest.issues.removeLabel({ -# owner: context.repo.owner, -# repo: context.repo.repo, -# issue_number: item.number, -# name: label -# }).catch(error => { -# if (error.status !== 404) { -# throw error; -# } -# }))); -# -# if (labels.size > 0) { -# await github.rest.issues.addLabels({ -# owner: context.repo.owner, -# repo: context.repo.repo, -# issue_number: item.number, -# labels: [...labels] -# }); \ No newline at end of file +name: Auto Label + +on: + issues: + types: [opened, edited] + pull_request_target: + types: [opened, edited, reopened, synchronize] + +permissions: + issues: write + pull-requests: write + +jobs: + label: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v8 + with: + script: | + const isIssue = context.eventName === 'issues'; + const item = isIssue + ? context.payload.issue + : context.payload.pull_request; + + const title = item.title.toLowerCase(); + const author = item.user.login; + + const labels = new Set(); + + const titleRules = [ + { + label: '✨ feat', + patterns: [/\[feat\]/, /(?:^|[^a-z0-9])feat:/] + }, + { + label: '🛠️ fix', + patterns: [/\[fix\]/, /(?:^|[^a-z0-9])fix:/] + }, + { + label: '🚨 hotfix', + patterns: [/\[hotfix\]/, /(?:^|[^a-z0-9])hotfix:/] + }, + { + label: '📃 docs', + patterns: [/\[docs\]/, /(?:^|[^a-z0-9])docs:/] + }, + { + label: '🔁 refactor', + patterns: [/\[refactor\]/, /(?:^|[^a-z0-9])refactor:/] + }, + { + label: '📝 chore', + patterns: [/\[chore\]/, /(?:^|[^a-z0-9])chore:/] + }, + { + label: '🧪 test', + patterns: [/\[test\]/, /(?:^|[^a-z0-9])test:/] + }, + { + label: '✏️ style', + patterns: [/\[style\]/, /(?:^|[^a-z0-9])style:/] + }, + { + label: '✒️ comment', + patterns: [/\[comment\]/, /(?:^|[^a-z0-9])comment:/] + }, + { + label: '📄 rename', + patterns: [/\[rename\]/, /(?:^|[^a-z0-9])rename:/] + }, + { + label: '♻️ remove', + patterns: [/\[remove\]/, /(?:^|[^a-z0-9])remove:/] + } + ]; + + for (const rule of titleRules) { + if (rule.patterns.some(pattern => pattern.test(title))) { + labels.add(rule.label); + } + } + + const authorLabels = { + 'Jy000n': '🌸 자윤', + 'aneykrap': '🌵 예나', + 'laura-jung': '🍀️ 윤아' + }; + + const managedLabels = new Set([ + ...titleRules.map(rule => rule.label), + ...Object.values(authorLabels) + ]); + + if (authorLabels[author]) { + labels.add(authorLabels[author]); + } + + const currentLabels = (item.labels || []).map(label => label.name || label); + const labelsToRemove = currentLabels.filter(label => managedLabels.has(label)); + + await Promise.all(labelsToRemove.map(label => github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: item.number, + name: label + }).catch(error => { + if (error.status !== 404) { + throw error; + } + }))); + + if (labels.size > 0) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: item.number, + labels: [...labels] + }); + } diff --git a/db/migration/2026-07-09__tags_add_owner_and_default.sql b/db/migration/2026-07-09__tags_add_owner_and_default.sql new file mode 100644 index 00000000..0e5d6833 --- /dev/null +++ b/db/migration/2026-07-09__tags_add_owner_and_default.sql @@ -0,0 +1,9 @@ +-- 태그 소유자(user_id)와 기본 태그 여부(is_default) 컬럼 추가. +-- 운영은 ddl-auto: validate 이므로 배포 전에 반드시 먼저 실행해야 한다. + +-- 모든 사용자가 공유하는 기본 태그. 소유자가 없으므로 user_id는 NULL이다. +INSERT INTO tags (user_id, name, is_default) +VALUES (NULL, '일상', b'1'), + (NULL, '운동', b'1'), + (NULL, '업무', b'1'), + (NULL, '과제', b'1'); diff --git a/src/main/java/com/Timo/Timo/TimoApplication.java b/src/main/java/com/Timo/Timo/TimoApplication.java index 2093fce2..f339dd26 100644 --- a/src/main/java/com/Timo/Timo/TimoApplication.java +++ b/src/main/java/com/Timo/Timo/TimoApplication.java @@ -1,5 +1,7 @@ package com.Timo.Timo; +import java.util.TimeZone; + import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @@ -9,6 +11,7 @@ public class TimoApplication { public static void main(String[] args) { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); SpringApplication.run(TimoApplication.class, args); } diff --git a/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java b/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java new file mode 100644 index 00000000..6d5c84fe --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java @@ -0,0 +1,52 @@ +package com.Timo.Timo.domain.ai.controller; + +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 com.Timo.Timo.domain.ai.docs.AiTodoDocs; +import com.Timo.Timo.domain.ai.dto.request.RecommendDurationRequest; +import com.Timo.Timo.domain.ai.dto.response.RecommendDurationResponse; +import com.Timo.Timo.domain.ai.exception.AiSuccessCode; +import com.Timo.Timo.domain.ai.service.AiTodoService; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.response.BaseResponse; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestController +@RequestMapping("/api/v1/ai") +@RequiredArgsConstructor +public class AiTodoController implements AiTodoDocs { + + private final AiTodoService aiTodoService; + + @Override + @PostMapping("/duration") + public ResponseEntity> recommendDuration( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody RecommendDurationRequest request + ) { + log.info( + "AI duration recommendation API called. userId={}, tagId={}", + userDetails.getUserId(), + request.tagId() + ); + + RecommendDurationResponse response = aiTodoService.recommendDuration( + userDetails.getUserId(), + request + ); + + return ResponseEntity.ok( + BaseResponse.onSuccess(AiSuccessCode.DURATION_RECOMMENDED, response) + ); + } + +} diff --git a/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java b/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java new file mode 100644 index 00000000..330d496b --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java @@ -0,0 +1,83 @@ +package com.Timo.Timo.domain.ai.docs; + +import org.springframework.http.ResponseEntity; + +import com.Timo.Timo.domain.ai.dto.request.RecommendDurationRequest; +import com.Timo.Timo.domain.ai.dto.response.RecommendDurationResponse; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.exception.dto.ErrorDto; +import com.Timo.Timo.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +public interface AiTodoDocs { + + @Operation( + tags = "AI", + summary = "AI 예상 소요 시간 추천", + description = """ + 투두명과 태그를 기준으로 예상 소요 시간을 추천합니다. + + 서버는 사용자의 타이머 기반 실제 소요시간 기록을 조회해 Gemini에 전달합니다. + 1. 현재 투두명과 비슷한 과거 투두의 실제 소요시간 기록 + 2. 사용자가 지정한 태그의 최근 실제 소요시간 기록 + 3. 기록이 없으면 현재 투두명 기준 + + Gemini는 위 기록을 종합해 예상 소요 시간을 생성합니다. + RPM, RPD, TPM 제한을 초과하면 Gemini 호출 전 429 응답을 반환합니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "AI 예상 소요 시간 추천 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = "투두명이 누락되었거나 형식이 올바르지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "429", + description = "AI 추천 요청 횟수 제한을 초과한 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> recommendDuration( + @Parameter(hidden = true) CustomUserDetails userDetails, + @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + description = "예상 소요 시간을 추천받을 투두 정보", + content = @Content(schema = @Schema(implementation = RecommendDurationRequest.class)) + ) + RecommendDurationRequest request + ); +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/dto/TodoDurationHistory.java b/src/main/java/com/Timo/Timo/domain/ai/dto/TodoDurationHistory.java new file mode 100644 index 00000000..aaa91337 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/dto/TodoDurationHistory.java @@ -0,0 +1,9 @@ +package com.Timo.Timo.domain.ai.dto; + +import java.time.LocalDate; + +public record TodoDurationHistory( + String title, + Integer actualSeconds, + LocalDate date +) {} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/dto/TodoFeedbackSource.java b/src/main/java/com/Timo/Timo/domain/ai/dto/TodoFeedbackSource.java new file mode 100644 index 00000000..a1b99ef6 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/dto/TodoFeedbackSource.java @@ -0,0 +1,9 @@ +package com.Timo.Timo.domain.ai.dto; + +public record TodoFeedbackSource( + String title, + Long tagId, + String tagName, + Integer estimatedSeconds, + Integer actualSeconds +) {} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/dto/request/RecommendDurationRequest.java b/src/main/java/com/Timo/Timo/domain/ai/dto/request/RecommendDurationRequest.java new file mode 100644 index 00000000..a0cb3347 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/dto/request/RecommendDurationRequest.java @@ -0,0 +1,15 @@ +package com.Timo.Timo.domain.ai.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record RecommendDurationRequest( + @NotBlank + @Size(max = 30) + @Schema(description = "추천받을 투두명", example = "알고리즘 문제 풀기") + String title, + + @Schema(description = "투두 태그 ID", example = "1", nullable = true) + Long tagId +) {} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/dto/response/GeminiDurationRecommendation.java b/src/main/java/com/Timo/Timo/domain/ai/dto/response/GeminiDurationRecommendation.java new file mode 100644 index 00000000..e57732e0 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/dto/response/GeminiDurationRecommendation.java @@ -0,0 +1,4 @@ +package com.Timo.Timo.domain.ai.dto.response; + +public record GeminiDurationRecommendation(Integer recommendedMinutes) { +} diff --git a/src/main/java/com/Timo/Timo/domain/ai/dto/response/GeminiTodoFeedback.java b/src/main/java/com/Timo/Timo/domain/ai/dto/response/GeminiTodoFeedback.java new file mode 100644 index 00000000..3dfe7100 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/dto/response/GeminiTodoFeedback.java @@ -0,0 +1,3 @@ +package com.Timo.Timo.domain.ai.dto.response; + +public record GeminiTodoFeedback(String feedback) {} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/dto/response/RecommendDurationResponse.java b/src/main/java/com/Timo/Timo/domain/ai/dto/response/RecommendDurationResponse.java new file mode 100644 index 00000000..fdd45707 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/dto/response/RecommendDurationResponse.java @@ -0,0 +1,8 @@ +package com.Timo.Timo.domain.ai.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record RecommendDurationResponse( + @Schema(description = "추천 예상 소요 시간(분)", example = "45") + Integer recommendedMinutes +) {} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/exception/AiErrorCode.java b/src/main/java/com/Timo/Timo/domain/ai/exception/AiErrorCode.java new file mode 100644 index 00000000..982cc1f6 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/exception/AiErrorCode.java @@ -0,0 +1,22 @@ +package com.Timo.Timo.domain.ai.exception; + +import org.springframework.http.HttpStatus; + +import com.Timo.Timo.global.exception.code.BaseErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum AiErrorCode implements BaseErrorCode { + + AI_RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "AI_429", "AI 추천 요청이 많습니다. 잠시 후 다시 시도해주세요."), + AI_CONFIG_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, "AI_500", "AI 설정 정보가 올바르지 않습니다."), + AI_RESPONSE_PARSE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AI_500", "AI 응답을 처리하는 중 오류가 발생했습니다."), + AI_INVALID_RESPONSE(HttpStatus.INTERNAL_SERVER_ERROR, "AI_500", "AI 응답 형식이 올바르지 않습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/exception/AiSuccessCode.java b/src/main/java/com/Timo/Timo/domain/ai/exception/AiSuccessCode.java new file mode 100644 index 00000000..0be33414 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/exception/AiSuccessCode.java @@ -0,0 +1,18 @@ +package com.Timo.Timo.domain.ai.exception; + +import org.springframework.http.HttpStatus; + +import com.Timo.Timo.global.exception.code.BaseSuccessCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum AiSuccessCode implements BaseSuccessCode { + + DURATION_RECOMMENDED(HttpStatus.OK, "AI 예상 소요 시간을 추천했습니다."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java b/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java new file mode 100644 index 00000000..b1a5fcba --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java @@ -0,0 +1,92 @@ +package com.Timo.Timo.domain.ai.prompt; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.Timo.Timo.domain.ai.dto.TodoDurationHistory; +import com.Timo.Timo.domain.ai.dto.request.RecommendDurationRequest; + +@Component +public class TodoDurationPromptBuilder { + + public String build( + RecommendDurationRequest request, + List similarTitleHistories, + List recentTagHistories + ) { + return """ + 너는 Timo 투두 앱에서 사용자의 실제 작업 시간을 분석해 예상 소요 시간을 추천하는 시간 계획 코치야. + 사용자가 입력한 투두명을 먼저 이해하고, 과거 타이머 기록의 실제 소요시간 패턴을 참고해 현실적인 예상 소요 시간을 제안해. + + 판단 방식: + - 먼저 현재 투두명과 비슷한 과거 투두의 실제 소요시간을 확인해. + - 그다음 사용자가 지정한 태그에서 최근 실제 소요시간 경향을 참고해. + - 비슷한 투두명 기록과 태그 기록이 모두 있으면 둘을 함께 보고, 비슷한 투두명 기록을 조금 더 중요하게 봐. + - 기록이 아예 없으면 현재 투두명만 기준으로 일반적인 예상 소요 시간을 판단해. + + 규칙: + - 응답은 반드시 JSON 객체 하나만 반환해. + - recommendedMinutes는 분 단위 정수로 반환해. + - 실제 기록에 없는 패턴은 만들지 마. + + 반환해야 할 응답 JSON 형식: + { + "recommendedMinutes": 45 + } + + 아래 데이터는 응답에 포함할 값이 아니라 추천 계산에만 참고할 입력 데이터야. + + 입력 데이터 - 현재 투두: + { + "title": "%s" + } + + 입력 데이터 - 비슷한 투두명 실제 소요시간 기록: + %s + + 입력 데이터 - 사용자가 지정한 태그의 최근 실제 소요시간 기록: + %s + """.formatted( + escapeJsonString(request.title()), + formatHistories(similarTitleHistories), + formatHistories(recentTagHistories) + ); + } + + private String formatHistories(List histories) { + if (histories == null || histories.isEmpty()) { + return "[]"; + } + + return histories.stream() + .map(history -> """ + {"title":"%s","date":"%s","actualMinutes":%d} + """.formatted( + escapeJsonString(history.title()), + history.date(), + toMinutes(history.actualSeconds()) + ).trim()) + .toList() + .toString(); + } + + private int toMinutes(Integer durationSeconds) { + if (durationSeconds == null || durationSeconds <= 0) { + return 0; + } + return Math.max(1, (int)Math.round(durationSeconds / 60.0)); + } + + private String escapeJsonString(String value) { + if (value == null) { + return ""; + } + return value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", " ") + .replace("\r", " ") + .replace("\t", " "); + } +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoFeedbackPromptBuilder.java b/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoFeedbackPromptBuilder.java new file mode 100644 index 00000000..19dcdbdb --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoFeedbackPromptBuilder.java @@ -0,0 +1,106 @@ +package com.Timo.Timo.domain.ai.prompt; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.Timo.Timo.domain.ai.dto.TodoDurationHistory; +import com.Timo.Timo.domain.ai.dto.TodoFeedbackSource; + +@Component +public class TodoFeedbackPromptBuilder { + + public String build( + TodoFeedbackSource source, + List similarTitleHistories, + List recentTagHistories + ) { + return """ + 너는 Timo 투두 앱에서 사용자의 실제 작업 시간을 분석해 짧은 피드백을 작성하는 시간 계획 코치야. + 이번 태스크의 예상 소요 시간과 실제 소요 시간을 비교하고, 과거 기록을 참고해 다음 계획을 제안해. + + 피드백 판단 구조: + 1. 현재 결과 관찰 + - 이번 태스크가 예상 시간보다 길어졌는지, 거의 맞았는지, 일찍 끝났는지 짧게 분석해. + 2. 패턴 해석 + - 패턴 근거는 아래 우선순위로 선택해. + - 1순위: 비슷한 투두명 실제 소요시간 기록 + - 2순위: 같은 태그의 최근 실제 소요시간 기록 + - 3순위: 기록이 없으면 이번 태스크의 연장 또는 조기 종료 여부 + 3. 다음 행동 추천 + - 다음에 예상 시간을 어떻게 잡으면 좋을지 제안해. + + 규칙: + - 응답은 반드시 JSON 객체 하나만 반환해. + - feedback은 한국어 1~2문장으로 자연스럽게 작성해. + - feedback은 현재 결과 관찰, 패턴 해석, 다음 행동 추천을 압축해서 포함해. + - 실제 기록에 없는 패턴은 만들지 마. + - 기록이 부족하면 부족하다고 길게 말하지 말고, 이번 결과 기준으로만 제안해. + - 다음 예상 시간은 분 단위로 제안해. + + 반환해야 할 응답 JSON 형식: + { + "feedback": "이번 작업은 예상보다 조금 길어졌어요. 비슷한 Work 태그의 작업들도 살짝 길어지는 편이라, 다음에는 60분 정도로 잡아보면 좋아요." + } + + 아래 데이터는 응답에 포함할 값이 아니라 피드백 생성에만 참고할 입력 데이터야. + + 입력 데이터 - 이번 태스크: + { + "title": "%s", + "tagName": "%s", + "estimatedMinutes": %d, + "actualMinutes": %d + } + + 입력 데이터 - 비슷한 투두명 실제 소요시간 기록: + %s + + 입력 데이터 - 같은 태그의 최근 실제 소요시간 기록: + %s + """.formatted( + escapeJsonString(source.title()), + escapeJsonString(source.tagName()), + toMinutes(source.estimatedSeconds()), + toMinutes(source.actualSeconds()), + formatHistories(similarTitleHistories), + formatHistories(recentTagHistories) + ); + } + + private String formatHistories(List histories) { + if (histories == null || histories.isEmpty()) { + return "[]"; + } + + return histories.stream() + .map(history -> """ + {"title":"%s","date":"%s","actualMinutes":%d} + """.formatted( + escapeJsonString(history.title()), + history.date(), + toMinutes(history.actualSeconds()) + ).trim()) + .toList() + .toString(); + } + + private int toMinutes(Integer durationSeconds) { + if (durationSeconds == null || durationSeconds <= 0) { + return 0; + } + return Math.max(1, (int)Math.round(durationSeconds / 60.0)); + } + + private String escapeJsonString(String value) { + if (value == null) { + return ""; + } + return value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", " ") + .replace("\r", " ") + .replace("\t", " "); + } +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java b/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java new file mode 100644 index 00000000..adf319dc --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java @@ -0,0 +1,161 @@ +package com.Timo.Timo.domain.ai.repository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.Timo.Timo.domain.ai.dto.TodoDurationHistory; +import com.Timo.Timo.domain.ai.dto.TodoFeedbackSource; + +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class AiTodoQueryRepository { + + private final EntityManager entityManager; + + public TodoFeedbackSource findFeedbackSource(Long userId, Long todoId) { + List sources = entityManager.createQuery(""" + select new com.Timo.Timo.domain.ai.dto.TodoFeedbackSource( + t.title, + t.tagId, + tag.name, + t.durationSeconds, + 0 + ) + from Todo t + left join Tag tag on tag.id = t.tagId + where t.id = :todoId + and t.user.id = :userId + """, TodoFeedbackSource.class) + .setParameter("userId", userId) + .setParameter("todoId", todoId) + .getResultList(); + + if (sources.isEmpty()) { + return null; + } + + TodoFeedbackSource source = sources.get(0); + return new TodoFeedbackSource( + source.title(), + source.tagId(), + source.tagName(), + source.estimatedSeconds(), + findLatestActualSeconds(userId, todoId) + ); + } + + public List findActualDurationHistoriesBySimilarTitle( + Long userId, + String title, + LocalDateTime toExclusive, + ZoneId userZoneId, + int limit + ) { + List rows = entityManager.createQuery(""" + select new com.Timo.Timo.domain.ai.repository.TodoDurationHistoryRow( + t.title, + tr.actualSeconds, + coalesce(tr.endedAt, tr.startedAt) + ) + from TimerRecord tr + join tr.todo t + where t.user.id = :userId + and tr.user.id = :userId + and tr.actualSeconds is not null + and coalesce(tr.endedAt, tr.startedAt) < :toExclusive + and ( + lower(t.title) like lower(concat('%', :title, '%')) + or lower(:title) like lower(concat('%', t.title, '%')) + ) + order by + case + when lower(t.title) = lower(:title) then 0 + when lower(t.title) like lower(concat('%', :title, '%')) then 1 + when lower(:title) like lower(concat('%', t.title, '%')) then 2 + else 3 + end, + coalesce(tr.endedAt, tr.startedAt) desc, + tr.id desc + """, TodoDurationHistoryRow.class) + .setParameter("userId", userId) + .setParameter("title", title) + .setParameter("toExclusive", toExclusive) + .setMaxResults(limit) + .getResultList(); + + return toHistories(rows, userZoneId); + } + + public List findActualDurationHistoriesByTagId( + Long userId, + Long tagId, + LocalDateTime toExclusive, + ZoneId userZoneId, + int limit + ) { + List rows = entityManager.createQuery(""" + select new com.Timo.Timo.domain.ai.repository.TodoDurationHistoryRow( + t.title, + tr.actualSeconds, + coalesce(tr.endedAt, tr.startedAt) + ) + from TimerRecord tr + join tr.todo t + where t.user.id = :userId + and tr.user.id = :userId + and tr.actualSeconds is not null + and coalesce(tr.endedAt, tr.startedAt) < :toExclusive + and t.tagId = :tagId + order by coalesce(tr.endedAt, tr.startedAt) desc, tr.id desc + """, TodoDurationHistoryRow.class) + .setParameter("userId", userId) + .setParameter("tagId", tagId) + .setParameter("toExclusive", toExclusive) + .setMaxResults(limit) + .getResultList(); + + return toHistories(rows, userZoneId); + } + + private Integer findLatestActualSeconds(Long userId, Long todoId) { + return entityManager.createQuery(""" + select tr.actualSeconds + from TimerRecord tr + where tr.todo.id = :todoId + and tr.user.id = :userId + and tr.actualSeconds is not null + order by coalesce(tr.endedAt, tr.startedAt) desc, tr.id desc + """, Integer.class) + .setParameter("userId", userId) + .setParameter("todoId", todoId) + .setMaxResults(1) + .getResultStream() + .findFirst() + .orElse(0); + } + + private List toHistories(List rows, ZoneId userZoneId) { + return rows.stream() + .map(row -> new TodoDurationHistory( + row.title(), + row.actualSeconds(), + toUserLocalDate(row.recordedAt(), userZoneId) + )) + .toList(); + } + + private LocalDate toUserLocalDate(LocalDateTime utcDateTime, ZoneId userZoneId) { + return utcDateTime + .atZone(ZoneOffset.UTC) + .withZoneSameInstant(userZoneId) + .toLocalDate(); + } +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/repository/TodoDurationHistoryRow.java b/src/main/java/com/Timo/Timo/domain/ai/repository/TodoDurationHistoryRow.java new file mode 100644 index 00000000..ef6d60f1 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/repository/TodoDurationHistoryRow.java @@ -0,0 +1,9 @@ +package com.Timo.Timo.domain.ai.repository; + +import java.time.LocalDateTime; + +public record TodoDurationHistoryRow( + String title, + Integer actualSeconds, + LocalDateTime recordedAt +) {} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/service/AiRequestRateLimiter.java b/src/main/java/com/Timo/Timo/domain/ai/service/AiRequestRateLimiter.java new file mode 100644 index 00000000..e3a5a94e --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiRequestRateLimiter.java @@ -0,0 +1,139 @@ +package com.Timo.Timo.domain.ai.service; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; + +import com.Timo.Timo.domain.ai.exception.AiErrorCode; +import com.Timo.Timo.global.exception.CustomException; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class AiRequestRateLimiter { + + private static final String KEY_PREFIX = "ai:quota:"; + private static final int REQUEST_COST = 1; + private static final DefaultRedisScript LIMIT_SCRIPT = createLimitScript(); + + private final RedisTemplate redisTemplate; + + @Value("${ai.rate-limit.user-rpm}") + private int userRpmLimit; + + @Value("${ai.rate-limit.global-rpm}") + private int globalRpmLimit; + + @Value("${ai.rate-limit.user-rpd}") + private int userRpdLimit; + + @Value("${ai.rate-limit.global-rpd}") + private int globalRpdLimit; + + @Value("${ai.rate-limit.global-tpm}") + private int globalTpmLimit; + + public void validate(Long userId, int estimatedTokenCost) { + String minuteBucket = currentMinuteBucket(); + String dayBucket = currentDayBucket(); + long minuteTtlSeconds = secondsUntilNextMinute(); + long dayTtlSeconds = secondsUntilNextDay(); + + List keys = List.of( + KEY_PREFIX + "rpm:global:" + minuteBucket, + KEY_PREFIX + "rpm:user:" + userId + ":" + minuteBucket, + KEY_PREFIX + "rpd:global:" + dayBucket, + KEY_PREFIX + "rpd:user:" + userId + ":" + dayBucket, + KEY_PREFIX + "tpm:global:" + minuteBucket + ); + + Long allowed = redisTemplate.execute( + LIMIT_SCRIPT, + keys, + String.valueOf(globalRpmLimit), + String.valueOf(userRpmLimit), + String.valueOf(globalRpdLimit), + String.valueOf(userRpdLimit), + String.valueOf(globalTpmLimit), + String.valueOf(REQUEST_COST), + String.valueOf(REQUEST_COST), + String.valueOf(REQUEST_COST), + String.valueOf(REQUEST_COST), + String.valueOf(estimatedTokenCost), + String.valueOf(minuteTtlSeconds), + String.valueOf(dayTtlSeconds) + ); + + if (allowed == null || allowed == 0L) { + throw new CustomException(AiErrorCode.AI_RATE_LIMIT_EXCEEDED); + } + } + + private static DefaultRedisScript createLimitScript() { + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setResultType(Long.class); + script.setScriptText(""" + local keyCount = #KEYS + local minuteTtl = tonumber(ARGV[11]) + local dayTtl = tonumber(ARGV[12]) + + for i = 1, keyCount do + local limit = tonumber(ARGV[i]) + local cost = tonumber(ARGV[keyCount + i]) + local current = tonumber(redis.call('GET', KEYS[i]) or '0') + + if limit <= 0 or current + cost > limit then + return 0 + end + end + + for i = 1, keyCount do + local cost = tonumber(ARGV[keyCount + i]) + local ttl = minuteTtl + + if string.find(KEYS[i], ':rpd:') then + ttl = dayTtl + end + + redis.call('INCRBY', KEYS[i], cost) + if redis.call('TTL', KEYS[i]) < 0 then + redis.call('EXPIRE', KEYS[i], ttl) + end + end + + return 1 + """); + return script; + } + + private String currentMinuteBucket() { + return LocalDateTime.now(ZoneOffset.UTC) + .truncatedTo(ChronoUnit.MINUTES) + .toString(); + } + + private String currentDayBucket() { + return LocalDate.now(ZoneOffset.UTC).toString(); + } + + private long secondsUntilNextMinute() { + LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); + LocalDateTime nextMinute = now.truncatedTo(ChronoUnit.MINUTES).plusMinutes(1); + return Math.max(1, Duration.between(now, nextMinute).toSeconds()); + } + + private long secondsUntilNextDay() { + LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); + LocalDateTime nextDay = now.toLocalDate().plusDays(1).atStartOfDay(); + return Math.max(1, Duration.between(now, nextDay).toSeconds()); + } +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistories.java b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistories.java new file mode 100644 index 00000000..8f90cc0f --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistories.java @@ -0,0 +1,10 @@ +package com.Timo.Timo.domain.ai.service; + +import java.util.List; + +import com.Timo.Timo.domain.ai.dto.TodoDurationHistory; + +public record AiTodoHistories( + List similarTitleHistories, + List recentTagHistories +) {} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java new file mode 100644 index 00000000..ffdd3627 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java @@ -0,0 +1,50 @@ +package com.Timo.Timo.domain.ai.service; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.Timo.Timo.domain.ai.dto.TodoDurationHistory; +import com.Timo.Timo.domain.ai.repository.AiTodoQueryRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AiTodoHistoryService { + + private final AiTodoQueryRepository aiTodoQueryRepository; + + @Transactional(readOnly = true) + public AiTodoHistories findHistories( + Long userId, + String title, + Long tagId, + LocalDateTime toExclusive, + ZoneId userZoneId, + int limit + ) { + List similarTitleHistories = + aiTodoQueryRepository.findActualDurationHistoriesBySimilarTitle( + userId, + title, + toExclusive, + userZoneId, + limit + ); + List recentTagHistories = tagId == null + ? List.of() + : aiTodoQueryRepository.findActualDurationHistoriesByTagId( + userId, + tagId, + toExclusive, + userZoneId, + limit + ); + + return new AiTodoHistories(similarTitleHistories, recentTagHistories); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java new file mode 100644 index 00000000..f3f215bb --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java @@ -0,0 +1,192 @@ +package com.Timo.Timo.domain.ai.service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; + +import org.springframework.stereotype.Service; + +import com.Timo.Timo.domain.ai.dto.TodoFeedbackSource; +import com.Timo.Timo.domain.ai.dto.request.RecommendDurationRequest; +import com.Timo.Timo.domain.ai.dto.response.GeminiDurationRecommendation; +import com.Timo.Timo.domain.ai.dto.response.GeminiTodoFeedback; +import com.Timo.Timo.domain.ai.dto.response.RecommendDurationResponse; +import com.Timo.Timo.domain.ai.exception.AiErrorCode; +import com.Timo.Timo.domain.ai.prompt.TodoDurationPromptBuilder; +import com.Timo.Timo.domain.ai.prompt.TodoFeedbackPromptBuilder; +import com.Timo.Timo.domain.ai.repository.AiTodoQueryRepository; +import com.Timo.Timo.domain.todo.exception.TodoErrorCode; +import com.Timo.Timo.domain.user.entity.User; +import com.Timo.Timo.domain.user.exception.UserErrorCode; +import com.Timo.Timo.domain.user.repository.UserRepository; +import com.Timo.Timo.global.exception.CustomException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AiTodoService { + + private static final int HISTORY_LIMIT = 5; + private static final int ESTIMATED_RESPONSE_TOKEN_COST = 200; + private static final int TOKEN_ESTIMATE_CHAR_DIVISOR = 4; + + private final AiTodoHistoryService historyService; + private final AiTodoQueryRepository queryRepository; + private final UserRepository userRepository; + private final TodoDurationPromptBuilder promptBuilder; + private final TodoFeedbackPromptBuilder feedbackPromptBuilder; + private final GeminiService geminiService; + private final ObjectMapper objectMapper; + private final AiRequestRateLimiter rateLimiter; + + public RecommendDurationResponse recommendDuration(Long userId, RecommendDurationRequest request) { + ZoneId userZoneId = getUserZoneId(userId); + LocalDateTime toExclusive = getTodayToExclusiveUtc(userZoneId); + + AiTodoHistories histories = historyService.findHistories( + userId, + request.title(), + request.tagId(), + toExclusive, + userZoneId, + HISTORY_LIMIT + ); + + String prompt = promptBuilder.build( + request, + histories.similarTitleHistories(), + histories.recentTagHistories() + ); + rateLimiter.validate(userId, estimateTokenCost(prompt)); + + log.info( + "AI duration recommendation histories loaded. similarTitle={}, recentTag={}", + histories.similarTitleHistories().size(), + histories.recentTagHistories().size() + ); + + String geminiJson = geminiService.generateJson(prompt); + GeminiDurationRecommendation recommendation = parseRecommendation(geminiJson); + return validate(recommendation); + } + + public String createFeedback(Long userId, Long todoId) { + ZoneId userZoneId = getUserZoneId(userId); + LocalDateTime toExclusive = getTodayToExclusiveUtc(userZoneId); + TodoFeedbackSource source = queryRepository.findFeedbackSource(userId, todoId); + if (source == null) { + throw new CustomException(TodoErrorCode.TODO_NOT_FOUND); + } + + AiTodoHistories histories = historyService.findHistories( + userId, + source.title(), + source.tagId(), + toExclusive, + userZoneId, + HISTORY_LIMIT + ); + + String prompt = feedbackPromptBuilder.build( + source, + histories.similarTitleHistories(), + histories.recentTagHistories() + ); + rateLimiter.validate(userId, estimateTokenCost(prompt)); + + log.info( + "AI todo feedback histories loaded. similarTitle={}, recentTag={}", + histories.similarTitleHistories().size(), + histories.recentTagHistories().size() + ); + + String geminiJson = geminiService.generateJson(prompt); + GeminiTodoFeedback feedback = parseFeedback(geminiJson); + return validateFeedback(feedback); + } + + private ZoneId getUserZoneId(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + return ZoneId.of(user.getZoneId()); + } + + private LocalDateTime getTodayToExclusiveUtc(ZoneId userZoneId) { + return LocalDate.now(userZoneId) + .plusDays(1) + .atStartOfDay(userZoneId) + .withZoneSameInstant(ZoneOffset.UTC) + .toLocalDateTime(); + } + + private GeminiDurationRecommendation parseRecommendation(String geminiJson) { + try { + return objectMapper.readValue( + stripMarkdownFence(geminiJson), + GeminiDurationRecommendation.class + ); + } catch (Exception exception) { + log.warn("Failed to parse Gemini duration recommendation.", exception); + throw new CustomException(AiErrorCode.AI_RESPONSE_PARSE_FAILED); + } + } + + private GeminiTodoFeedback parseFeedback(String geminiJson) { + try { + return objectMapper.readValue( + stripMarkdownFence(geminiJson), + GeminiTodoFeedback.class + ); + } catch (Exception exception) { + log.warn("Failed to parse Gemini todo feedback.", exception); + throw new CustomException(AiErrorCode.AI_RESPONSE_PARSE_FAILED); + } + } + + private RecommendDurationResponse validate(GeminiDurationRecommendation recommendation) { + if (recommendation == null + || recommendation.recommendedMinutes() == null + ) { + throw new CustomException(AiErrorCode.AI_INVALID_RESPONSE); + } + + int recommendedMinutes = normalizeMinutes(recommendation.recommendedMinutes()); + + return new RecommendDurationResponse(recommendedMinutes); + } + + private String validateFeedback(GeminiTodoFeedback feedback) { + if (feedback == null + || feedback.feedback() == null + || feedback.feedback().isBlank() + ) { + throw new CustomException(AiErrorCode.AI_INVALID_RESPONSE); + } + + return feedback.feedback().trim(); + } + + private int normalizeMinutes(int minutes) { + return Math.max(1, minutes); + } + + private int estimateTokenCost(String prompt) { + return Math.max(1, prompt.length() / TOKEN_ESTIMATE_CHAR_DIVISOR) + ESTIMATED_RESPONSE_TOKEN_COST; + } + + private String stripMarkdownFence(String value) { + String trimmed = value.trim(); + if (trimmed.startsWith("```json") && trimmed.endsWith("```")) { + return trimmed.substring(7, trimmed.length() - 3).trim(); + } + if (trimmed.startsWith("```") && trimmed.endsWith("```")) { + return trimmed.substring(3, trimmed.length() - 3).trim(); + } + return trimmed; + } +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java b/src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java new file mode 100644 index 00000000..5a31063f --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java @@ -0,0 +1,99 @@ +package com.Timo.Timo.domain.ai.service; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import com.Timo.Timo.domain.ai.exception.AiErrorCode; +import com.Timo.Timo.global.exception.CustomException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class GeminiService { + + private static final String GENERATE_CONTENT_URL = + "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"; + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3); + private static final Duration READ_TIMEOUT = Duration.ofSeconds(10); + + private final ObjectMapper objectMapper; + private final RestClient restClient = createRestClient(); + + @Value("${ai.gemini.api-key:}") + private String apiKey; + + @Value("${ai.gemini.model}") + private String model; + + public String generateJson(String prompt) { + if (apiKey == null || apiKey.isBlank()) { + throw new CustomException(AiErrorCode.AI_CONFIG_NOT_FOUND); + } + if (model == null || model.isBlank()) { + throw new CustomException(AiErrorCode.AI_CONFIG_NOT_FOUND); + } + + Map request = Map.of( + "contents", List.of(Map.of( + "parts", List.of(Map.of("text", prompt)) + )), + "generationConfig", Map.of( + "temperature", 0.2, + "response_mime_type", "application/json" + ) + ); + + String response = restClient.post() + .uri(GENERATE_CONTENT_URL, model) + .contentType(MediaType.APPLICATION_JSON) + .header("x-goog-api-key", apiKey) + .body(request) + .retrieve() + .body(String.class); + + return extractText(response); + } + + private RestClient createRestClient() { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(CONNECT_TIMEOUT); + requestFactory.setReadTimeout(READ_TIMEOUT); + + return RestClient.builder() + .requestFactory(requestFactory) + .build(); + } + + private String extractText(String response) { + try { + JsonNode root = objectMapper.readTree(response); + JsonNode textNode = root + .path("candidates") + .path(0) + .path("content") + .path("parts") + .path(0) + .path("text"); + + if (textNode.isMissingNode() || textNode.asText().isBlank()) { + throw new CustomException(AiErrorCode.AI_INVALID_RESPONSE); + } + + return textNode.asText(); + } catch (CustomException exception) { + throw exception; + } catch (Exception exception) { + throw new CustomException(AiErrorCode.AI_RESPONSE_PARSE_FAILED); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/focus/controller/FocusController.java b/src/main/java/com/Timo/Timo/domain/focus/controller/FocusController.java new file mode 100644 index 00000000..d3b5ecd7 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/focus/controller/FocusController.java @@ -0,0 +1,38 @@ +package com.Timo.Timo.domain.focus.controller; + +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.RestController; + +import com.Timo.Timo.domain.focus.docs.FocusControllerDocs; +import com.Timo.Timo.domain.focus.dto.FocusTodoResult; +import com.Timo.Timo.domain.focus.dto.response.FocusTodoResponse; +import com.Timo.Timo.domain.focus.service.FocusService; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/focus") +@RequiredArgsConstructor +@Tag(name = "Focus", description = "집중 모드 API") +public class FocusController implements FocusControllerDocs { + + private final FocusService focusService; + + @Override + @GetMapping + public ResponseEntity> getFocusTodo( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + FocusTodoResult result = focusService.getFocusTodo(userDetails.getUserId()); + + return ResponseEntity + .status(result.successCode().getHttpStatus()) + .body(BaseResponse.onSuccess(result.successCode(), result.response())); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/focus/docs/FocusControllerDocs.java b/src/main/java/com/Timo/Timo/domain/focus/docs/FocusControllerDocs.java new file mode 100644 index 00000000..1d7ac758 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/focus/docs/FocusControllerDocs.java @@ -0,0 +1,62 @@ +package com.Timo.Timo.domain.focus.docs; + +import org.springframework.http.ResponseEntity; + +import com.Timo.Timo.domain.focus.dto.response.FocusTodoResponse; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.exception.dto.ErrorDto; +import com.Timo.Timo.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +public interface FocusControllerDocs { + + @Operation( + summary = "집중 모드 TODO 조회", + description = """ + 오늘 TODO 중 미완료인 최상단 TODO 하나를 조회합니다. (sortOrder가 가장 작은 미완료 TODO) + 오늘 TODO가 하나도 없으면 "오늘의 TODO가 없습니다.", + 오늘 TODO를 모두 완료했으면 "오늘의 TODO를 모두 완료했습니다." 메시지를 반환하며, + 두 경우 모두 hasTodo는 false, todo는 null입니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "집중 모드 TODO 조회 성공 / 오늘의 TODO가 없거나 모두 완료한 경우", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 사용자인 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> getFocusTodo( + @Parameter(hidden = true) CustomUserDetails userDetails + ); +} diff --git a/src/main/java/com/Timo/Timo/domain/focus/dto/FocusTodoResult.java b/src/main/java/com/Timo/Timo/domain/focus/dto/FocusTodoResult.java new file mode 100644 index 00000000..69f17139 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/focus/dto/FocusTodoResult.java @@ -0,0 +1,9 @@ +package com.Timo.Timo.domain.focus.dto; + +import com.Timo.Timo.domain.focus.dto.response.FocusTodoResponse; +import com.Timo.Timo.domain.focus.exception.FocusSuccessCode; + +public record FocusTodoResult( + FocusSuccessCode successCode, + FocusTodoResponse response +) { } diff --git a/src/main/java/com/Timo/Timo/domain/focus/dto/response/FocusTodoResponse.java b/src/main/java/com/Timo/Timo/domain/focus/dto/response/FocusTodoResponse.java new file mode 100644 index 00000000..e89457fd --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/focus/dto/response/FocusTodoResponse.java @@ -0,0 +1,62 @@ +package com.Timo.Timo.domain.focus.dto.response; + +import java.time.LocalDate; +import java.util.List; + +import com.Timo.Timo.domain.home.dto.response.HomeResponse.SubtaskResponse; +import com.Timo.Timo.domain.home.dto.response.HomeResponse.TagResponse; +import com.Timo.Timo.domain.home.dto.response.HomeResponse.TodoResponse; +import com.Timo.Timo.domain.todo.enums.Weekday; +import com.fasterxml.jackson.annotation.JsonProperty; + +public record FocusTodoResponse( + LocalDate date, + Weekday dayOfWeek, + boolean hasTodo, + FocusTodoDetailResponse todo +) { + public static FocusTodoResponse of(LocalDate date, TodoResponse todo) { + return new FocusTodoResponse( + date, + Weekday.from(date.getDayOfWeek()), + true, + FocusTodoDetailResponse.from(todo) + ); + } + + public static FocusTodoResponse empty(LocalDate date) { + return new FocusTodoResponse( + date, + Weekday.from(date.getDayOfWeek()), + false, + null + ); + } + + public record FocusTodoDetailResponse( + Long todoId, + String icon, + String title, + boolean completed, + Integer durationSeconds, + String priority, + TagResponse tag, + @JsonProperty("isRepeated") + boolean isRepeated, + List subtasks + ) { + public static FocusTodoDetailResponse from(TodoResponse todo) { + return new FocusTodoDetailResponse( + todo.todoId(), + todo.icon(), + todo.title(), + todo.completed(), + todo.durationSeconds(), + todo.priority(), + todo.tag(), + todo.isRepeated(), + todo.subtasks() + ); + } + } +} diff --git a/src/main/java/com/Timo/Timo/domain/focus/exception/FocusSuccessCode.java b/src/main/java/com/Timo/Timo/domain/focus/exception/FocusSuccessCode.java new file mode 100644 index 00000000..eb487a05 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/focus/exception/FocusSuccessCode.java @@ -0,0 +1,21 @@ +package com.Timo.Timo.domain.focus.exception; + +import org.springframework.http.HttpStatus; + +import com.Timo.Timo.global.exception.code.BaseSuccessCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum FocusSuccessCode implements BaseSuccessCode { + + GET_FOCUS_TODO(HttpStatus.OK, "집중 모드 TODO 조회 성공"), + NO_TODO_TODAY(HttpStatus.OK, "오늘의 TODO가 없습니다."), + ALL_TODO_COMPLETED(HttpStatus.OK, "오늘의 TODO를 모두 완료했습니다."), + ; + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/Timo/Timo/domain/focus/service/FocusService.java b/src/main/java/com/Timo/Timo/domain/focus/service/FocusService.java new file mode 100644 index 00000000..7b078b84 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/focus/service/FocusService.java @@ -0,0 +1,59 @@ +package com.Timo.Timo.domain.focus.service; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.Timo.Timo.domain.focus.dto.FocusTodoResult; +import com.Timo.Timo.domain.focus.dto.response.FocusTodoResponse; +import com.Timo.Timo.domain.focus.exception.FocusSuccessCode; +import com.Timo.Timo.domain.home.dto.response.HomeResponse.TodoResponse; +import com.Timo.Timo.domain.home.service.HomeTodoReader; +import com.Timo.Timo.domain.home.service.HomeTodoReader.LoadedTodos; +import com.Timo.Timo.domain.user.entity.User; +import com.Timo.Timo.domain.user.exception.UserErrorCode; +import com.Timo.Timo.domain.user.repository.UserRepository; +import com.Timo.Timo.global.exception.CustomException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FocusService { + + private final UserRepository userRepository; + private final HomeTodoReader homeTodoReader; + + public FocusTodoResult getFocusTodo(Long userId) { + User user = getUser(userId); + LocalDate today = LocalDate.now(ZoneId.of(user.getZoneId())); + + LoadedTodos loaded = homeTodoReader.load(userId, today, today); + List todos = homeTodoReader.sortedTodosOn(loaded, today); + + if (todos.isEmpty()) { + return new FocusTodoResult(FocusSuccessCode.NO_TODO_TODAY, FocusTodoResponse.empty(today)); + } + + return todos.stream() + .filter(todo -> !todo.completed()) + .findFirst() + .map(todo -> new FocusTodoResult( + FocusSuccessCode.GET_FOCUS_TODO, + FocusTodoResponse.of(today, todo) + )) + .orElseGet(() -> new FocusTodoResult( + FocusSuccessCode.ALL_TODO_COMPLETED, + FocusTodoResponse.empty(today) + )); + } + + private User getUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/home/controller/HomeController.java b/src/main/java/com/Timo/Timo/domain/home/controller/HomeController.java new file mode 100644 index 00000000..f097e65e --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/home/controller/HomeController.java @@ -0,0 +1,54 @@ +package com.Timo.Timo.domain.home.controller; + +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 com.Timo.Timo.domain.home.docs.HomeControllerDocs; +import com.Timo.Timo.domain.home.dto.response.HomeResponse; +import com.Timo.Timo.domain.home.dto.response.TodayResponse; +import com.Timo.Timo.domain.home.exception.HomeSuccessCode; +import com.Timo.Timo.domain.home.service.HomeService; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/home") +@RequiredArgsConstructor +@Tag(name = "Home", description = "HOME API") +public class HomeController implements HomeControllerDocs { + + private final HomeService homeService; + + @Override + @GetMapping + public ResponseEntity> getHome( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestParam(required = false) String filter, + @RequestParam(required = false) String baseDate + ) { + HomeResponse response = homeService.getHome(userDetails.getUserId(), filter, baseDate); + + return ResponseEntity + .status(HomeSuccessCode.GET_HOME.getHttpStatus()) + .body(BaseResponse.onSuccess(HomeSuccessCode.GET_HOME, response)); + } + + @Override + @GetMapping("/today") + public ResponseEntity> getToday( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + TodayResponse response = homeService.getToday(userDetails.getUserId()); + + return ResponseEntity + .status(HomeSuccessCode.GET_TODAY.getHttpStatus()) + .body(BaseResponse.onSuccess(HomeSuccessCode.GET_TODAY, response)); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/home/docs/HomeControllerDocs.java b/src/main/java/com/Timo/Timo/domain/home/docs/HomeControllerDocs.java new file mode 100644 index 00000000..fa445253 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/home/docs/HomeControllerDocs.java @@ -0,0 +1,98 @@ +package com.Timo.Timo.domain.home.docs; + +import org.springframework.http.ResponseEntity; + +import com.Timo.Timo.domain.home.dto.response.HomeResponse; +import com.Timo.Timo.domain.home.dto.response.TodayResponse; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.exception.dto.ErrorDto; +import com.Timo.Timo.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +public interface HomeControllerDocs { + + @Operation( + summary = "HOME 화면 TODO 목록 조회", + description = """ + HOME 화면의 필터 조건에 따라 일별 TODO 목록을 조회합니다. + DEFAULT는 기준 날짜 전후 7일, WEEK는 기준 날짜부터 7일을 반환합니다. + 각 날짜의 TODO는 미완료 먼저, 같은 완료 그룹 안에서는 sortOrder 오름차순으로 정렬됩니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "홈 뷰 조회 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = "유효하지 않은 필터 값이거나 날짜 형식이 올바르지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> getHome( + @Parameter(hidden = true) CustomUserDetails userDetails, + @Parameter(description = "조회 필터(DEFAULT, WEEK)", example = "DEFAULT") String filter, + @Parameter(description = "기준 날짜(yyyy-MM-dd), 미입력 시 오늘", example = "2026-07-22") String baseDate + ); + + @Operation( + summary = "오늘 뷰 TODO 목록 조회", + description = """ + 오늘 하루의 TODO 목록을 하위 태스크 상세 정보까지 포함해 단일 객체로 조회합니다. + TODO는 미완료 먼저, 같은 완료 그룹 안에서는 sortOrder 오름차순으로 정렬됩니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "오늘 뷰 조회 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> getToday( + @Parameter(hidden = true) CustomUserDetails userDetails + ); +} diff --git a/src/main/java/com/Timo/Timo/domain/home/dto/response/HomeResponse.java b/src/main/java/com/Timo/Timo/domain/home/dto/response/HomeResponse.java new file mode 100644 index 00000000..62d74cbf --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/home/dto/response/HomeResponse.java @@ -0,0 +1,93 @@ +package com.Timo.Timo.domain.home.dto.response; + +import java.time.LocalDate; +import java.util.List; +import java.util.Objects; + +import com.Timo.Timo.domain.home.enums.HomeFilter; +import com.Timo.Timo.domain.tag.entity.Tag; +import com.Timo.Timo.domain.todo.entity.Subtask; +import com.Timo.Timo.domain.todo.enums.TodoTimerStatus; +import com.Timo.Timo.domain.todo.enums.Weekday; +import com.fasterxml.jackson.annotation.JsonProperty; + +public record HomeResponse( + HomeFilter filter, + LocalDate baseDate, + List days +) { + + public record DayResponse( + LocalDate date, + Weekday dayOfWeek, + @JsonProperty("isHoliday") + boolean isHoliday, + @JsonProperty("isToday") + boolean isToday, + int totalCount, + int completedCount, + List todos + ) { + public static DayResponse of( + LocalDate date, + boolean isHoliday, + boolean isToday, + List todos + ) { + int completedCount = (int) todos.stream() + .filter(TodoResponse::completed) + .count(); + + return new DayResponse( + date, + Weekday.from(date.getDayOfWeek()), + isHoliday, + isToday, + todos.size(), + completedCount, + todos + ); + } + } + + public record TodoResponse( + Long todoId, + String icon, + String title, + boolean completed, + Integer durationSeconds, + String priority, + TagResponse tag, + boolean hasMemo, + @JsonProperty("isRepeated") + boolean isRepeated, + TodoTimerStatus timerStatus, + Integer sortOrder, + List subtasks + ) { + } + + public record TagResponse( + Long tagId, + String name + ) { + public static TagResponse from(Tag tag) { + Objects.requireNonNull(tag, "tag must not be null"); + return new TagResponse(tag.getId(), tag.getName()); + } + } + + public record SubtaskResponse( + Long subtaskId, + String content, + boolean completed + ) { + public static SubtaskResponse from(Subtask subtask) { + return new SubtaskResponse( + subtask.getId(), + subtask.getContent(), + subtask.isCompleted() + ); + } + } +} diff --git a/src/main/java/com/Timo/Timo/domain/home/dto/response/TodayResponse.java b/src/main/java/com/Timo/Timo/domain/home/dto/response/TodayResponse.java new file mode 100644 index 00000000..9e2a7b36 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/home/dto/response/TodayResponse.java @@ -0,0 +1,73 @@ +package com.Timo.Timo.domain.home.dto.response; + +import java.time.LocalDate; +import java.util.List; + +import com.Timo.Timo.domain.home.dto.response.HomeResponse.SubtaskResponse; +import com.Timo.Timo.domain.home.dto.response.HomeResponse.TagResponse; +import com.Timo.Timo.domain.home.dto.response.HomeResponse.TodoResponse; +import com.Timo.Timo.domain.todo.enums.TodoTimerStatus; +import com.Timo.Timo.domain.todo.enums.Weekday; +import com.fasterxml.jackson.annotation.JsonProperty; + +public record TodayResponse( + LocalDate date, + Weekday dayOfWeek, + int totalCount, + int completedCount, + List todos +) { + + public static TodayResponse of(LocalDate date, List todos) { + List todayTodos = todos.stream() + .map(todo -> TodayTodoResponse.from(date, todo)) + .toList(); + + int completedCount = (int) todayTodos.stream() + .filter(TodayTodoResponse::completed) + .count(); + + return new TodayResponse( + date, + Weekday.from(date.getDayOfWeek()), + todayTodos.size(), + completedCount, + todayTodos + ); + } + + public record TodayTodoResponse( + Long todoId, + String icon, + String title, + boolean completed, + LocalDate date, + Integer durationSeconds, + String priority, + TagResponse tag, + @JsonProperty("isRepeated") + boolean isRepeated, + boolean hasMemo, + TodoTimerStatus timerStatus, + Integer sortOrder, + List subtasks + ) { + public static TodayTodoResponse from(LocalDate date, TodoResponse todo) { + return new TodayTodoResponse( + todo.todoId(), + todo.icon(), + todo.title(), + todo.completed(), + date, + todo.durationSeconds(), + todo.priority(), + todo.tag(), + todo.isRepeated(), + todo.hasMemo(), + todo.timerStatus(), + todo.sortOrder(), + todo.subtasks() + ); + } + } +} diff --git a/src/main/java/com/Timo/Timo/domain/home/enums/HomeFilter.java b/src/main/java/com/Timo/Timo/domain/home/enums/HomeFilter.java new file mode 100644 index 00000000..a322149c --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/home/enums/HomeFilter.java @@ -0,0 +1,47 @@ +package com.Timo.Timo.domain.home.enums; + +import java.time.LocalDate; + +import com.Timo.Timo.domain.home.exception.HomeErrorCode; +import com.Timo.Timo.global.exception.CustomException; + +public enum HomeFilter { + DEFAULT { + @Override + public LocalDate rangeStart(LocalDate baseDate) { + return baseDate.minusDays(7); + } + + @Override + public LocalDate rangeEnd(LocalDate baseDate) { + return baseDate.plusDays(7); + } + }, + WEEK { + @Override + public LocalDate rangeStart(LocalDate baseDate) { + return baseDate; + } + + @Override + public LocalDate rangeEnd(LocalDate baseDate) { + return baseDate.plusDays(6); + } + }; + + public abstract LocalDate rangeStart(LocalDate baseDate); + + public abstract LocalDate rangeEnd(LocalDate baseDate); + + public static HomeFilter from(String value) { + if (value == null || value.isBlank()) { + return DEFAULT; + } + + try { + return valueOf(value); + } catch (IllegalArgumentException exception) { + throw new CustomException(HomeErrorCode.INVALID_FILTER_OR_DATE); + } + } +} diff --git a/src/main/java/com/Timo/Timo/domain/home/exception/HomeErrorCode.java b/src/main/java/com/Timo/Timo/domain/home/exception/HomeErrorCode.java new file mode 100644 index 00000000..41102a5b --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/home/exception/HomeErrorCode.java @@ -0,0 +1,20 @@ +package com.Timo.Timo.domain.home.exception; + +import org.springframework.http.HttpStatus; + +import com.Timo.Timo.global.exception.code.BaseErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum HomeErrorCode implements BaseErrorCode { + + INVALID_FILTER_OR_DATE(HttpStatus.BAD_REQUEST, "HOME_400", "유효하지 않은 필터 값이거나 날짜 형식이 올바르지 않습니다."), + INVALID_DATE(HttpStatus.BAD_REQUEST, "DATE_400", "날짜 형식이 올바르지 않습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/com/Timo/Timo/domain/home/exception/HomeSuccessCode.java b/src/main/java/com/Timo/Timo/domain/home/exception/HomeSuccessCode.java new file mode 100644 index 00000000..dd2ef245 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/home/exception/HomeSuccessCode.java @@ -0,0 +1,19 @@ +package com.Timo.Timo.domain.home.exception; + +import org.springframework.http.HttpStatus; + +import com.Timo.Timo.global.exception.code.BaseSuccessCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum HomeSuccessCode implements BaseSuccessCode { + + GET_HOME(HttpStatus.OK, "홈 뷰 조회 성공"), + GET_TODAY(HttpStatus.OK, "오늘 뷰 조회 성공"); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/Timo/Timo/domain/home/mapper/HomeTodoMapper.java b/src/main/java/com/Timo/Timo/domain/home/mapper/HomeTodoMapper.java new file mode 100644 index 00000000..65863eaa --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/home/mapper/HomeTodoMapper.java @@ -0,0 +1,61 @@ +package com.Timo.Timo.domain.home.mapper; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.Timo.Timo.domain.home.dto.response.HomeResponse.SubtaskResponse; +import com.Timo.Timo.domain.home.dto.response.HomeResponse.TagResponse; +import com.Timo.Timo.domain.home.dto.response.HomeResponse.TodoResponse; +import com.Timo.Timo.domain.tag.entity.Tag; +import com.Timo.Timo.domain.todo.entity.Subtask; +import com.Timo.Timo.domain.todo.entity.Todo; +import com.Timo.Timo.domain.todo.entity.TodoInstance; +import com.Timo.Timo.domain.todo.enums.RepeatType; +import com.Timo.Timo.domain.todo.enums.TodoTimerStatus; + +@Component +public class HomeTodoMapper { + + public TodoResponse toResponse(TodoContext context) { + Todo todo = context.todo(); + TodoInstance instance = context.instance(); + + return new TodoResponse( + todo.getId(), + todo.getIcon() != null ? todo.getIcon().name() : null, + todo.getTitle(), + instance != null && instance.isCompleted(), + todo.getDurationSeconds(), + todo.getPriority() != null ? todo.getPriority().name() : null, + mapTag(context.tag()), + todo.getMemo() != null && !todo.getMemo().isBlank(), + todo.getRepeatType() != RepeatType.NONE, + resolveTimerStatus(instance), + instance != null ? instance.getSortOrder() : context.defaultSortOrder(), + mapSubtasks(todo.getSubtasks()) + ); + } + + private TagResponse mapTag(Tag tag) { + return tag != null ? TagResponse.from(tag) : null; + } + + private TodoTimerStatus resolveTimerStatus(TodoInstance instance) { + return instance != null ? instance.getTimerStatus() : TodoTimerStatus.STOPPED; + } + + private List mapSubtasks(List subtasks) { + return subtasks.stream() + .map(SubtaskResponse::from) + .toList(); + } + + public record TodoContext( + Todo todo, + TodoInstance instance, + Tag tag, + int defaultSortOrder + ) { + } +} diff --git a/src/main/java/com/Timo/Timo/domain/home/service/HolidayChecker.java b/src/main/java/com/Timo/Timo/domain/home/service/HolidayChecker.java new file mode 100644 index 00000000..508e2478 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/home/service/HolidayChecker.java @@ -0,0 +1,44 @@ +package com.Timo.Timo.domain.home.service; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.MonthDay; +import java.util.Map; +import java.util.Set; + +import org.springframework.stereotype.Component; + +import com.Timo.Timo.domain.user.enums.Language; + +@Component +public class HolidayChecker { + + private static final Set KOREAN_FIXED_HOLIDAYS = Set.of( + MonthDay.of(1, 1), + MonthDay.of(3, 1), + MonthDay.of(5, 5), + MonthDay.of(6, 6), + MonthDay.of(8, 15), + MonthDay.of(10, 3), + MonthDay.of(10, 9), + MonthDay.of(12, 25) + ); + + /** + * 언어(지역)별 양력 고정 공휴일. 음력 공휴일(설날·추석 등)은 별도 계산이 필요해 아직 포함하지 않는다. + * 신뢰할 만한 데이터가 없는 언어는 빈 집합으로 두고 일요일만 공휴일로 처리한다. + */ + private static final Map> FIXED_HOLIDAYS_BY_LANGUAGE = Map.of( + Language.KO, KOREAN_FIXED_HOLIDAYS, + Language.EN, Set.of() + ); + + public boolean isHoliday(LocalDate date, Language language) { + if (date.getDayOfWeek() == DayOfWeek.SUNDAY) { + return true; + } + + Set fixedHolidays = FIXED_HOLIDAYS_BY_LANGUAGE.getOrDefault(language, Set.of()); + return fixedHolidays.contains(MonthDay.from(date)); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/home/service/HomeService.java b/src/main/java/com/Timo/Timo/domain/home/service/HomeService.java new file mode 100644 index 00000000..123681c1 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/home/service/HomeService.java @@ -0,0 +1,84 @@ +package com.Timo.Timo.domain.home.service; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeParseException; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.Timo.Timo.domain.home.dto.response.HomeResponse; +import com.Timo.Timo.domain.home.dto.response.HomeResponse.DayResponse; +import com.Timo.Timo.domain.home.dto.response.TodayResponse; +import com.Timo.Timo.domain.home.enums.HomeFilter; +import com.Timo.Timo.domain.home.exception.HomeErrorCode; +import com.Timo.Timo.domain.home.service.HomeTodoReader.LoadedTodos; +import com.Timo.Timo.domain.user.entity.User; +import com.Timo.Timo.domain.user.enums.Language; +import com.Timo.Timo.domain.user.exception.UserErrorCode; +import com.Timo.Timo.domain.user.repository.UserRepository; +import com.Timo.Timo.global.exception.CustomException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class HomeService { + + private final UserRepository userRepository; + private final HomeTodoReader homeTodoReader; + private final HolidayChecker holidayChecker; + + public HomeResponse getHome(Long userId, String filterValue, String baseDateValue) { + User user = getUser(userId); + ZoneId userZone = ZoneId.of(user.getZoneId()); + Language language = user.getLanguage(); + + HomeFilter filter = HomeFilter.from(filterValue); + LocalDate baseDate = parseBaseDate(baseDateValue, userZone); + LocalDate from = filter.rangeStart(baseDate); + LocalDate to = filter.rangeEnd(baseDate); + LocalDate today = LocalDate.now(userZone); + + LoadedTodos loaded = homeTodoReader.load(userId, from, to); + + List days = from.datesUntil(to.plusDays(1)) + .map(date -> DayResponse.of( + date, + holidayChecker.isHoliday(date, language), + date.equals(today), + homeTodoReader.sortedTodosOn(loaded, date) + )) + .toList(); + + return new HomeResponse(filter, baseDate, days); + } + + public TodayResponse getToday(Long userId) { + User user = getUser(userId); + LocalDate today = LocalDate.now(ZoneId.of(user.getZoneId())); + + LoadedTodos loaded = homeTodoReader.load(userId, today, today); + + return TodayResponse.of(today, homeTodoReader.sortedTodosOn(loaded, today)); + } + + private User getUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + } + + private LocalDate parseBaseDate(String baseDateValue, ZoneId userZone) { + if (baseDateValue == null || baseDateValue.isBlank()) { + return LocalDate.now(userZone); + } + + try { + return LocalDate.parse(baseDateValue); + } catch (DateTimeParseException exception) { + throw new CustomException(HomeErrorCode.INVALID_DATE); + } + } +} diff --git a/src/main/java/com/Timo/Timo/domain/home/service/HomeTodoReader.java b/src/main/java/com/Timo/Timo/domain/home/service/HomeTodoReader.java new file mode 100644 index 00000000..40f68dd6 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/home/service/HomeTodoReader.java @@ -0,0 +1,107 @@ +package com.Timo.Timo.domain.home.service; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Component; + +import com.Timo.Timo.domain.home.dto.response.HomeResponse.TodoResponse; +import com.Timo.Timo.domain.home.mapper.HomeTodoMapper; +import com.Timo.Timo.domain.home.mapper.HomeTodoMapper.TodoContext; +import com.Timo.Timo.domain.tag.entity.Tag; +import com.Timo.Timo.domain.tag.repository.TagRepository; +import com.Timo.Timo.domain.todo.entity.Todo; +import com.Timo.Timo.domain.todo.entity.TodoInstance; +import com.Timo.Timo.domain.todo.repository.TodoInstanceRepository; +import com.Timo.Timo.domain.todo.repository.TodoRepository; +import com.Timo.Timo.domain.todo.service.TodoDateCalculator; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class HomeTodoReader { + + private final TodoRepository todoRepository; + private final TodoInstanceRepository todoInstanceRepository; + private final TagRepository tagRepository; + private final TodoDateCalculator todoDateCalculator; + private final HomeTodoMapper homeTodoMapper; + + public LoadedTodos load(Long userId, LocalDate from, LocalDate to) { + List rules = todoRepository.findRulesInRange(userId, from, to); + return new LoadedTodos(rules, loadInstances(rules, from, to), loadTags(rules)); + } + + public List sortedTodosOn(LoadedTodos loaded, LocalDate date) { + List occurredRules = loaded.rules().stream() + .filter(rule -> todoDateCalculator.occursOn(rule, date)) + .toList(); + + List todos = new ArrayList<>(); + for (int index = 0; index < occurredRules.size(); index++) { + Todo rule = occurredRules.get(index); + TodoInstance instance = loaded.instancesByKey().get(new InstanceKey(rule.getId(), date)); + todos.add(homeTodoMapper.toResponse( + new TodoContext(rule, instance, loaded.tagsById().get(rule.getTagId()), index) + )); + } + + return todos.stream() + .sorted(Comparator + .comparing(TodoResponse::completed) + .thenComparing(TodoResponse::sortOrder, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(TodoResponse::todoId)) + .toList(); + } + + private Map loadInstances(List rules, LocalDate from, LocalDate to) { + List todoIds = rules.stream() + .map(Todo::getId) + .toList(); + + if (todoIds.isEmpty()) { + return Map.of(); + } + + return todoInstanceRepository.findByTodoIdsAndDateRange(todoIds, from, to).stream() + .collect(Collectors.toMap( + instance -> new InstanceKey(instance.getTodo().getId(), instance.getDate()), + Function.identity() + )); + } + + private Map loadTags(List rules) { + List tagIds = rules.stream() + .map(Todo::getTagId) + .filter(Objects::nonNull) + .distinct() + .toList(); + + if (tagIds.isEmpty()) { + return Map.of(); + } + + return tagRepository.findAllById(tagIds).stream() + .collect(Collectors.toMap(Tag::getId, Function.identity())); + } + + public record LoadedTodos( + List rules, + Map instancesByKey, + Map tagsById + ) { + } + + private record InstanceKey( + Long todoId, + LocalDate date + ) { + } +} diff --git a/src/main/java/com/Timo/Timo/domain/statistics/controller/StatisticsController.java b/src/main/java/com/Timo/Timo/domain/statistics/controller/StatisticsController.java new file mode 100644 index 00000000..dab78155 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/statistics/controller/StatisticsController.java @@ -0,0 +1,79 @@ +package com.Timo.Timo.domain.statistics.controller; + +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 com.Timo.Timo.domain.statistics.docs.StatisticsCalendarDocs; +import com.Timo.Timo.domain.statistics.docs.StatisticsDailyDocs; +import com.Timo.Timo.domain.statistics.docs.StatisticsSummaryDocs; +import com.Timo.Timo.domain.statistics.dto.response.StatisticsCalendarResponse; +import com.Timo.Timo.domain.statistics.dto.response.StatisticsDailyResponse; +import com.Timo.Timo.domain.statistics.dto.response.StatisticsSummaryResponse; +import com.Timo.Timo.domain.statistics.exception.StatisticsSuccessCode; +import com.Timo.Timo.domain.statistics.service.StatisticsService; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/statistics") +@RequiredArgsConstructor +@Tag(name = "Statistics", description = "통계 API") +public class StatisticsController implements StatisticsCalendarDocs, StatisticsSummaryDocs, StatisticsDailyDocs { + + private final StatisticsService statisticsService; + + @Override + @GetMapping("/calendar") + public ResponseEntity> getCalendar( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestParam(required = false) String yearMonth + ) { + StatisticsCalendarResponse response = statisticsService.getCalendar( + userDetails.getUserId(), + yearMonth + ); + + return ResponseEntity.ok( + BaseResponse.onSuccess(StatisticsSuccessCode.CALENDAR_RETRIEVED, response) + ); + } + + @Override + @GetMapping("/summary") + public ResponseEntity> getSummary( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestParam(required = false) String yearMonth + ) { + StatisticsSummaryResponse response = statisticsService.getSummary( + userDetails.getUserId(), + yearMonth + ); + + return ResponseEntity.ok( + BaseResponse.onSuccess(StatisticsSuccessCode.SUMMARY_RETRIEVED, response) + ); + } + + @Override + @GetMapping("/daily") + public ResponseEntity> getDaily( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestParam(required = false) String date + ) { + StatisticsDailyResponse response = statisticsService.getDaily( + userDetails.getUserId(), + date + ); + + return ResponseEntity.ok( + BaseResponse.onSuccess(StatisticsSuccessCode.DAILY_RETRIEVED, response) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/statistics/docs/StatisticsCalendarDocs.java b/src/main/java/com/Timo/Timo/domain/statistics/docs/StatisticsCalendarDocs.java new file mode 100644 index 00000000..b400d421 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/statistics/docs/StatisticsCalendarDocs.java @@ -0,0 +1,71 @@ +package com.Timo.Timo.domain.statistics.docs; + +import org.springframework.http.ResponseEntity; + +import com.Timo.Timo.domain.statistics.dto.response.StatisticsCalendarResponse; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.exception.dto.ErrorDto; +import com.Timo.Timo.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +public interface StatisticsCalendarDocs { + + @Operation( + summary = "통계 캘린더 조회", + description = """ + 지정한 연월의 일별 투두 완료 현황을 통계 캘린더에 표시하기 위한 API입니다. + + - yearMonth는 yyyy-MM 형식으로 전달합니다. + - today는 UTC 기준 오늘 날짜입니다. + - 조회 월의 모든 날짜를 날짜 오름차순으로 반환합니다. + - 날짜가 오늘 이후여도 작성된 투두가 있다면 완료율을 계산해서 반환합니다. + - 해당 날짜에 투두가 없으면 completionRate는 `0`으로 반환합니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "통계 캘린더 조회 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = "yearMonth 누락, 형식 오류, 유효하지 않은 연월", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> getCalendar( + @Parameter(hidden = true) CustomUserDetails userDetails, + @Parameter( + description = "조회할 연월. yyyy-MM 형식", + required = true, + example = "2026-06" + ) + String yearMonth + ); +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/statistics/docs/StatisticsDailyDocs.java b/src/main/java/com/Timo/Timo/domain/statistics/docs/StatisticsDailyDocs.java new file mode 100644 index 00000000..6b7c35a5 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/statistics/docs/StatisticsDailyDocs.java @@ -0,0 +1,76 @@ +package com.Timo.Timo.domain.statistics.docs; + +import org.springframework.http.ResponseEntity; + +import com.Timo.Timo.domain.statistics.dto.response.StatisticsDailyResponse; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.exception.dto.ErrorDto; +import com.Timo.Timo.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +public interface StatisticsDailyDocs { + + @Operation( + summary = "일별 기록 조회", + description = """ + 특정 날짜의 총 기록시간과 해당 날짜에 계획된 투두 목록을 조회합니다. + + 투두별로 투두명, 실제 소요 시간, 예상 소요 시간, 태그 정보를 제공합니다. + 실제 소요 시간은 해당 날짜의 타이머 기록 합계이며, 기록이 없으면 0분입니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "일별 기록 조회 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = "date 누락, 형식 오류, 유효하지 않은 날짜", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "사용자 정보를 찾을 수 없는 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> getDaily( + @Parameter(hidden = true) CustomUserDetails userDetails, + @Parameter( + description = "조회 날짜. yyyy-MM-dd 형식", + required = true, + example = "2026-06-28" + ) + String date + ); +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/statistics/docs/StatisticsSummaryDocs.java b/src/main/java/com/Timo/Timo/domain/statistics/docs/StatisticsSummaryDocs.java new file mode 100644 index 00000000..98cf0f00 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/statistics/docs/StatisticsSummaryDocs.java @@ -0,0 +1,70 @@ +package com.Timo.Timo.domain.statistics.docs; + +import org.springframework.http.ResponseEntity; + +import com.Timo.Timo.domain.statistics.dto.response.StatisticsSummaryResponse; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.exception.dto.ErrorDto; +import com.Timo.Timo.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +public interface StatisticsSummaryDocs { + + @Operation( + summary = "월별 통계 요약 조회", + description = """ + 지정한 연월의 전체 기록 시간, 활동일, 일평균, 누적 태스크를 조회합니다. + + - 전체 기록 시간: 해당 월에 기록된 타이머 시간의 총합 + - 활동일: 해당 월에 1개 이상의 투두를 생성한 날짜 수 + - 일평균: 타이머를 1회 이상 실행한 날짜들의 기록 시간 총합을 해당 날짜 수로 나눈 값 + - 누적 태스크: 해당 월에 작성된 전체 투두 수와 그중 완료한 투두 수 + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "월별 통계 요약 조회 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = "yearMonth 누락, 형식 오류, 유효하지 않은 연월", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> getSummary( + @Parameter(hidden = true) CustomUserDetails userDetails, + @Parameter( + description = "조회할 연월. yyyy-MM 형식", + required = true, + example = "2026-07" + ) + String yearMonth + ); +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/statistics/dto/response/StatisticsCalendarResponse.java b/src/main/java/com/Timo/Timo/domain/statistics/dto/response/StatisticsCalendarResponse.java new file mode 100644 index 00000000..d90f521a --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/statistics/dto/response/StatisticsCalendarResponse.java @@ -0,0 +1,27 @@ +package com.Timo.Timo.domain.statistics.dto.response; + +import java.time.LocalDate; +import java.util.List; + +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; + +public record StatisticsCalendarResponse( + @Schema(description = "조회한 연월", example = "2026-06") + String yearMonth, + + @Schema(description = "UTC 기준 오늘 날짜", example = "2026-06-28") + LocalDate today, + + @ArraySchema(schema = @Schema(description = "조회 월의 날짜별 완료 현황")) + List days +) { + + public record DayCompletionResponse( + @Schema(description = "통계 날짜", example = "2026-06-01") + LocalDate date, + + @Schema(description = "투두 완료율. 투두가 없으면 0", example = "50") + Integer completionRate + ) { } +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/statistics/dto/response/StatisticsDailyResponse.java b/src/main/java/com/Timo/Timo/domain/statistics/dto/response/StatisticsDailyResponse.java new file mode 100644 index 00000000..e5764748 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/statistics/dto/response/StatisticsDailyResponse.java @@ -0,0 +1,40 @@ +package com.Timo.Timo.domain.statistics.dto.response; + +import java.time.LocalDate; +import java.util.List; + +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; + +public record StatisticsDailyResponse( + @Schema(description = "조회 날짜", example = "2026-06-28") + LocalDate date, + + @Schema(description = "해당 날짜의 총 기록시간(분)", example = "260") + Long totalRecordMinutes, + + @ArraySchema(schema = @Schema(description = "해당 날짜에 계획된 투두 목록")) + List todos +) { + public record DailyTodoResponse( + @Schema(description = "투두 ID", example = "101") + Long todoId, + + @Schema(description = "투두명", example = "와이어프레임 제작") + String title, + + @Schema(description = "실제 소요 시간(분), 기록이 없으면 0", example = "90") + Long actualTimeMinutes, + + @Schema(description = "예상 소요 시간(분)", example = "60") + Long estimatedTimeMinutes, + + @Schema(description = "투두에 설정된 태그", nullable = true) + TagResponse tag + ) {} + + public record TagResponse( + @Schema(description = "태그명", example = "과제") + String name + ) {} +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/statistics/dto/response/StatisticsSummaryResponse.java b/src/main/java/com/Timo/Timo/domain/statistics/dto/response/StatisticsSummaryResponse.java new file mode 100644 index 00000000..5e21c1c0 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/statistics/dto/response/StatisticsSummaryResponse.java @@ -0,0 +1,20 @@ +package com.Timo.Timo.domain.statistics.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record StatisticsSummaryResponse( + @Schema(description = "해당 월의 전체 타이머 기록 시간(분)", example = "283200") + Long totalRecordMinutes, + + @Schema(description = "1개 이상의 투두를 생성한 날짜 수", example = "28") + Integer activeDayCount, + + @Schema(description = "타이머를 실행한 날짜 기준 일평균 기록 시간(분)", example = "204") + Long averageRecordedMinutes, + + @Schema(description = "해당 월에 완료한 투두 수", example = "80") + Integer completedTodoCount, + + @Schema(description = "해당 월에 작성된 전체 투두 수", example = "100") + Integer totalTodoCount +) {} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/statistics/exception/StatisticsErrorCode.java b/src/main/java/com/Timo/Timo/domain/statistics/exception/StatisticsErrorCode.java new file mode 100644 index 00000000..d95d45e8 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/statistics/exception/StatisticsErrorCode.java @@ -0,0 +1,24 @@ +package com.Timo.Timo.domain.statistics.exception; + +import org.springframework.http.HttpStatus; + +import com.Timo.Timo.global.exception.code.BaseErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum StatisticsErrorCode implements BaseErrorCode { + + YEAR_MONTH_REQUIRED(HttpStatus.BAD_REQUEST, "STATISTICS_400", "yearMonth는 필수입니다."), + INVALID_YEAR_MONTH_FORMAT(HttpStatus.BAD_REQUEST, "STATISTICS_400", "yearMonth는 yyyy-MM 형식이어야 합니다."), + INVALID_YEAR_MONTH(HttpStatus.BAD_REQUEST, "STATISTICS_400", "유효하지 않은 연월입니다."), + DATE_REQUIRED(HttpStatus.BAD_REQUEST, "STATISTICS_400", "조회 날짜는 필수입니다."), + INVALID_DATE_FORMAT(HttpStatus.BAD_REQUEST, "STATISTICS_400", "날짜는 yyyy-MM-dd 형식이어야 합니다."), + INVALID_DATE(HttpStatus.BAD_REQUEST, "STATISTICS_400", "유효하지 않은 날짜입니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/statistics/exception/StatisticsSuccessCode.java b/src/main/java/com/Timo/Timo/domain/statistics/exception/StatisticsSuccessCode.java new file mode 100644 index 00000000..fa64e8c5 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/statistics/exception/StatisticsSuccessCode.java @@ -0,0 +1,20 @@ +package com.Timo.Timo.domain.statistics.exception; + +import org.springframework.http.HttpStatus; + +import com.Timo.Timo.global.exception.code.BaseSuccessCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum StatisticsSuccessCode implements BaseSuccessCode { + + CALENDAR_RETRIEVED(HttpStatus.OK, "통계 캘린더를 조회했습니다."), + SUMMARY_RETRIEVED(HttpStatus.OK, "월별 통계 요약을 조회했습니다."), + DAILY_RETRIEVED(HttpStatus.OK, "일별 기록 조회에 성공했습니다."); + + private final HttpStatus httpStatus; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/statistics/service/StatisticsService.java b/src/main/java/com/Timo/Timo/domain/statistics/service/StatisticsService.java new file mode 100644 index 00000000..db2a47b9 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/statistics/service/StatisticsService.java @@ -0,0 +1,231 @@ +package com.Timo.Timo.domain.statistics.service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.Timo.Timo.domain.statistics.dto.response.StatisticsCalendarResponse; +import com.Timo.Timo.domain.statistics.dto.response.StatisticsCalendarResponse.DayCompletionResponse; +import com.Timo.Timo.domain.statistics.dto.response.StatisticsDailyResponse; +import com.Timo.Timo.domain.statistics.dto.response.StatisticsDailyResponse.DailyTodoResponse; +import com.Timo.Timo.domain.statistics.dto.response.StatisticsDailyResponse.TagResponse; +import com.Timo.Timo.domain.statistics.dto.response.StatisticsSummaryResponse; +import com.Timo.Timo.domain.statistics.support.StatisticsDateParser; +import com.Timo.Timo.domain.tag.entity.Tag; +import com.Timo.Timo.domain.tag.repository.TagRepository; +import com.Timo.Timo.domain.timer.repository.TimerDailyTodoStats; +import com.Timo.Timo.domain.timer.repository.TimerMonthlyRecordStats; +import com.Timo.Timo.domain.timer.repository.TimerRecordRepository; +import com.Timo.Timo.domain.todo.entity.Todo; +import com.Timo.Timo.domain.todo.entity.TodoInstance; +import com.Timo.Timo.domain.todo.repository.TodoDailyCompletionStats; +import com.Timo.Timo.domain.todo.repository.TodoInstanceRepository; +import com.Timo.Timo.domain.todo.repository.TodoMonthlySummaryStats; +import com.Timo.Timo.domain.todo.repository.TodoRepository; +import com.Timo.Timo.domain.user.entity.User; +import com.Timo.Timo.domain.user.exception.UserErrorCode; +import com.Timo.Timo.domain.user.repository.UserRepository; +import com.Timo.Timo.global.exception.CustomException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class StatisticsService { + + private static final DateTimeFormatter YEAR_MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM"); + private static final int SECONDS_PER_MINUTE = 60; + + private final TodoRepository todoRepository; + private final TodoInstanceRepository todoInstanceRepository; + private final TimerRecordRepository timerRecordRepository; + private final TagRepository tagRepository; + private final StatisticsDateParser statisticsDateParser; + private final UserRepository userRepository; + + public StatisticsCalendarResponse getCalendar(Long userId, String yearMonthValue) { + ZoneId userZone = getUserZone(userId); + YearMonth yearMonth = statisticsDateParser.parseYearMonth(yearMonthValue); + LocalDate today = LocalDate.now(userZone); + LocalDate startDate = yearMonth.atDay(1); + LocalDate endDate = yearMonth.atEndOfMonth(); + + Map dailyStats = todoRepository.findDailyCompletionStats( + userId, + startDate, + endDate + ).stream() + .collect(Collectors.toMap(TodoDailyCompletionStats::getDate, Function.identity())); + + List days = IntStream.rangeClosed(1, yearMonth.lengthOfMonth()) + .mapToObj(day -> { + LocalDate date = yearMonth.atDay(day); + return new DayCompletionResponse(date, calculateCompletionRate(dailyStats.get(date))); + }) + .toList(); + + return new StatisticsCalendarResponse( + yearMonth.format(YEAR_MONTH_FORMATTER), + today, + days + ); + } + + public StatisticsSummaryResponse getSummary(Long userId, String yearMonthValue) { + ZoneId userZone = getUserZone(userId); + YearMonth yearMonth = statisticsDateParser.parseYearMonth(yearMonthValue); + LocalDate startDate = yearMonth.atDay(1); + LocalDate nextMonthStartDate = yearMonth.plusMonths(1).atDay(1); + LocalDateTime fromInclusive = toUtcStartOfDay(startDate, userZone); + LocalDateTime toExclusive = toUtcStartOfDay(nextMonthStartDate, userZone); + + TimerMonthlyRecordStats timerStats = timerRecordRepository.findMonthlyRecordStats( + userId, + fromInclusive, + toExclusive + ); + TodoMonthlySummaryStats todoStats = todoRepository.findMonthlySummaryStats( + userId, + fromInclusive, + toExclusive + ); + + long totalRecordSeconds = timerStats.getTotalRecordSeconds(); + long timerRecordedDayCount = countDistinctUserDates( + timerRecordRepository.findMonthlyRecordedAtTimes(userId, fromInclusive, toExclusive), + userZone + ); + int activeDayCount = countDistinctUserDates( + todoRepository.findMonthlyTodoCreatedAtTimes(userId, fromInclusive, toExclusive), + userZone + ); + long averageRecordedMinutes = timerRecordedDayCount == 0 + ? 0L + : totalRecordSeconds / timerRecordedDayCount / SECONDS_PER_MINUTE; + + return new StatisticsSummaryResponse( + totalRecordSeconds / SECONDS_PER_MINUTE, + activeDayCount, + averageRecordedMinutes, + toInteger(todoStats.getCompletedTodoCount()), + toInteger(todoStats.getTotalTodoCount()) + ); + } + + public StatisticsDailyResponse getDaily(Long userId, String dateValue) { + ZoneId userZone = getUserZone(userId); + LocalDate date = statisticsDateParser.parseDate(dateValue); + LocalDate nextDate = date.plusDays(1); + LocalDateTime fromInclusive = toUtcStartOfDay(date, userZone); + LocalDateTime toExclusive = toUtcStartOfDay(nextDate, userZone); + long totalRecordSeconds = timerRecordRepository.sumActualSeconds( + userId, + fromInclusive, + toExclusive + ); + + Map actualSecondsByTodoId = timerRecordRepository.findDailyTodoStats( + userId, + fromInclusive, + toExclusive + ).stream() + .collect(Collectors.toMap(TimerDailyTodoStats::getTodoId, TimerDailyTodoStats::getActualSeconds)); + + List instances = todoInstanceRepository.findDailyInstances(userId, date); + Map tagNamesById = findTagNames(instances); + List todos = instances.stream() + .map(instance -> toDailyTodoResponse(instance, actualSecondsByTodoId, tagNamesById)) + .toList(); + + return new StatisticsDailyResponse(date, toMinutes(totalRecordSeconds), todos); + } + + private ZoneId getUserZone(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + return ZoneId.of(user.getZoneId()); + } + + private LocalDateTime toUtcStartOfDay(LocalDate date, ZoneId userZone) { + return date.atStartOfDay(userZone) + .withZoneSameInstant(ZoneOffset.UTC) + .toLocalDateTime(); + } + + private int countDistinctUserDates(List utcDateTimes, ZoneId userZone) { + return (int)utcDateTimes.stream() + .filter(Objects::nonNull) + .map(utcDateTime -> utcDateTime.atZone(ZoneOffset.UTC) + .withZoneSameInstant(userZone) + .toLocalDate()) + .distinct() + .count(); + } + + private int calculateCompletionRate(TodoDailyCompletionStats stats) { + if (stats == null || stats.getTotalCount() == null || stats.getTotalCount() == 0) { + return 0; + } + + long completedCount = stats.getCompletedCount() == null ? 0 : stats.getCompletedCount(); + return (int)Math.round(completedCount * 100.0 / stats.getTotalCount()); + } + + private DailyTodoResponse toDailyTodoResponse( + TodoInstance instance, + Map actualSecondsByTodoId, + Map tagNamesById + ) { + Todo todo = instance.getTodo(); + return new DailyTodoResponse( + todo.getId(), + todo.getTitle(), + toMinutes(actualSecondsByTodoId.getOrDefault(todo.getId(), 0L)), + toMinutes(todo.getDurationSeconds()), + toTagResponse(todo.getTagId(), tagNamesById) + ); + } + + private Map findTagNames(List instances) { + List tagIds = instances.stream() + .map(TodoInstance::getTodo) + .map(Todo::getTagId) + .filter(Objects::nonNull) + .distinct() + .toList(); + + return tagRepository.findAllById(tagIds).stream() + .collect(Collectors.toMap(Tag::getId, Tag::getName)); + } + + private TagResponse toTagResponse(Long tagId, Map tagNamesById) { + if (tagId == null || !tagNamesById.containsKey(tagId)) { + return null; + } + return new TagResponse(tagNamesById.get(tagId)); + } + + private long toMinutes(long seconds) { + return seconds / SECONDS_PER_MINUTE; + } + + private int toInteger(Long value) { + if (value == null) { + return 0; + } + return value.intValue(); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/statistics/support/StatisticsDateParser.java b/src/main/java/com/Timo/Timo/domain/statistics/support/StatisticsDateParser.java new file mode 100644 index 00000000..64a30f77 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/statistics/support/StatisticsDateParser.java @@ -0,0 +1,52 @@ +package com.Timo.Timo.domain.statistics.support; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +import org.springframework.stereotype.Component; + +import com.Timo.Timo.domain.statistics.exception.StatisticsErrorCode; +import com.Timo.Timo.global.exception.CustomException; + +@Component +public class StatisticsDateParser { + + private static final DateTimeFormatter YEAR_MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM"); + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final String YEAR_MONTH_PATTERN = "^\\d{4}-\\d{2}$"; + private static final String DATE_PATTERN = "^\\d{4}-\\d{2}-\\d{2}$"; + + public YearMonth parseYearMonth(String yearMonth) { + if (yearMonth == null || yearMonth.isBlank()) { + throw new CustomException(StatisticsErrorCode.YEAR_MONTH_REQUIRED); + } + + if (!yearMonth.matches(YEAR_MONTH_PATTERN)) { + throw new CustomException(StatisticsErrorCode.INVALID_YEAR_MONTH_FORMAT); + } + + try { + return YearMonth.parse(yearMonth, YEAR_MONTH_FORMATTER); + } catch (DateTimeParseException exception) { + throw new CustomException(StatisticsErrorCode.INVALID_YEAR_MONTH); + } + } + + public LocalDate parseDate(String date) { + if (date == null || date.isBlank()) { + throw new CustomException(StatisticsErrorCode.DATE_REQUIRED); + } + + if (!date.matches(DATE_PATTERN)) { + throw new CustomException(StatisticsErrorCode.INVALID_DATE_FORMAT); + } + + try { + return LocalDate.parse(date, DATE_FORMATTER); + } catch (DateTimeParseException exception) { + throw new CustomException(StatisticsErrorCode.INVALID_DATE); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/tag/controller/TagController.java b/src/main/java/com/Timo/Timo/domain/tag/controller/TagController.java new file mode 100644 index 00000000..1f79f2eb --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/tag/controller/TagController.java @@ -0,0 +1,71 @@ +package com.Timo.Timo.domain.tag.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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 com.Timo.Timo.domain.tag.docs.TagControllerDocs; +import com.Timo.Timo.domain.tag.dto.request.TagCreateRequest; +import com.Timo.Timo.domain.tag.dto.response.TagCreateResponse; +import com.Timo.Timo.domain.tag.dto.response.TagListResponse; +import com.Timo.Timo.domain.tag.exception.TagSuccessCode; +import com.Timo.Timo.domain.tag.service.TagService; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/tags") +@RequiredArgsConstructor +@Tag(name = "Tag", description = "태그 API") +public class TagController implements TagControllerDocs { + + private final TagService tagService; + + @Override + @PostMapping + public ResponseEntity> createTag( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody TagCreateRequest request + ) { + TagCreateResponse response = tagService.createTag(userDetails.getUserId(), request); + + return ResponseEntity + .status(TagSuccessCode.CREATED.getHttpStatus()) + .body(BaseResponse.onSuccess(TagSuccessCode.CREATED, response)); + } + + @Override + @GetMapping + public ResponseEntity> getTags( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + TagListResponse response = tagService.getTags(userDetails.getUserId()); + + return ResponseEntity + .status(TagSuccessCode.TAG_LIST_RETRIEVED.getHttpStatus()) + .body(BaseResponse.onSuccess(TagSuccessCode.TAG_LIST_RETRIEVED, response)); + } + + @Override + @DeleteMapping("/{tagId}") + public ResponseEntity> deleteTag( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long tagId + ) { + tagService.deleteTag(userDetails.getUserId(), tagId); + + return ResponseEntity + .status(TagSuccessCode.DELETED.getHttpStatus()) + .body(BaseResponse.onSuccess(TagSuccessCode.DELETED, null)); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/tag/docs/TagControllerDocs.java b/src/main/java/com/Timo/Timo/domain/tag/docs/TagControllerDocs.java new file mode 100644 index 00000000..faac515d --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/tag/docs/TagControllerDocs.java @@ -0,0 +1,164 @@ +package com.Timo.Timo.domain.tag.docs; + +import org.springframework.http.ResponseEntity; + +import com.Timo.Timo.domain.tag.dto.request.TagCreateRequest; +import com.Timo.Timo.domain.tag.dto.response.TagCreateResponse; +import com.Timo.Timo.domain.tag.dto.response.TagListResponse; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.exception.dto.ErrorDto; +import com.Timo.Timo.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +public interface TagControllerDocs { + + @Operation( + summary = "태그 생성", + description = """ + 신규 태그를 생성합니다. 생성된 태그는 요청한 사용자만 사용할 수 있으며 isDefault는 항상 false입니다. + 모든 사용자가 공유하는 기본 태그 또는 본인이 이미 만든 태그와 이름이 같으면 생성할 수 없습니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "201", + description = "태그 생성 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = "태그 이름이 누락되었거나 20자를 초과한 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "409", + description = "이미 존재하는 태그명인 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> createTag( + @Parameter(hidden = true) CustomUserDetails userDetails, + TagCreateRequest request + ); + + @Operation( + summary = "태그 목록 조회", + description = """ + 모든 사용자가 공유하는 기본 태그와 본인이 생성한 태그를 함께 조회합니다. + 기본 태그가 먼저 오고, 같은 그룹 안에서는 태그 ID 오름차순으로 정렬됩니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "태그 목록 조회 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> getTags( + @Parameter(hidden = true) CustomUserDetails userDetails + ); + + @Operation( + summary = "태그 삭제", + description = """ + 본인이 등록한 태그를 삭제합니다. + 모든 사용자가 공유하는 기본 태그는 삭제할 수 없으며, 다른 사용자의 태그는 조회되지 않습니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "태그 삭제 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = "태그 ID가 숫자가 아니거나 0 이하인 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "403", + description = "기본 태그처럼 삭제할 수 없는 태그인 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "태그가 존재하지 않거나 본인의 태그가 아닌 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> deleteTag( + @Parameter(hidden = true) CustomUserDetails userDetails, + @Parameter(description = "삭제할 태그 ID", example = "5") Long tagId + ); +} diff --git a/src/main/java/com/Timo/Timo/domain/tag/dto/request/TagCreateRequest.java b/src/main/java/com/Timo/Timo/domain/tag/dto/request/TagCreateRequest.java new file mode 100644 index 00000000..4fd3badd --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/tag/dto/request/TagCreateRequest.java @@ -0,0 +1,10 @@ +package com.Timo.Timo.domain.tag.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record TagCreateRequest( + @NotBlank(message = "태그 이름은 필수입니다.") + @Size(max = 10, message = "태그 이름은 10자를 초과할 수 없습니다.") + String name +) { } diff --git a/src/main/java/com/Timo/Timo/domain/tag/dto/response/TagCreateResponse.java b/src/main/java/com/Timo/Timo/domain/tag/dto/response/TagCreateResponse.java new file mode 100644 index 00000000..d83257a9 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/tag/dto/response/TagCreateResponse.java @@ -0,0 +1,16 @@ +package com.Timo.Timo.domain.tag.dto.response; + +import com.Timo.Timo.domain.tag.entity.Tag; +import com.fasterxml.jackson.annotation.JsonProperty; + +public record TagCreateResponse( + Long tagId, + String name, + @JsonProperty("isDefault") + boolean isDefault +) { + + public static TagCreateResponse from(Tag tag) { + return new TagCreateResponse(tag.getId(), tag.getName(), tag.isDefault()); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/tag/dto/response/TagListResponse.java b/src/main/java/com/Timo/Timo/domain/tag/dto/response/TagListResponse.java new file mode 100644 index 00000000..fefb9228 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/tag/dto/response/TagListResponse.java @@ -0,0 +1,27 @@ +package com.Timo.Timo.domain.tag.dto.response; + +import java.util.List; + +import com.Timo.Timo.domain.tag.entity.Tag; +import com.fasterxml.jackson.annotation.JsonProperty; + +public record TagListResponse( + List tags +) { + public static TagListResponse from(List tags) { + return new TagListResponse(tags.stream() + .map(TagResponse::from) + .toList()); + } + + public record TagResponse( + Long tagId, + String name, + @JsonProperty("isDefault") + boolean isDefault + ) { + public static TagResponse from(Tag tag) { + return new TagResponse(tag.getId(), tag.getName(), tag.isDefault()); + } + } +} diff --git a/src/main/java/com/Timo/Timo/domain/tag/entity/Tag.java b/src/main/java/com/Timo/Timo/domain/tag/entity/Tag.java new file mode 100644 index 00000000..823df099 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/tag/entity/Tag.java @@ -0,0 +1,53 @@ +package com.Timo.Timo.domain.tag.entity; + +import com.Timo.Timo.domain.user.entity.User; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "tags", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "name"}) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Tag { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Column(name = "name", nullable = false, length = 10) + private String name; + + @Column(name = "is_default", nullable = false) + private boolean isDefault; + + private Tag(User user, String name, boolean isDefault) { + this.user = user; + this.name = name; + this.isDefault = isDefault; + } + + public static Tag ofUser(User user, String name) { + return new Tag(user, name, false); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/tag/exception/TagErrorCode.java b/src/main/java/com/Timo/Timo/domain/tag/exception/TagErrorCode.java new file mode 100644 index 00000000..8237da84 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/tag/exception/TagErrorCode.java @@ -0,0 +1,24 @@ +package com.Timo.Timo.domain.tag.exception; + +import org.springframework.http.HttpStatus; + +import com.Timo.Timo.global.exception.code.BaseErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TagErrorCode implements BaseErrorCode { + + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "TAG_400", "태그 이름은 필수입니다."), + INVALID_TAG_ID(HttpStatus.BAD_REQUEST, "TAG_400", "유효하지 않은 태그 ID입니다."), + TAG_DELETE_FORBIDDEN(HttpStatus.FORBIDDEN, "TAG_403", "삭제할 수 없는 태그입니다."), + TAG_NOT_FOUND(HttpStatus.NOT_FOUND, "TAG_404", "존재하지 않는 태그입니다."), + DUPLICATE_TAG_NAME(HttpStatus.CONFLICT, "TAG_409", "이미 존재하는 태그명입니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/com/Timo/Timo/domain/tag/exception/TagExceptionHandler.java b/src/main/java/com/Timo/Timo/domain/tag/exception/TagExceptionHandler.java new file mode 100644 index 00000000..a16fee3b --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/tag/exception/TagExceptionHandler.java @@ -0,0 +1,74 @@ +package com.Timo.Timo.domain.tag.exception; + +import java.time.LocalDateTime; + +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import com.Timo.Timo.domain.tag.controller.TagController; +import com.Timo.Timo.global.exception.dto.ErrorDto; + +import jakarta.servlet.http.HttpServletRequest; + +@RestControllerAdvice(assignableTypes = TagController.class) +@Order(Ordered.HIGHEST_PRECEDENCE) +public class TagExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException( + MethodArgumentNotValidException exception, + HttpServletRequest request + ) { + FieldError fieldError = exception.getBindingResult().getFieldError(); + String message = fieldError != null && fieldError.getDefaultMessage() != null + ? fieldError.getDefaultMessage() + : TagErrorCode.INVALID_REQUEST.getMessage(); + + return createInvalidRequestResponse(message, request); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException( + HttpMessageNotReadableException exception, + HttpServletRequest request + ) { + return createInvalidRequestResponse(TagErrorCode.INVALID_REQUEST.getMessage(), request); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleMethodArgumentTypeMismatchException( + MethodArgumentTypeMismatchException exception, + HttpServletRequest request + ) { + return createErrorResponse(TagErrorCode.INVALID_TAG_ID, TagErrorCode.INVALID_TAG_ID.getMessage(), request); + } + + private ResponseEntity createInvalidRequestResponse(String message, HttpServletRequest request) { + return createErrorResponse(TagErrorCode.INVALID_REQUEST, message, request); + } + + private ResponseEntity createErrorResponse( + TagErrorCode errorCode, + String message, + HttpServletRequest request + ) { + ErrorDto response = new ErrorDto( + LocalDateTime.now(), + errorCode.getHttpStatus().value(), + errorCode.getCode(), + message, + request.getRequestURI() + ); + + return ResponseEntity + .status(errorCode.getHttpStatus()) + .body(response); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/tag/exception/TagSuccessCode.java b/src/main/java/com/Timo/Timo/domain/tag/exception/TagSuccessCode.java new file mode 100644 index 00000000..b0da21f8 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/tag/exception/TagSuccessCode.java @@ -0,0 +1,21 @@ +package com.Timo.Timo.domain.tag.exception; + +import org.springframework.http.HttpStatus; + +import com.Timo.Timo.global.exception.code.BaseSuccessCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TagSuccessCode implements BaseSuccessCode { + + CREATED(HttpStatus.CREATED, "태그가 생성되었습니다."), + DELETED(HttpStatus.OK, "태그가 삭제되었습니다."), + TAG_LIST_RETRIEVED(HttpStatus.OK, "태그 목록 조회 성공"), + ; + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/Timo/Timo/domain/tag/repository/TagRepository.java b/src/main/java/com/Timo/Timo/domain/tag/repository/TagRepository.java new file mode 100644 index 00000000..5c92a839 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/tag/repository/TagRepository.java @@ -0,0 +1,29 @@ +package com.Timo.Timo.domain.tag.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.Timo.Timo.domain.tag.entity.Tag; + +public interface TagRepository extends JpaRepository { + + @Query(""" + select count(t) > 0 from Tag t + where t.name = :name + and (t.isDefault = true or t.user.id = :userId) + """) + boolean existsAccessibleByName( + @Param("userId") Long userId, + @Param("name") String name + ); + + @Query(""" + select t from Tag t + where t.isDefault = true or t.user.id = :userId + order by t.isDefault desc, t.id asc + """) + List findAccessibleTags(@Param("userId") Long userId); +} diff --git a/src/main/java/com/Timo/Timo/domain/tag/service/TagService.java b/src/main/java/com/Timo/Timo/domain/tag/service/TagService.java new file mode 100644 index 00000000..8a1c21bf --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/tag/service/TagService.java @@ -0,0 +1,71 @@ +package com.Timo.Timo.domain.tag.service; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.Timo.Timo.domain.tag.dto.request.TagCreateRequest; +import com.Timo.Timo.domain.tag.dto.response.TagCreateResponse; +import com.Timo.Timo.domain.tag.dto.response.TagListResponse; +import com.Timo.Timo.domain.tag.entity.Tag; +import com.Timo.Timo.domain.tag.exception.TagErrorCode; +import com.Timo.Timo.domain.tag.repository.TagRepository; +import com.Timo.Timo.domain.user.entity.User; +import com.Timo.Timo.domain.user.exception.UserErrorCode; +import com.Timo.Timo.domain.user.repository.UserRepository; +import com.Timo.Timo.global.exception.CustomException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class TagService { + + private final TagRepository tagRepository; + private final UserRepository userRepository; + + public TagCreateResponse createTag(Long userId, TagCreateRequest request) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + if (tagRepository.existsAccessibleByName(userId, request.name())) { + throw new CustomException(TagErrorCode.DUPLICATE_TAG_NAME); + } + + try { + Tag tag = tagRepository.saveAndFlush(Tag.ofUser(user, request.name())); + return TagCreateResponse.from(tag); + } catch (DataIntegrityViolationException exception) { + throw new CustomException(TagErrorCode.DUPLICATE_TAG_NAME); + } + } + + @Transactional(readOnly = true) + public TagListResponse getTags(Long userId) { + return TagListResponse.from(tagRepository.findAccessibleTags(userId)); + } + + public void deleteTag(Long userId, Long tagId) { + if (tagId == null || tagId <= 0) { + throw new CustomException(TagErrorCode.INVALID_TAG_ID); + } + + Tag tag = tagRepository.findById(tagId) + .orElseThrow(() -> new CustomException(TagErrorCode.TAG_NOT_FOUND)); + + if (tag.isDefault()) { + throw new CustomException(TagErrorCode.TAG_DELETE_FORBIDDEN); + } + + if (!isOwnedBy(tag, userId)) { + throw new CustomException(TagErrorCode.TAG_NOT_FOUND); + } + + tagRepository.delete(tag); + } + + private boolean isOwnedBy(Tag tag, Long userId) { + return tag.getUser() != null && tag.getUser().getId().equals(userId); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/terms/controller/TermsController.java b/src/main/java/com/Timo/Timo/domain/terms/controller/TermsController.java new file mode 100644 index 00000000..62085eba --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/terms/controller/TermsController.java @@ -0,0 +1,37 @@ +package com.Timo.Timo.domain.terms.controller; + +import org.springframework.http.ResponseEntity; +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 com.Timo.Timo.domain.terms.docs.TermsControllerDocs; +import com.Timo.Timo.domain.terms.dto.response.TermsListResponse; +import com.Timo.Timo.domain.terms.exception.TermsSuccessCode; +import com.Timo.Timo.domain.terms.service.TermsService; +import com.Timo.Timo.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/terms") +@RequiredArgsConstructor +@Tag(name = "Terms", description = "약관 API") +public class TermsController implements TermsControllerDocs { + + private final TermsService termsService; + + @Override + @GetMapping + public ResponseEntity> getTerms( + @RequestParam(required = false) String type + ) { + TermsListResponse response = termsService.getTerms(type); + + return ResponseEntity.ok( + BaseResponse.onSuccess(TermsSuccessCode.TERMS_RETRIEVED, response) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/terms/docs/TermsControllerDocs.java b/src/main/java/com/Timo/Timo/domain/terms/docs/TermsControllerDocs.java new file mode 100644 index 00000000..2539e8cd --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/terms/docs/TermsControllerDocs.java @@ -0,0 +1,50 @@ +package com.Timo.Timo.domain.terms.docs; + +import org.springframework.http.ResponseEntity; + +import com.Timo.Timo.domain.terms.dto.response.TermsListResponse; +import com.Timo.Timo.global.exception.dto.ErrorDto; +import com.Timo.Timo.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +public interface TermsControllerDocs { + @Operation( + summary = "약관 내용 조회", + description = """ + 서비스 이용약관 및 개인정보 처리방침을 조회합니다. + type을 지정하지 않으면 전체 약관을 조회하고, SERVICE 또는 PRIVACY를 지정하면 해당 약관만 조회합니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "약관 조회 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 type 값", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> getTerms( + @Parameter(description = "약관 타입(SERVICE, PRIVACY)", example = "SERVICE") String type + ); +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/terms/dto/response/TermsListResponse.java b/src/main/java/com/Timo/Timo/domain/terms/dto/response/TermsListResponse.java new file mode 100644 index 00000000..1259e59d --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/terms/dto/response/TermsListResponse.java @@ -0,0 +1,24 @@ +package com.Timo.Timo.domain.terms.dto.response; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record TermsListResponse( + @Schema(description = "약관 목록") + List terms +) { + public record TermsResponse( + @Schema(description = "약관 ID") + Long termsId, + + @Schema(description = "약관 타입") + String type, + + @Schema(description = "약관 제목") + String title, + + @Schema(description = "약관 전문") + String content + ) {} +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/terms/entity/Terms.java b/src/main/java/com/Timo/Timo/domain/terms/entity/Terms.java new file mode 100644 index 00000000..071f13d7 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/terms/entity/Terms.java @@ -0,0 +1,38 @@ +package com.Timo.Timo.domain.terms.entity; + +import com.Timo.Timo.domain.terms.enums.TermsType; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "terms") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Terms { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 20) + private TermsType type; + + @Column(name = "title", nullable = false, length = 100) + private String title; + + @Lob + @Column(name = "content", nullable = false) + private String content; +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/terms/enums/TermsType.java b/src/main/java/com/Timo/Timo/domain/terms/enums/TermsType.java new file mode 100644 index 00000000..0949331a --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/terms/enums/TermsType.java @@ -0,0 +1,17 @@ +package com.Timo.Timo.domain.terms.enums; + +import com.Timo.Timo.global.exception.CustomException; +import com.Timo.Timo.global.exception.code.ErrorCode; + +public enum TermsType { + SERVICE, + PRIVACY; + + public static TermsType from(String value) { + try { + return TermsType.valueOf(value.trim()); + } catch (IllegalArgumentException exception) { + throw new CustomException(ErrorCode.BAD_REQUEST); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/terms/exception/TermsSuccessCode.java b/src/main/java/com/Timo/Timo/domain/terms/exception/TermsSuccessCode.java new file mode 100644 index 00000000..f75628b6 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/terms/exception/TermsSuccessCode.java @@ -0,0 +1,17 @@ +package com.Timo.Timo.domain.terms.exception; + +import org.springframework.http.HttpStatus; + +import com.Timo.Timo.global.exception.code.BaseSuccessCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TermsSuccessCode implements BaseSuccessCode { + TERMS_RETRIEVED(HttpStatus.OK, "약관을 조회했습니다."); + + private final HttpStatus httpStatus; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/terms/repository/TermsRepository.java b/src/main/java/com/Timo/Timo/domain/terms/repository/TermsRepository.java new file mode 100644 index 00000000..5ea403fe --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/terms/repository/TermsRepository.java @@ -0,0 +1,13 @@ +package com.Timo.Timo.domain.terms.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.Timo.Timo.domain.terms.entity.Terms; +import com.Timo.Timo.domain.terms.enums.TermsType; + +public interface TermsRepository extends JpaRepository { + List findAllByOrderByIdAsc(); + List findAllByTypeOrderByIdAsc(TermsType type); +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/terms/service/TermsService.java b/src/main/java/com/Timo/Timo/domain/terms/service/TermsService.java new file mode 100644 index 00000000..ebcf1123 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/terms/service/TermsService.java @@ -0,0 +1,51 @@ +package com.Timo.Timo.domain.terms.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.Timo.Timo.domain.terms.dto.response.TermsListResponse; +import com.Timo.Timo.domain.terms.dto.response.TermsListResponse.TermsResponse; +import com.Timo.Timo.domain.terms.entity.Terms; +import com.Timo.Timo.domain.terms.enums.TermsType; +import com.Timo.Timo.domain.terms.repository.TermsRepository; +import com.Timo.Timo.global.exception.CustomException; +import com.Timo.Timo.global.exception.code.ErrorCode; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TermsService { + private final TermsRepository termsRepository; + + public TermsListResponse getTerms(String type) { + List terms = type == null + ? termsRepository.findAllByOrderByIdAsc() + : termsRepository.findAllByTypeOrderByIdAsc(parseType(type)); + + return new TermsListResponse( + terms.stream() + .map(this::toResponse) + .toList() + ); + } + + private TermsType parseType(String type) { + if (type.isBlank()) { + throw new CustomException(ErrorCode.BAD_REQUEST); + } + return TermsType.from(type); + } + + private TermsResponse toResponse(Terms terms) { + return new TermsResponse( + terms.getId(), + terms.getType().name(), + terms.getTitle(), + terms.getContent() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/timebox/controller/TimeBoxController.java b/src/main/java/com/Timo/Timo/domain/timebox/controller/TimeBoxController.java new file mode 100644 index 00000000..56499711 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timebox/controller/TimeBoxController.java @@ -0,0 +1,45 @@ +package com.Timo.Timo.domain.timebox.controller; + +import java.util.List; + +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 com.Timo.Timo.domain.timebox.docs.TimeBoxControllerDocs; +import com.Timo.Timo.domain.timebox.dto.response.TimeBoxResponse; +import com.Timo.Timo.domain.timebox.exception.TimeBoxSuccessCode; +import com.Timo.Timo.domain.timebox.service.TimeBoxService; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/time-boxes") +@RequiredArgsConstructor +@Tag(name = "Time Box", description = "타임박스 API") +public class TimeBoxController implements TimeBoxControllerDocs { + + private final TimeBoxService timeBoxService; + + @Override + @GetMapping + public ResponseEntity>> getTimeBoxes( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestParam(required = false) String date + ) { + List response = timeBoxService.getTimeBoxes( + userDetails.getUserId(), + date + ); + + return ResponseEntity.ok( + BaseResponse.onSuccess(TimeBoxSuccessCode.TIME_BOXES_RETRIEVED, response) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/timebox/docs/TimeBoxControllerDocs.java b/src/main/java/com/Timo/Timo/domain/timebox/docs/TimeBoxControllerDocs.java new file mode 100644 index 00000000..0e006a04 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timebox/docs/TimeBoxControllerDocs.java @@ -0,0 +1,74 @@ +package com.Timo.Timo.domain.timebox.docs; + +import java.util.List; + +import org.springframework.http.ResponseEntity; + +import com.Timo.Timo.domain.timebox.dto.response.TimeBoxResponse; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.exception.dto.ErrorDto; +import com.Timo.Timo.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +public interface TimeBoxControllerDocs { + + @Operation( + summary = "날짜별 타임박스 조회", + description = """ + 지정한 날짜의 타임라인에 표시할 타임박스 목록을 조회합니다. + 실제로 타이머가 작동한 TimerSession 단위로 반환하므로 일시정지 구간은 제외됩니다. + 타이머 재개 시 같은 timerId에 새로운 sessionId를 가진 타임박스가 생성됩니다. + startAction과 endAction으로 시작, 일시정지, 재개, 완료 시점을 구분합니다. + 사용자 종료와 예상시간 완료는 모두 COMPLETE 액션으로 반환합니다. + 완료 액션이 있는 마지막 타임박스에만 전체 실제 수행 시간을 분 단위로 반환합니다. + 진행 중인 구간은 endedAt과 endAction이 null로 반환됩니다. + 조회 결과가 없으면 빈 배열을 반환합니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "타임박스 조회 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = "date 누락, 형식 오류 또는 유효하지 않은 날짜", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity>> getTimeBoxes( + @Parameter(hidden = true) CustomUserDetails userDetails, + @Parameter( + description = "조회 날짜", + required = true, + example = "2026-07-01" + ) + String date + ); +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/timebox/dto/response/TimeBoxResponse.java b/src/main/java/com/Timo/Timo/domain/timebox/dto/response/TimeBoxResponse.java new file mode 100644 index 00000000..6c6e91f1 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timebox/dto/response/TimeBoxResponse.java @@ -0,0 +1,53 @@ +package com.Timo.Timo.domain.timebox.dto.response; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import com.Timo.Timo.domain.timebox.enums.TimeBoxAction; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record TimeBoxResponse( + @Schema(description = "타임박스의 원본이 되는 타이머 세션 ID", example = "201") + Long sessionId, + + @Schema(description = "타이머 기록 ID", example = "101") + Long timerId, + + @Schema(description = "투두 ID", example = "12") + Long todoId, + + @Schema(description = "투두명", example = "밀린 집 정리") + String todoName, + + @Schema(description = "타임박스 표시 날짜", example = "2026-07-01") + LocalDate date, + + @Schema(description = "해당 날짜에 표시할 시작 일시", example = "2026-07-01T11:30:00") + LocalDateTime startedAt, + + @Schema( + description = "시작 일시의 액션. 이전 날짜부터 이어진 구간이면 null", + allowableValues = {"START", "RESUME"}, + nullable = true + ) + TimeBoxAction startAction, + + @Schema(description = "해당 날짜에 표시할 종료 일시. 진행 중이면 null", + example = "2026-07-01T13:30:00", nullable = true) + LocalDateTime endedAt, + + @Schema( + description = "종료 일시의 액션. 진행 중이거나 다음 날짜로 이어지면 null", + allowableValues = {"PAUSE", "COMPLETE"}, + nullable = true + ) + TimeBoxAction endAction, + + @Schema( + description = "전체 실제 수행 시간(분). 완료된 마지막 타임박스에만 포함", + example = "70", + nullable = true + ) + Integer actualMinutes +) {} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/timebox/enums/TimeBoxAction.java b/src/main/java/com/Timo/Timo/domain/timebox/enums/TimeBoxAction.java new file mode 100644 index 00000000..035d4fb8 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timebox/enums/TimeBoxAction.java @@ -0,0 +1,8 @@ +package com.Timo.Timo.domain.timebox.enums; + +public enum TimeBoxAction { + START, + PAUSE, + RESUME, + COMPLETE +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/timebox/exception/TimeBoxErrorCode.java b/src/main/java/com/Timo/Timo/domain/timebox/exception/TimeBoxErrorCode.java new file mode 100644 index 00000000..487b6649 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timebox/exception/TimeBoxErrorCode.java @@ -0,0 +1,21 @@ +package com.Timo.Timo.domain.timebox.exception; + +import org.springframework.http.HttpStatus; + +import com.Timo.Timo.global.exception.code.BaseErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TimeBoxErrorCode implements BaseErrorCode { + + DATE_REQUIRED(HttpStatus.BAD_REQUEST, "COMMON_400", "date는 필수입니다."), + INVALID_DATE_FORMAT(HttpStatus.BAD_REQUEST, "COMMON_400", "date는 yyyy-MM-dd 형식이어야 합니다."), + INVALID_DATE(HttpStatus.BAD_REQUEST, "COMMON_400", "유효하지 않은 날짜입니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/timebox/exception/TimeBoxSuccessCode.java b/src/main/java/com/Timo/Timo/domain/timebox/exception/TimeBoxSuccessCode.java new file mode 100644 index 00000000..cab16ecb --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timebox/exception/TimeBoxSuccessCode.java @@ -0,0 +1,18 @@ +package com.Timo.Timo.domain.timebox.exception; + +import org.springframework.http.HttpStatus; + +import com.Timo.Timo.global.exception.code.BaseSuccessCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TimeBoxSuccessCode implements BaseSuccessCode { + + TIME_BOXES_RETRIEVED(HttpStatus.OK, "타임박스를 조회했습니다."); + + private final HttpStatus httpStatus; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/timebox/service/TimeBoxService.java b/src/main/java/com/Timo/Timo/domain/timebox/service/TimeBoxService.java new file mode 100644 index 00000000..2fd64d6d --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timebox/service/TimeBoxService.java @@ -0,0 +1,159 @@ +package com.Timo.Timo.domain.timebox.service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.Timo.Timo.domain.timebox.dto.response.TimeBoxResponse; +import com.Timo.Timo.domain.timebox.enums.TimeBoxAction; +import com.Timo.Timo.domain.timebox.support.TimeBoxDateParser; +import com.Timo.Timo.domain.timer.entity.TimerRecord; +import com.Timo.Timo.domain.timer.entity.TimerSession; +import com.Timo.Timo.domain.timer.repository.TimerSessionRepository; +import com.Timo.Timo.domain.user.entity.User; +import com.Timo.Timo.domain.user.exception.UserErrorCode; +import com.Timo.Timo.domain.user.repository.UserRepository; +import com.Timo.Timo.global.exception.CustomException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TimeBoxService { + + private static final int SECONDS_PER_MINUTE = 60; + + private final TimerSessionRepository timerSessionRepository; + private final UserRepository userRepository; + private final TimeBoxDateParser dateParser; + + public List getTimeBoxes(Long userId, String dateValue) { + LocalDate date = dateParser.parse(dateValue); + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + ZoneId userZoneId = ZoneId.of(user.getZoneId()); + + LocalDateTime dayStart = date.atStartOfDay(); + LocalDateTime nextDayStart = date.plusDays(1).atStartOfDay(); + LocalDateTime fromInclusiveUtc = toUtc(dayStart, userZoneId); + LocalDateTime toExclusiveUtc = toUtc(nextDayStart, userZoneId); + LocalDateTime nowUtc = LocalDateTime.now(ZoneOffset.UTC); + + return timerSessionRepository.findTimeBoxSessions( + userId, + fromInclusiveUtc, + toExclusiveUtc, + nowUtc + ).stream() + .map(session -> toResponse(session, date, dayStart, nextDayStart, userZoneId, nowUtc)) + .toList(); + } + + private TimeBoxResponse toResponse( + TimerSession session, + LocalDate date, + LocalDateTime dayStart, + LocalDateTime nextDayStart, + ZoneId userZoneId, + LocalDateTime nowUtc + ) { + TimerRecord record = session.getTimerRecord(); + LocalDateTime localStartedAt = toUserTime(session.getStartedAt(), userZoneId); + LocalDateTime startedAt = localStartedAt.isBefore(dayStart) ? dayStart : localStartedAt; + LocalDateTime endedAt = resolveEndedAt(session, date, nextDayStart, userZoneId, nowUtc); + TimeBoxAction startAction = resolveStartAction(session, record, localStartedAt, dayStart, nextDayStart); + TimeBoxAction endAction = resolveEndAction(session, record, userZoneId, dayStart, nextDayStart); + + return new TimeBoxResponse( + session.getId(), + record.getId(), + record.getTodo().getId(), + record.getTodo().getTitle(), + date, + startedAt, + startAction, + endedAt, + endAction, + toActualMinutes(record, endAction) + ); + } + + private LocalDateTime resolveEndedAt( + TimerSession session, + LocalDate date, + LocalDateTime nextDayStart, + ZoneId userZoneId, + LocalDateTime nowUtc + ) { + if (session.getPausedAt() != null) { + LocalDateTime localEndedAt = toUserTime(session.getPausedAt(), userZoneId); + return localEndedAt.isAfter(nextDayStart) ? nextDayStart : localEndedAt; + } + + LocalDate today = toUserTime(nowUtc, userZoneId).toLocalDate(); + return date.isBefore(today) ? nextDayStart : null; + } + + private TimeBoxAction resolveStartAction( + TimerSession session, + TimerRecord record, + LocalDateTime localStartedAt, + LocalDateTime dayStart, + LocalDateTime nextDayStart + ) { + if (localStartedAt.isBefore(dayStart) || !localStartedAt.isBefore(nextDayStart)) { + return null; + } + return session.getStartedAt().equals(record.getStartedAt()) + ? TimeBoxAction.START + : TimeBoxAction.RESUME; + } + + private TimeBoxAction resolveEndAction( + TimerSession session, + TimerRecord record, + ZoneId userZoneId, + LocalDateTime dayStart, + LocalDateTime nextDayStart + ) { + if (session.getPausedAt() == null) { + return null; + } + + LocalDateTime localPausedAt = toUserTime(session.getPausedAt(), userZoneId); + if (localPausedAt.isBefore(dayStart) || localPausedAt.isAfter(nextDayStart)) { + return null; + } + + return session.getPausedAt().equals(record.getEndedAt()) + ? TimeBoxAction.COMPLETE + : TimeBoxAction.PAUSE; + } + + private Integer toActualMinutes(TimerRecord record, TimeBoxAction endAction) { + if (endAction != TimeBoxAction.COMPLETE || record.getActualSeconds() == null) { + return null; + } + return record.getActualSeconds() / SECONDS_PER_MINUTE; + } + + private LocalDateTime toUtc(LocalDateTime localDateTime, ZoneId userZoneId) { + return localDateTime + .atZone(userZoneId) + .withZoneSameInstant(ZoneOffset.UTC) + .toLocalDateTime(); + } + + private LocalDateTime toUserTime(LocalDateTime utcDateTime, ZoneId userZoneId) { + return utcDateTime + .atZone(ZoneOffset.UTC) + .withZoneSameInstant(userZoneId) + .toLocalDateTime(); + } +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/timebox/support/TimeBoxDateParser.java b/src/main/java/com/Timo/Timo/domain/timebox/support/TimeBoxDateParser.java new file mode 100644 index 00000000..d069de94 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timebox/support/TimeBoxDateParser.java @@ -0,0 +1,34 @@ +package com.Timo.Timo.domain.timebox.support; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.format.ResolverStyle; + +import org.springframework.stereotype.Component; + +import com.Timo.Timo.domain.timebox.exception.TimeBoxErrorCode; +import com.Timo.Timo.global.exception.CustomException; + +@Component +public class TimeBoxDateParser { + + private static final String DATE_PATTERN = "^\\d{4}-\\d{2}-\\d{2}$"; + private static final DateTimeFormatter DATE_FORMATTER = + DateTimeFormatter.ofPattern("uuuu-MM-dd").withResolverStyle(ResolverStyle.STRICT); + + public LocalDate parse(String date) { + if (date == null || date.isBlank()) { + throw new CustomException(TimeBoxErrorCode.DATE_REQUIRED); + } + if (!date.matches(DATE_PATTERN)) { + throw new CustomException(TimeBoxErrorCode.INVALID_DATE_FORMAT); + } + + try { + return LocalDate.parse(date, DATE_FORMATTER); + } catch (DateTimeParseException exception) { + throw new CustomException(TimeBoxErrorCode.INVALID_DATE); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java new file mode 100644 index 00000000..c0ec6813 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java @@ -0,0 +1,133 @@ +package com.Timo.Timo.domain.timer.controller; + +import com.Timo.Timo.domain.timer.docs.TimerActiveControllerDocs; +import com.Timo.Timo.domain.timer.docs.TimerCompleteControllerDocs; +import com.Timo.Timo.domain.timer.docs.TimerExtendControllerDocs; +import com.Timo.Timo.domain.timer.docs.TimerStartControllerDocs; +import com.Timo.Timo.domain.timer.docs.TimerStatusControllerDocs; +import com.Timo.Timo.domain.timer.docs.TimerStopControllerDocs; +import com.Timo.Timo.domain.timer.dto.request.TimerActionRequest; +import com.Timo.Timo.domain.timer.dto.request.TimerExtendRequest; +import com.Timo.Timo.domain.timer.dto.response.TimerExtendResponse; +import com.Timo.Timo.domain.timer.dto.response.TimerActiveResponse; +import com.Timo.Timo.domain.timer.dto.response.TimerFinishResponse; +import com.Timo.Timo.domain.timer.dto.response.TimerStartResponse; +import com.Timo.Timo.domain.timer.dto.response.TimerStatusResponse; +import com.Timo.Timo.domain.timer.exception.TimerSuccessCode; +import com.Timo.Timo.domain.timer.service.TimerService; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.response.BaseResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +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; + +@Tag(name = "Timer", description = "타이머 API") +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class TimerController implements TimerStartControllerDocs, TimerStatusControllerDocs, + TimerActiveControllerDocs, TimerCompleteControllerDocs, + TimerStopControllerDocs, TimerExtendControllerDocs { + + private final TimerService timerService; + + @Override + @PostMapping("/todos/{todoId}/timers/start") + public ResponseEntity> startTimer( + @PathVariable Long todoId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + + Long userId = userDetails.getUserId(); + TimerStartResponse response = timerService.startTimer(userId, todoId); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(BaseResponse.onSuccess(TimerSuccessCode.TIMER_STARTED, response)); + } + + @Override + @PatchMapping("timers/{timerId}/status") + public ResponseEntity> changeStatus( + @PathVariable Long timerId, + @Valid @RequestBody TimerActionRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Long userId = userDetails.getUserId(); + TimerStatusResponse response = timerService.changeStatus(userId, timerId, request.action()); + + TimerSuccessCode successCode = switch (request.action()) { + case PAUSE -> TimerSuccessCode.TIMER_PAUSED; + case RESUME -> TimerSuccessCode.TIMER_RESUMED; + }; + + return ResponseEntity.ok() + .body(BaseResponse.onSuccess(successCode, response)); + } + + @Override + @PatchMapping("timers/{timerId}/extend") + public ResponseEntity> extendTimer( + @PathVariable Long timerId, + @Valid @RequestBody TimerExtendRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Long userId = userDetails.getUserId(); + TimerExtendResponse response = timerService.extendTimer(userId, timerId, + request.extendMinutes()); + + return ResponseEntity.ok() + .body(BaseResponse.onSuccess(TimerSuccessCode.TIMER_EXTENDED, response)); + } + + @Override + @GetMapping("/timers/active") + public ResponseEntity> getActiveTimer( + @AuthenticationPrincipal CustomUserDetails userDetails + ){ + Long userId = userDetails.getUserId(); + TimerActiveResponse response = timerService.getActiveTimer(userId); + + TimerSuccessCode successCode = response != null + ? TimerSuccessCode.TIMER_ACTIVE_FOUND + : TimerSuccessCode.TIMER_ACTIVE_NOT_FOUND; + + return ResponseEntity.ok() + .body(BaseResponse.onSuccess(successCode, response)); + } + + @Override + @PatchMapping("/timers/{timerId}/complete") + public ResponseEntity> completeTimer( + @PathVariable Long timerId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Long userId = userDetails.getUserId(); + TimerFinishResponse response = timerService.completeTimer(userId, timerId); + + return ResponseEntity.ok() + .body(BaseResponse.onSuccess(TimerSuccessCode.TIMER_COMPLETED, response)); + } + + @Override + @PatchMapping("/timers/{timerId}/stop") + public ResponseEntity> stopTimer( + @PathVariable Long timerId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Long userId = userDetails.getUserId(); + TimerFinishResponse response = timerService.stopTimer(userId, timerId); + + return ResponseEntity.ok() + .body(BaseResponse.onSuccess(TimerSuccessCode.TIMER_STOPPED, response)); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerActiveControllerDocs.java b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerActiveControllerDocs.java new file mode 100644 index 00000000..5e2809f2 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerActiveControllerDocs.java @@ -0,0 +1,51 @@ +package com.Timo.Timo.domain.timer.docs; + +import com.Timo.Timo.domain.timer.dto.response.TimerActiveResponse; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.exception.dto.ErrorDto; +import com.Timo.Timo.global.response.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import org.springframework.http.ResponseEntity; + +public interface TimerActiveControllerDocs { + + @Operation( + summary = "현재 실행 중인 타이머 조회", + description = """ + 로그인한 사용자의 현재 실행 중(RUNNING/PAUSED)인 타이머를 단건 조회합니다. + 한 사용자당 시작 이후 완료/종료되지 않은 타이머는 최대 1개만 존재할 수 있습니다. + 실행 중인 타이머가 없으면 data: null을 반환합니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "조회 성공 (실행 중인 타이머 유무와 무관하게 200 반환)", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> getActiveTimer( + @Parameter(hidden = true) CustomUserDetails userDetails + ); +} diff --git a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerCompleteControllerDocs.java b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerCompleteControllerDocs.java new file mode 100644 index 00000000..204bc21a --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerCompleteControllerDocs.java @@ -0,0 +1,80 @@ +package com.Timo.Timo.domain.timer.docs; + +import com.Timo.Timo.domain.timer.dto.response.TimerFinishResponse; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.exception.dto.ErrorDto; +import com.Timo.Timo.global.response.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; + +public interface TimerCompleteControllerDocs { + + @Operation( + summary = "타이머 시간 완료", + description = """ + 예상 소요 시간이 모두 경과하여 타이머를 자동 종료합니다. + 종료 시각 기록, 실제 수행 시간 계산 (status → COMPLETED) + 해당 날짜 TodoInstance 완료 처리 및 타이머 상태 초기화 + 계획 시간과 실제 수행 기록을 분석한 AI 피드백 반환 + AI 피드백 생성에 실패해도 타이머 완료는 정상 처리되며 aiFeedback은 null로 반환 + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "타이머 완료 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "403", + description = "본인 소유의 타이머가 아닌 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 타이머인 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "409", + description = "이미 종료된 타이머인 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> completeTimer( + @Parameter(description = "타이머 기록 ID", example = "10") + @PathVariable Long timerId, + @Parameter(hidden = true) CustomUserDetails userDetails + ); +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerExtendControllerDocs.java b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerExtendControllerDocs.java new file mode 100644 index 00000000..70765436 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerExtendControllerDocs.java @@ -0,0 +1,90 @@ +package com.Timo.Timo.domain.timer.docs; + +import com.Timo.Timo.domain.timer.dto.request.TimerExtendRequest; +import com.Timo.Timo.domain.timer.dto.response.TimerExtendResponse; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.exception.dto.ErrorDto; +import com.Timo.Timo.global.response.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +public interface TimerExtendControllerDocs { + + @Operation( + summary = "타이머 연장", + description = """ + 사용자가 입력한 연장 시간을 현재 타이머에 반영합니다. + 연장 시간(분)을 초로 변환하여 extended_seconds에 누적 + RUNNING, PAUSED 상태 모두에서 호출 가능하며, 타이머 상태(status)는 변경되지 않습니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "타이머 연장 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 데이터 형식 (연장 시간 값 오류)", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "403", + description = "본인 소유의 타이머가 아닌 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 타이머인 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "409", + description = "이미 종료된 타이머인 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> extendTimer( + @Parameter(description = "타이머 기록 ID", example = "10") + @PathVariable Long timerId, + @Valid @RequestBody TimerExtendRequest request, + @Parameter(hidden = true) CustomUserDetails userDetails + ); +} diff --git a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStartControllerDocs.java b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStartControllerDocs.java new file mode 100644 index 00000000..d7ec365c --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStartControllerDocs.java @@ -0,0 +1,77 @@ +package com.Timo.Timo.domain.timer.docs; + +import com.Timo.Timo.domain.timer.dto.response.TimerStartResponse; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.exception.dto.ErrorDto; +import com.Timo.Timo.global.response.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; + +public interface TimerStartControllerDocs { + + @Operation( + summary = "타이머 시작", + description = """ + 투두의 타이머를 시작합니다.
+ 한 번에 한 개의 타이머만 실행 가능하며, 이미 실행/일시정지 중인 타이머가 있으면 409를 반환합니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "201", + description = "타이머 시작 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "403", + description = "본인 소유의 투두가 아닌 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 투두인 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "409", + description = "이미 실행 중인 타이머가 있는 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> startTimer( + @Parameter(description = "타이머를 시작할 투두 ID", example = "3") + @PathVariable Long todoId, + @Parameter(hidden = true) CustomUserDetails userDetails + ); +} diff --git a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStatusControllerDocs.java b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStatusControllerDocs.java new file mode 100644 index 00000000..634a7633 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStatusControllerDocs.java @@ -0,0 +1,82 @@ +package com.Timo.Timo.domain.timer.docs; + +import com.Timo.Timo.domain.timer.dto.request.TimerActionRequest; +import com.Timo.Timo.domain.timer.dto.response.TimerStatusResponse; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.exception.dto.ErrorDto; +import com.Timo.Timo.global.response.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +public interface TimerStatusControllerDocs { + + @Operation( + summary = "타이머 일시정지/재개", + description = """ + 실행 중인 타이머의 일시정지 / 재개를 처리합니다.
+ PAUSE: 현재 세션의 paused_at 기록, status → PAUSED
+ RESUME: 새 세션 생성, status → RUNNING + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "상태 변경 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "403", + description = "본인 소유의 타이머가 아닌 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 타이머인 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "409", + description = "잘못된 상태 전이 (PAUSED 상태에서 PAUSE 요청, 종료된 타이머 조작 등)", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> changeStatus( + @Parameter(description = "타이머 기록 ID", example = "10") + @PathVariable Long timerId, + @Valid @RequestBody TimerActionRequest request, + @Parameter(hidden = true) CustomUserDetails userDetails + ); +} diff --git a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStopControllerDocs.java b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStopControllerDocs.java new file mode 100644 index 00000000..ff6bf603 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStopControllerDocs.java @@ -0,0 +1,79 @@ +package com.Timo.Timo.domain.timer.docs; + +import com.Timo.Timo.domain.timer.dto.response.TimerFinishResponse; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.exception.dto.ErrorDto; +import com.Timo.Timo.global.response.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; + +public interface TimerStopControllerDocs { + + @Operation( + summary = "타이머 종료", + description = """ + 사용자의 요청으로 타이머를 종료합니다. + 종료 시각 기록, 실제 수행 시간 계산 (status → STOPPED) + 해당 날짜 TodoInstance 완료 처리 및 타이머 상태 초기화 + 계획 시간과 실제 수행 기록을 분석한 AI 피드백 반환 + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "타이머 종료 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "403", + description = "본인 소유의 타이머가 아닌 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 타이머인 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "409", + description = "이미 종료된 타이머인 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> stopTimer( + @Parameter(description = "타이머 기록 ID", example = "10") + @PathVariable Long timerId, + @Parameter(hidden = true) CustomUserDetails userDetails + ); +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/timer/dto/request/TimerActionRequest.java b/src/main/java/com/Timo/Timo/domain/timer/dto/request/TimerActionRequest.java new file mode 100644 index 00000000..13f272da --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/dto/request/TimerActionRequest.java @@ -0,0 +1,11 @@ +package com.Timo.Timo.domain.timer.dto.request; + +import com.Timo.Timo.domain.timer.enums.TimerAction; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record TimerActionRequest ( + @NotNull(message = "action은 필수입니다.") + @Schema(description = "타이머 동작 (PAUSE/RESUME)", example = "PAUSE") + TimerAction action +){} diff --git a/src/main/java/com/Timo/Timo/domain/timer/dto/request/TimerExtendRequest.java b/src/main/java/com/Timo/Timo/domain/timer/dto/request/TimerExtendRequest.java new file mode 100644 index 00000000..68c65b69 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/dto/request/TimerExtendRequest.java @@ -0,0 +1,14 @@ +package com.Timo.Timo.domain.timer.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +public record TimerExtendRequest ( + @NotNull(message = "연장 시간은 필수입니다.") + @Min(value = 1, message = "연장 시간은 1분 이상이어야 합니다.") + @Max(value = 720, message = "연장 시간은 720분을 초과할 수 없습니다.") + @Schema(description = "연장할 시간 (분), 1 이상", example = "10") + Integer extendMinutes +) {} diff --git a/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerActiveResponse.java b/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerActiveResponse.java new file mode 100644 index 00000000..2a3d3281 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerActiveResponse.java @@ -0,0 +1,40 @@ +package com.Timo.Timo.domain.timer.dto.response; + +import com.Timo.Timo.domain.timer.entity.TimerRecord; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +public record TimerActiveResponse ( + Long timerId, + Long todoId, + String todoTitle, + String iconType, + String status, + Integer plannedSeconds, + Integer extendedSeconds, + Integer elapsedSeconds, + Integer remainingSeconds, + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(type = "string", example = "2026-07-09 10:14:19") + LocalDateTime startedAt +){ + public static TimerActiveResponse of(TimerRecord timerRecord, int elapsedSeconds){ + int remainingSeconds = Math.max( + 0, + timerRecord.getPlannedSeconds() + timerRecord.getExtendedSeconds() - elapsedSeconds + ); + return new TimerActiveResponse( + timerRecord.getId(), + timerRecord.getTodo().getId(), + timerRecord.getTodo().getTitle(), + timerRecord.getTodo().getIcon() != null ? timerRecord.getTodo().getIcon().name() : null, + timerRecord.getStatus().name(), + timerRecord.getPlannedSeconds(), + timerRecord.getExtendedSeconds(), + elapsedSeconds, + remainingSeconds, + timerRecord.getStartedAt() + ); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerExtendResponse.java b/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerExtendResponse.java new file mode 100644 index 00000000..260df2d4 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerExtendResponse.java @@ -0,0 +1,19 @@ +package com.Timo.Timo.domain.timer.dto.response; + +import com.Timo.Timo.domain.timer.entity.TimerRecord; + +public record TimerExtendResponse ( + Long timerId, + String status, + Integer extendedSeconds, + Integer remainingSeconds +){ + public static TimerExtendResponse of(TimerRecord timerRecord, int remainingSeconds){ + return new TimerExtendResponse( + timerRecord.getId(), + timerRecord.getStatus().name(), + timerRecord.getExtendedSeconds(), + remainingSeconds + ); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerFinishResponse.java b/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerFinishResponse.java new file mode 100644 index 00000000..23a7cc4d --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerFinishResponse.java @@ -0,0 +1,25 @@ +package com.Timo.Timo.domain.timer.dto.response; + +import com.Timo.Timo.domain.timer.entity.TimerRecord; +import io.swagger.v3.oas.annotations.media.Schema; + +public record TimerFinishResponse( + Long timerId, + Long todoId, + String status, + Integer plannedSeconds, + Integer actualSeconds, + @Schema(description = "AI 피드백 문구. AI 호출 실패 시 null", nullable = true) + String aiFeedback +) { + public static TimerFinishResponse of(TimerRecord timerRecord) { + return new TimerFinishResponse( + timerRecord.getId(), + timerRecord.getTodo().getId(), + timerRecord.getStatus().name(), + timerRecord.getPlannedSeconds(), + timerRecord.getActualSeconds(), + timerRecord.getAiFeedback() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerStartResponse.java b/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerStartResponse.java new file mode 100644 index 00000000..78ffca3a --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerStartResponse.java @@ -0,0 +1,26 @@ +package com.Timo.Timo.domain.timer.dto.response; + +import com.Timo.Timo.domain.timer.entity.TimerRecord; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +public record TimerStartResponse( + Long timerId, + Long todoId, + String status, + Integer plannedSeconds, + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(example = "2026-07-06 17:51:50", type = "string") + LocalDateTime startedAt +) { + public static TimerStartResponse from(TimerRecord timerRecord) { + return new TimerStartResponse( + timerRecord.getId(), + timerRecord.getTodo().getId(), + timerRecord.getStatus().name(), + timerRecord.getPlannedSeconds(), + timerRecord.getStartedAt() + ); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerStatusResponse.java b/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerStatusResponse.java new file mode 100644 index 00000000..0254a8c2 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerStatusResponse.java @@ -0,0 +1,25 @@ +package com.Timo.Timo.domain.timer.dto.response; + +import com.Timo.Timo.domain.timer.entity.TimerRecord; + +public record TimerStatusResponse ( + Long timerId, + String status, + Integer elapsedSeconds, + Integer remainingSeconds +){ + public static TimerStatusResponse of(TimerRecord timerRecord, int elapsedSeconds){ + + int remainingSeconds = Math.max( + 0, + timerRecord.getPlannedSeconds() + timerRecord.getExtendedSeconds() - elapsedSeconds + ); + + return new TimerStatusResponse( + timerRecord.getId(), + timerRecord.getStatus().name(), + elapsedSeconds, + remainingSeconds + ); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java b/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java new file mode 100644 index 00000000..a83e71b0 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java @@ -0,0 +1,129 @@ +package com.Timo.Timo.domain.timer.entity; + +import com.Timo.Timo.domain.timer.enums.TimerStatus; +import com.Timo.Timo.domain.timer.exception.TimerErrorCode; +import com.Timo.Timo.domain.todo.entity.Todo; +import com.Timo.Timo.domain.user.entity.User; +import com.Timo.Timo.global.exception.CustomException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@Table(name = "timer_records") +@EntityListeners(AuditingEntityListener.class) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TimerRecord { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "todo_id", nullable = false) + private Todo todo; + + @Column(name = "planned_seconds", nullable = false) + private Integer plannedSeconds; + + @Column(name = "extended_seconds", nullable = false) + private Integer extendedSeconds; + + @Column(name = "started_at", nullable = false) + private LocalDateTime startedAt; + + @Column(name = "ended_at") + private LocalDateTime endedAt; + + @Column(name = "actual_seconds") + private Integer actualSeconds; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private TimerStatus status; + + @Column(name = "ai_feedback", length = 500) + private String aiFeedback; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Builder + private TimerRecord(User user, Todo todo, Integer plannedSeconds, LocalDateTime startedAt) { + this.user = user; + this.todo = todo; + this.plannedSeconds = plannedSeconds; + this.startedAt = startedAt; + this.extendedSeconds = 0; + this.status = TimerStatus.RUNNING; + } + + public void pause() { + if (!isRunning()) { + throw new CustomException(TimerErrorCode.TIMER_INVALID_STATUS_TRANSITION); + } + this.status = TimerStatus.PAUSED; + } + + public void resume() { + if (!isPaused()) { + throw new CustomException(TimerErrorCode.TIMER_INVALID_STATUS_TRANSITION); + } + this.status = TimerStatus.RUNNING; + } + + public void finish(TimerStatus targetStatus, LocalDateTime endedAt, int actualSeconds) { + if (isFinished()) { + throw new CustomException(TimerErrorCode.TIMER_ALREADY_FINISHED); + } + this.status = targetStatus; + this.endedAt = endedAt; + this.actualSeconds = actualSeconds; + } + + public void updateAiFeedback(String aiFeedback) { + this.aiFeedback = aiFeedback; + } + + public void extend(int extendedSeconds){ + if (isFinished()){ + throw new CustomException(TimerErrorCode.TIMER_ALREADY_FINISHED); + } + this.extendedSeconds += extendedSeconds; + } + + public boolean isRunning() { + return this.status == TimerStatus.RUNNING; + } + + public boolean isPaused() { + return this.status == TimerStatus.PAUSED; + } + + public boolean isFinished() { + return this.status == TimerStatus.COMPLETED || this.status == TimerStatus.STOPPED; + } +} diff --git a/src/main/java/com/Timo/Timo/domain/timer/entity/TimerSession.java b/src/main/java/com/Timo/Timo/domain/timer/entity/TimerSession.java new file mode 100644 index 00000000..e29ef9cd --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/entity/TimerSession.java @@ -0,0 +1,58 @@ +package com.Timo.Timo.domain.timer.entity; + +import com.Timo.Timo.domain.timer.exception.TimerErrorCode; +import com.Timo.Timo.global.exception.CustomException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "timer_sessions") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TimerSession { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "timer_record_id", nullable = false) + private TimerRecord timerRecord; + + @Column(name = "started_at", nullable = false) + private LocalDateTime startedAt; + + @Column(name = "paused_at") + private LocalDateTime pausedAt; + + @Builder + private TimerSession(TimerRecord timerRecord, LocalDateTime startedAt){ + this.timerRecord = timerRecord; + this.startedAt = startedAt; + } + + public void pause(LocalDateTime pausedAt){ + if (!isActive()) { + throw new CustomException(TimerErrorCode.TIMER_INVALID_STATUS_TRANSITION); + } + + this.pausedAt = pausedAt; + } + + public boolean isActive(){ + return this.pausedAt == null; + } +} diff --git a/src/main/java/com/Timo/Timo/domain/timer/enums/TimerAction.java b/src/main/java/com/Timo/Timo/domain/timer/enums/TimerAction.java new file mode 100644 index 00000000..a8206cf3 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/enums/TimerAction.java @@ -0,0 +1,6 @@ +package com.Timo.Timo.domain.timer.enums; + +public enum TimerAction { + PAUSE, + RESUME +} diff --git a/src/main/java/com/Timo/Timo/domain/timer/enums/TimerStatus.java b/src/main/java/com/Timo/Timo/domain/timer/enums/TimerStatus.java new file mode 100644 index 00000000..71d99487 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/enums/TimerStatus.java @@ -0,0 +1,8 @@ +package com.Timo.Timo.domain.timer.enums; + +public enum TimerStatus { + RUNNING, + PAUSED, + COMPLETED, + STOPPED +} diff --git a/src/main/java/com/Timo/Timo/domain/timer/exception/TimerErrorCode.java b/src/main/java/com/Timo/Timo/domain/timer/exception/TimerErrorCode.java new file mode 100644 index 00000000..87eb7e89 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/exception/TimerErrorCode.java @@ -0,0 +1,20 @@ +package com.Timo.Timo.domain.timer.exception; + +import com.Timo.Timo.global.exception.code.BaseErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum TimerErrorCode implements BaseErrorCode { + + TIMER_NOT_FOUND(HttpStatus.NOT_FOUND, "TIMER_404", "존재하지 않는 타이머입니다."), + TIMER_ALREADY_RUNNING(HttpStatus.CONFLICT, "TIMER_409", "이미 실행 중인 타이머가 있습니다."), + TIMER_INVALID_STATUS_TRANSITION(HttpStatus.CONFLICT, "TIMER_409", "요청을 처리할 수 없는 타이머 상태입니다."), + TIMER_ALREADY_FINISHED(HttpStatus.CONFLICT, "TIMER_409", "이미 종료된 타이머입니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java b/src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java new file mode 100644 index 00000000..fc17a597 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java @@ -0,0 +1,26 @@ +package com.Timo.Timo.domain.timer.exception; + +import com.Timo.Timo.global.exception.code.BaseSuccessCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum TimerSuccessCode implements BaseSuccessCode { + + TIMER_PAUSED(HttpStatus.OK, "TIMER_200", "타이머가 일시정지되었습니다."), + TIMER_RESUMED(HttpStatus.OK, "TIMER_200", "타이머가 재개되었습니다."), + TIMER_ACTIVE_FOUND(HttpStatus.OK, "TIMER_200", "실행 중인 타이머를 조회했습니다."), + TIMER_ACTIVE_NOT_FOUND(HttpStatus.OK, "TIMER_200", "실행 중인 타이머가 없습니다."), + TIMER_COMPLETED(HttpStatus.OK, "TIMER_200", "타이머가 완료되었습니다."), + TIMER_STOPPED(HttpStatus.OK, "TIMER_200", "타이머가 종료되었습니다."), + TIMER_EXTENDED(HttpStatus.OK, "TIMER_200", "타이머가 연장되었습니다."), + + TIMER_STARTED(HttpStatus.CREATED, "TIMER_201", "타이머가 시작되었습니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerDailyTodoStats.java b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerDailyTodoStats.java new file mode 100644 index 00000000..f650034b --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerDailyTodoStats.java @@ -0,0 +1,6 @@ +package com.Timo.Timo.domain.timer.repository; + +public interface TimerDailyTodoStats { + Long getTodoId(); + Long getActualSeconds(); +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerMonthlyRecordStats.java b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerMonthlyRecordStats.java new file mode 100644 index 00000000..1c5f97c4 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerMonthlyRecordStats.java @@ -0,0 +1,6 @@ +package com.Timo.Timo.domain.timer.repository; + +public interface TimerMonthlyRecordStats { + Long getTotalRecordSeconds(); + Long getTimerRecordedDayCount(); +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java new file mode 100644 index 00000000..f2959c24 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java @@ -0,0 +1,89 @@ +package com.Timo.Timo.domain.timer.repository; + +import com.Timo.Timo.domain.timer.entity.TimerRecord; +import com.Timo.Timo.domain.timer.enums.TimerStatus; +import jakarta.persistence.LockModeType; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface TimerRecordRepository extends JpaRepository { + + Optional findByUserIdAndStatusIn(Long userId, List statuses); + + boolean existsByTodo_IdAndStatusIn(Long todoId, List statuses); + + @Modifying(clearAutomatically = true) + @Query("delete from TimerRecord r where r.todo.id = :todoId") + void deleteByTodoId(@Param("todoId") Long todoId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select t from TimerRecord t where t.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + + @Query(""" + select + coalesce(sum(tr.actualSeconds), 0) as totalRecordSeconds, + count(distinct function('date', coalesce(tr.endedAt, tr.startedAt))) as timerRecordedDayCount + from TimerRecord tr + where tr.user.id = :userId + and tr.actualSeconds is not null + and coalesce(tr.endedAt, tr.startedAt) >= :fromInclusive + and coalesce(tr.endedAt, tr.startedAt) < :toExclusive + """) + TimerMonthlyRecordStats findMonthlyRecordStats( + @Param("userId") Long userId, + @Param("fromInclusive") LocalDateTime fromInclusive, + @Param("toExclusive") LocalDateTime toExclusive + ); + + @Query(""" + select coalesce(sum(tr.actualSeconds), 0) + from TimerRecord tr + where tr.user.id = :userId + and tr.actualSeconds is not null + and coalesce(tr.endedAt, tr.startedAt) >= :fromInclusive + and coalesce(tr.endedAt, tr.startedAt) < :toExclusive + """) + Long sumActualSeconds( + @Param("userId") Long userId, + @Param("fromInclusive") LocalDateTime fromInclusive, + @Param("toExclusive") LocalDateTime toExclusive + ); + + @Query(""" + select + tr.todo.id as todoId, + coalesce(sum(tr.actualSeconds), 0) as actualSeconds + from TimerRecord tr + where tr.user.id = :userId + and tr.actualSeconds is not null + and coalesce(tr.endedAt, tr.startedAt) >= :fromInclusive + and coalesce(tr.endedAt, tr.startedAt) < :toExclusive + group by tr.todo.id + """) + List findDailyTodoStats( + @Param("userId") Long userId, + @Param("fromInclusive") LocalDateTime fromInclusive, + @Param("toExclusive") LocalDateTime toExclusive + ); + + @Query(""" + select coalesce(tr.endedAt, tr.startedAt) + from TimerRecord tr + where tr.user.id = :userId + and tr.actualSeconds is not null + and coalesce(tr.endedAt, tr.startedAt) >= :fromInclusive + and coalesce(tr.endedAt, tr.startedAt) < :toExclusive + """) + List findMonthlyRecordedAtTimes( + @Param("userId") Long userId, + @Param("fromInclusive") LocalDateTime fromInclusive, + @Param("toExclusive") LocalDateTime toExclusive + ); +} diff --git a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java new file mode 100644 index 00000000..a00c18ec --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java @@ -0,0 +1,38 @@ +package com.Timo.Timo.domain.timer.repository; + +import com.Timo.Timo.domain.timer.entity.TimerSession; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +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; + +public interface TimerSessionRepository extends JpaRepository { + + Optional findByTimerRecordIdAndPausedAtIsNull(Long timerRecordId); + + List findByTimerRecordId(Long timerRecordId); + + @Modifying(clearAutomatically = true) + @Query("delete from TimerSession s where s.timerRecord.todo.id = :todoId") + void deleteByTodoId(@Param("todoId") Long todoId); + + @Query(""" + select ts + from TimerSession ts + join fetch ts.timerRecord tr + join fetch tr.todo + where tr.user.id = :userId + and ts.startedAt < :toExclusive + and coalesce(ts.pausedAt, :nowUtc) > :fromInclusive + order by ts.startedAt asc, ts.id asc + """) + List findTimeBoxSessions( + @Param("userId") Long userId, + @Param("fromInclusive") LocalDateTime fromInclusive, + @Param("toExclusive") LocalDateTime toExclusive, + @Param("nowUtc") LocalDateTime nowUtc + ); +} diff --git a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java new file mode 100644 index 00000000..3fde84bb --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -0,0 +1,278 @@ +package com.Timo.Timo.domain.timer.service; + +import com.Timo.Timo.domain.ai.service.AiTodoService; +import com.Timo.Timo.domain.timer.dto.response.TimerActiveResponse; +import com.Timo.Timo.domain.timer.dto.response.TimerFinishResponse; +import com.Timo.Timo.domain.timer.dto.response.TimerExtendResponse; +import com.Timo.Timo.domain.timer.dto.response.TimerStartResponse; +import com.Timo.Timo.domain.timer.dto.response.TimerStatusResponse; +import com.Timo.Timo.domain.timer.entity.TimerRecord; +import com.Timo.Timo.domain.timer.entity.TimerSession; +import com.Timo.Timo.domain.timer.enums.TimerAction; +import com.Timo.Timo.domain.timer.enums.TimerStatus; +import com.Timo.Timo.domain.timer.exception.TimerErrorCode; +import com.Timo.Timo.domain.timer.repository.TimerRecordRepository; +import com.Timo.Timo.domain.timer.repository.TimerSessionRepository; +import com.Timo.Timo.domain.todo.entity.Todo; +import com.Timo.Timo.domain.todo.entity.TodoInstance; +import com.Timo.Timo.domain.todo.exception.TodoErrorCode; +import com.Timo.Timo.domain.todo.repository.TodoInstanceRepository; +import com.Timo.Timo.domain.todo.repository.TodoRepository; +import com.Timo.Timo.domain.user.entity.User; +import com.Timo.Timo.domain.user.exception.UserErrorCode; +import com.Timo.Timo.domain.user.repository.UserRepository; +import com.Timo.Timo.global.exception.CustomException; +import com.Timo.Timo.global.exception.code.ErrorCode; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TimerService { + + private static final List ACTIVE_STATUS = List.of(TimerStatus.RUNNING, TimerStatus.PAUSED); + + private final TimerRecordRepository timerRecordRepository; + private final TimerSessionRepository timerSessionRepository; + private final TodoRepository todoRepository; + private final UserRepository userRepository; + private final TodoInstanceRepository todoInstanceRepository; + private final AiTodoService aiTodoService; + private final PlatformTransactionManager transactionManager; + + @Transactional + public TimerStartResponse startTimer(Long userId, Long todoId) { + User user = userRepository.findByIdForUpdate(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + Todo todo = todoRepository.findById(todoId) + .orElseThrow(() -> new CustomException(TodoErrorCode.TODO_NOT_FOUND)); + + if (!todo.getUser().getId().equals(userId)) { + throw new CustomException(ErrorCode.FORBIDDEN); + } + + timerRecordRepository.findByUserIdAndStatusIn(userId, ACTIVE_STATUS) + .ifPresent(existing -> { + throw new CustomException(TimerErrorCode.TIMER_ALREADY_RUNNING); + }); + + LocalDateTime now = LocalDateTime.now(); + + TimerRecord timerRecord = TimerRecord.builder() + .user(user) + .todo(todo) + .plannedSeconds(todo.getDurationSeconds()) + .startedAt(now) + .build(); + timerRecordRepository.save(timerRecord); + + TimerSession session = TimerSession.builder() + .timerRecord(timerRecord) + .startedAt(now) + .build(); + timerSessionRepository.save(session); + + TodoInstance instance = getOrCreateInstance(todo, now.toLocalDate()); + instance.startTimer(); + + return TimerStartResponse.from(timerRecord); + } + + @Transactional + public TimerStatusResponse changeStatus(Long userId, Long timerId, TimerAction action){ + TimerRecord timerRecord = timerRecordRepository.findByIdForUpdate(timerId) + .orElseThrow(() -> new CustomException(TimerErrorCode.TIMER_NOT_FOUND)); + + if (!timerRecord.getUser().getId().equals(userId)){ + throw new CustomException(ErrorCode.FORBIDDEN); + } + + LocalDateTime now = LocalDateTime.now(); + + TodoInstance instance = getOrCreateInstance(timerRecord.getTodo(), timerRecord.getStartedAt().toLocalDate()); + + if (action == TimerAction.PAUSE) { + timerRecord.pause(); + TimerSession activeSession = timerSessionRepository.findByTimerRecordIdAndPausedAtIsNull(timerId) + .orElseThrow(() -> new CustomException(TimerErrorCode.TIMER_INVALID_STATUS_TRANSITION)); + activeSession.pause(now); + instance.pauseTimer(); + } else if (action == TimerAction.RESUME) { + timerRecord.resume(); + TimerSession newSession = TimerSession.builder() + .timerRecord(timerRecord) + .startedAt(now) + .build(); + timerSessionRepository.save(newSession); + instance.startTimer(); + } else { + throw new CustomException(TimerErrorCode.TIMER_INVALID_STATUS_TRANSITION); + } + + int elapsedSeconds = calculateElapsedSeconds(timerId, now); + + return TimerStatusResponse.of(timerRecord, elapsedSeconds); + } + + @Transactional + public TimerExtendResponse extendTimer(Long userId, Long timerId, int extendMinutes){ + TimerRecord timerRecord = timerRecordRepository.findByIdForUpdate(timerId) + .orElseThrow(() -> new CustomException(TimerErrorCode.TIMER_NOT_FOUND)); + + if (!timerRecord.getUser().getId().equals(userId)){ + throw new CustomException(ErrorCode.FORBIDDEN); + } + + int extendSeconds = extendMinutes * 60; + timerRecord.extend(extendSeconds); + + LocalDateTime now = LocalDateTime.now(); + int elapsedSeconds = calculateElapsedSeconds(timerId, now); + int remainingSeconds = Math.max( + 0, + timerRecord.getPlannedSeconds() + timerRecord.getExtendedSeconds() - elapsedSeconds + ); + + return TimerExtendResponse.of(timerRecord, remainingSeconds); + } + + public TimerActiveResponse getActiveTimer(Long userId){ + return timerRecordRepository.findByUserIdAndStatusIn(userId, ACTIVE_STATUS) + .map(timerRecord -> { + int elapsedSeconds = calculateElapsedSeconds(timerRecord.getId(), LocalDateTime.now()); + return TimerActiveResponse.of(timerRecord, elapsedSeconds); + }) + .orElse(null); + } + + private int calculateElapsedSeconds(Long timerRecordId, LocalDateTime now){ + List sessions = timerSessionRepository.findByTimerRecordId(timerRecordId); + long totalSeconds = 0; + for (TimerSession session : sessions){ + LocalDateTime end = session.getPausedAt() != null ? session.getPausedAt() : now; + totalSeconds += Duration.between(session.getStartedAt(), end).getSeconds(); + } + + return (int) totalSeconds; + } + + private TodoInstance getOrCreateInstance(Todo todo, LocalDate date) { + return todoInstanceRepository.findByTodo_IdAndDate(todo.getId(), date) + .orElseGet(() -> todoInstanceRepository.save(TodoInstance.of(todo, date, 0))); + } + + public boolean hasActiveTimer(Long todoId) { + return timerRecordRepository.existsByTodo_IdAndStatusIn(todoId, ACTIVE_STATUS); + } + + @Transactional + public void deleteTimersByTodo(Long todoId) { + timerSessionRepository.deleteByTodoId(todoId); + timerRecordRepository.deleteByTodoId(todoId); + } + + public TimerFinishResponse completeTimer(Long userId, Long timerId) { + return finishTimer(userId, timerId, TimerStatus.COMPLETED); + } + + public TimerFinishResponse stopTimer(Long userId, Long timerId) { + return finishTimer(userId, timerId, TimerStatus.STOPPED); + } + + private TimerFinishResponse finishTimer(Long userId, Long timerId, TimerStatus targetStatus) { + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + FinishedTimer finishedTimer = transactionTemplate.execute(status -> + finishTimerInTransaction(userId, timerId, targetStatus) + ); + + String feedback = generateAiFeedback(userId, finishedTimer.todoId()); + if (feedback != null) { + transactionTemplate.executeWithoutResult(status -> + updateAiFeedback(timerId, feedback) + ); + } + + return new TimerFinishResponse( + finishedTimer.timerId(), + finishedTimer.todoId(), + finishedTimer.status(), + finishedTimer.plannedSeconds(), + finishedTimer.actualSeconds(), + feedback + ); + } + + private FinishedTimer finishTimerInTransaction(Long userId, Long timerId, TimerStatus targetStatus) { + TimerRecord timerRecord = timerRecordRepository.findByIdForUpdate(timerId) + .orElseThrow(() -> new CustomException(TimerErrorCode.TIMER_NOT_FOUND)); + + if (!timerRecord.getUser().getId().equals(userId)) { + throw new CustomException(ErrorCode.FORBIDDEN); + } + + LocalDateTime now = LocalDateTime.now(); + int actualSeconds = calculateElapsedSeconds(timerId, now); + + timerSessionRepository.findByTimerRecordIdAndPausedAtIsNull(timerId) + .ifPresent(activeSession -> activeSession.pause(now)); + + timerRecord.finish(targetStatus, now, actualSeconds); + + TodoInstance instance = getOrCreateInstance(timerRecord.getTodo(), timerRecord.getStartedAt().toLocalDate()); + instance.stopTimer(); + instance.markCompleted(); + + timerRecordRepository.flush(); + + return FinishedTimer.from(timerRecord); + } + + private String generateAiFeedback(Long userId, Long todoId) { + try { + return aiTodoService.createFeedback(userId, todoId); + } catch (RuntimeException exception) { + log.warn( + "AI feedback generation failed. todoId={}, userId={}", + todoId, + userId, + exception + ); + return null; + } + } + + private void updateAiFeedback(Long timerId, String feedback) { + TimerRecord timerRecord = timerRecordRepository.findByIdForUpdate(timerId) + .orElseThrow(() -> new CustomException(TimerErrorCode.TIMER_NOT_FOUND)); + timerRecord.updateAiFeedback(feedback); + } + + private record FinishedTimer( + Long timerId, + Long todoId, + String status, + Integer plannedSeconds, + Integer actualSeconds + ) { + + private static FinishedTimer from(TimerRecord timerRecord) { + return new FinishedTimer( + timerRecord.getId(), + timerRecord.getTodo().getId(), + timerRecord.getStatus().name(), + timerRecord.getPlannedSeconds(), + timerRecord.getActualSeconds() + ); + } + } +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/controller/TodoController.java b/src/main/java/com/Timo/Timo/domain/todo/controller/TodoController.java new file mode 100644 index 00000000..6ea9f974 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/controller/TodoController.java @@ -0,0 +1,142 @@ +package com.Timo.Timo.domain.todo.controller; + +import java.util.Map; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.Timo.Timo.domain.todo.docs.TodoControllerDocs; +import com.Timo.Timo.domain.todo.dto.request.SubtaskStatusUpdateRequest; +import com.Timo.Timo.domain.todo.dto.request.TodoCreateRequest; +import com.Timo.Timo.domain.todo.dto.request.TodoReorderRequest; +import com.Timo.Timo.domain.todo.dto.request.TodoStatusUpdateRequest; +import com.Timo.Timo.domain.todo.dto.request.TodoUpdateRequest; +import com.Timo.Timo.domain.todo.dto.response.SubtaskStatusChangeResponse; +import com.Timo.Timo.domain.todo.dto.response.TodoCreateResponse; +import com.Timo.Timo.domain.todo.dto.response.TodoReorderResponse; +import com.Timo.Timo.domain.todo.dto.response.TodoStatusChangeResponse; +import com.Timo.Timo.domain.todo.dto.response.TodoDetailResponse; +import com.Timo.Timo.domain.todo.exception.TodoSuccessCode; +import com.Timo.Timo.domain.todo.service.TodoService; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/todos") +@RequiredArgsConstructor +@Tag(name = "Todo", description = "TODO API") +public class TodoController implements TodoControllerDocs { + + private final TodoService todoService; + + @Override + @PostMapping + public ResponseEntity> createTodo( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody TodoCreateRequest request + ) { + TodoCreateResponse response = todoService.createTodo(userDetails.getUserId(), request); + + return ResponseEntity + .status(TodoSuccessCode.CREATED.getHttpStatus()) + .body(BaseResponse.onSuccess(TodoSuccessCode.CREATED, response)); + } + + @Override + @GetMapping("/{todoId}") + public ResponseEntity> getTodoDetail( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long todoId, + @RequestParam String date + ) { + TodoDetailResponse response = todoService.getTodoDetail(userDetails.getUserId(), todoId, date); + + return ResponseEntity + .status(TodoSuccessCode.GET_DETAIL.getHttpStatus()) + .body(BaseResponse.onSuccess(TodoSuccessCode.GET_DETAIL, response)); + } + + @Override + @PatchMapping("/{todoId}/status") + public ResponseEntity> changeTodoStatus( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long todoId, + @Valid @RequestBody TodoStatusUpdateRequest request + ) { + TodoStatusChangeResponse response = todoService.changeCompletion(userDetails.getUserId(), todoId, request); + + return ResponseEntity + .status(TodoSuccessCode.STATUS_CHANGED.getHttpStatus()) + .body(BaseResponse.onSuccess(TodoSuccessCode.STATUS_CHANGED, response)); + } + + @Override + @PatchMapping("/{todoId}/subtasks/{subtaskId}/status") + public ResponseEntity> changeSubtaskStatus( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long todoId, + @PathVariable Long subtaskId, + @Valid @RequestBody SubtaskStatusUpdateRequest request + ) { + SubtaskStatusChangeResponse response = + todoService.changeSubtaskCompletion(userDetails.getUserId(), todoId, subtaskId, request); + + return ResponseEntity + .status(TodoSuccessCode.SUBTASK_STATUS_CHANGED.getHttpStatus()) + .body(BaseResponse.onSuccess(TodoSuccessCode.SUBTASK_STATUS_CHANGED, response)); + } + + @Override + @PatchMapping("/{todoId}/order") + public ResponseEntity> reorderTodo( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long todoId, + @Valid @RequestBody TodoReorderRequest request + ) { + TodoReorderResponse response = todoService.reorderTodo(userDetails.getUserId(), todoId, request); + + return ResponseEntity + .status(TodoSuccessCode.REORDERED.getHttpStatus()) + .body(BaseResponse.onSuccess(TodoSuccessCode.REORDERED, response)); + } + + @Override + @PatchMapping("/{todoId}") + public ResponseEntity> updateTodo( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long todoId, + @Valid @RequestBody TodoUpdateRequest request + ) { + todoService.updateTodo(userDetails.getUserId(), todoId, request); + + return ResponseEntity + .status(TodoSuccessCode.UPDATED.getHttpStatus()) + .body(BaseResponse.onSuccess(TodoSuccessCode.UPDATED, Map.of())); + } + + @Override + @DeleteMapping("/{todoId}") + public ResponseEntity> deleteTodo( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long todoId + ) { + todoService.deleteTodo(userDetails.getUserId(), todoId); + + return ResponseEntity + .status(TodoSuccessCode.DELETED.getHttpStatus()) + .body(BaseResponse.onSuccess(TodoSuccessCode.DELETED, Map.of())); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java b/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java new file mode 100644 index 00000000..f89cf2d2 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java @@ -0,0 +1,562 @@ +package com.Timo.Timo.domain.todo.docs; + +import org.springframework.http.ResponseEntity; + +import com.Timo.Timo.domain.todo.dto.request.SubtaskStatusUpdateRequest; +import com.Timo.Timo.domain.todo.dto.request.TodoCreateRequest; +import com.Timo.Timo.domain.todo.dto.request.TodoReorderRequest; +import com.Timo.Timo.domain.todo.dto.request.TodoStatusUpdateRequest; +import com.Timo.Timo.domain.todo.dto.request.TodoUpdateRequest; +import com.Timo.Timo.domain.todo.dto.response.SubtaskStatusChangeResponse; +import com.Timo.Timo.domain.todo.dto.response.TodoCreateResponse; +import com.Timo.Timo.domain.todo.dto.response.TodoDetailResponse; +import com.Timo.Timo.domain.todo.dto.response.TodoReorderResponse; +import com.Timo.Timo.domain.todo.dto.response.TodoStatusChangeResponse; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.exception.dto.ErrorDto; +import com.Timo.Timo.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +public interface TodoControllerDocs { + + @Operation( + summary = "TODO 생성", + description = """ + 사용자가 새로운 TODO를 생성합니다. + + 아이콘, 제목, 하위 태스크, 날짜, 예상 소요 시간, 우선순위, 태그, 반복 설정, 메모를 함께 저장합니다. + 예상 소요 시간은 duration 필드에 분:초 형식으로 전달합니다. 예: 00:15 + 반복 일정은 시작일 기준 최대 1년까지 생성됩니다. + + Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. + """ + ) + @RequestBody( + required = true, + description = "TODO 생성 요청", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = TodoCreateRequest.class), + examples = @ExampleObject( + name = "TODO 생성 요청 예시", + value = """ + { + "icon": "ICON_3", + "title": "티모 하이와프 작업하기", + "subtasks": ["타이머 명세 작성", "API 연결"], + "date": "2026-07-22", + "duration": "90:00", + "priority": "HIGH", + "tagId": 3, + "repeatType": "WEEKLY", + "repeatWeekdays": ["MON", "WED"], + "repeatDayOfMonth": null, + "memo": null + } + """ + ) + ) + ) + @ApiResponses({ + @ApiResponse( + responseCode = "201", + description = "TODO 생성 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = """ + 필수 데이터 누락, 잘못된 enum 값, duration 형식 오류, + repeatType이 WEEKLY인데 요일 미지정, + repeatType이 MONTHLY인데 반복 날짜 미지정, + 제목/하위 태스크/메모 길이 제한 초과 + """, + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 태그 ID를 전달한 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "409", + description = "해당 날짜의 TODO가 최대 개수 20개를 초과한 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> createTodo( + @Parameter(hidden = true) CustomUserDetails userDetails, + TodoCreateRequest request + ); + + @Operation( + summary = "TODO 상세 조회", + description = """ + todoId와 조회 기준 날짜(date)로 단일 TODO의 상세 정보를 조회합니다. + + 아이콘, 제목, 완료 여부, 날짜/요일, 예상 소요 시간, 우선순위, 태그, + 반복 설정, 타이머 상태, 메모, 정렬 순서, 하위 태스크 목록을 반환합니다. + + 완료 여부·타이머 상태·정렬 순서는 date에 해당하는 인스턴스 기준으로 반환되므로, + 반복 TODO의 경우 조회하려는 날짜를 date로 전달해야 합니다. + + Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "TODO 상세 조회 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = "date가 누락되었거나 날짜 형식(yyyy-MM-dd)이 올바르지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 투두이거나, 전달한 date에 해당 투두가 존재하지 않는 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> getTodoDetail( + @Parameter(hidden = true) CustomUserDetails userDetails, + @Parameter(description = "조회할 TODO ID", example = "3") Long todoId, + @Parameter(description = "조회 기준 날짜 (yyyy-MM-dd)", example = "2026-07-22") String date + ); + + @Operation( + summary = "TODO 완료 상태 변경", + description = """ + TODO의 완료 여부를 변경합니다. + + 완료/미완료 상태는 날짜별로 관리되므로 date로 대상 날짜를 지정합니다. date를 생략하면 사용자 타임존 기준 오늘로 처리합니다. + 완료로 변경 시 해당 날짜 완료 그룹의 하단으로, 미완료로 변경 시 미완료 그룹의 최상단으로 정렬 순서를 재조정하여 반환합니다. + 해당 TODO에 실행 중이거나 일시정지된 타이머가 있으면 변경할 수 없습니다. + + Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. + """ + ) + @RequestBody( + required = true, + description = "완료 상태 변경 요청", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = TodoStatusUpdateRequest.class), + examples = @ExampleObject( + name = "완료 상태 변경 요청 예시", + value = """ + { + "isCompleted": true, + "date": "2026-07-22" + } + """ + ) + ) + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "완료 상태 변경 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = "isCompleted가 누락된 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 TODO이거나 해당 날짜에 발생하지 않는 TODO인 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "409", + description = "타이머가 실행 중이거나 일시정지된 상태에서 변경을 시도한 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> changeTodoStatus( + @Parameter(hidden = true) CustomUserDetails userDetails, + @Parameter(description = "대상 TODO ID", example = "145") Long todoId, + TodoStatusUpdateRequest request + ); + + @Operation( + summary = "TODO 수정", + description = """ + 기존 TODO의 제목, 메모, 날짜, 태그, 우선순위, 예상 소요 시간, 아이콘, 반복 규칙, 하위 태스크를 부분 수정합니다. + + 요청 body에 포함된(null이 아닌) 필드만 수정됩니다. + 예상 소요 시간은 durationSeconds 필드에 초 단위 정수로 전달합니다. + subtasks를 전달하면 하위 태스크 목록 전체가 교체됩니다. + subtaskId가 있으면 기존 태스크를 수정하고, null이면 신규 태스크로 추가하며, 전달되지 않은 기존 태스크는 삭제됩니다. + + Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. + """ + ) + @RequestBody( + required = true, + description = "TODO 수정 요청 (수정할 필드만 포함)", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = TodoUpdateRequest.class), + examples = @ExampleObject( + name = "TODO 수정 요청 예시", + value = """ + { + "title": "티모 하이와프 작업하기 v2", + "priority": "VERY_HIGH", + "memo": "레퍼런스 정리 먼저 → API 명세", + "subtasks": [ + { "subtaskId": 1, "content": "타이머 명세 초안", "completed": true }, + { "subtaskId": null, "content": "리뷰 반영", "completed": false } + ] + } + """ + ) + ) + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "TODO 수정 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = "수정할 필드가 없거나, 잘못된 enum 값, 글자 수 제한 초과, 반복 규칙 불일치 등", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 TODO이거나, 존재하지 않는 태그 ID 또는 하위 태스크 ID를 전달한 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "409", + description = "타이머 실행 중에 일정/소요시간 변경을 시도했거나, 변경된 일정의 특정 날짜 TODO가 최대 개수(20개)를 초과한 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> updateTodo( + @Parameter(hidden = true) CustomUserDetails userDetails, + @Parameter(description = "수정할 TODO ID", example = "205") Long todoId, + TodoUpdateRequest request + ); + + @Operation( + summary = "TODO 삭제", + description = """ + TODO와 연결된 하위 태스크, 반복 규칙을 함께 삭제합니다. + 해당 TODO에 실행 중이거나 일시정지된 타이머가 있으면 삭제할 수 없습니다. + + Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "TODO 삭제 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 TODO인 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "409", + description = "타이머가 실행 중이거나 일시정지된 상태에서 삭제를 시도한 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> deleteTodo( + @Parameter(hidden = true) CustomUserDetails userDetails, + @Parameter(description = "삭제할 TODO ID", example = "205") Long todoId + ); + + @Operation( + summary = "TODO 순서 변경", + description = """ + 드래그 앤 드롭으로 변경된 TODO의 순서를 반영합니다. + + 순서 변경은 같은 날짜의 같은 완료 그룹(미완료 그룹) 내부에서만 가능합니다. + 완료된 TODO는 순서를 변경할 수 없으며, 완료/미완료 그룹 간 이동은 완료 상태 변경 API로 처리합니다. + newIndex는 해당 날짜 미완료 그룹 내에서 0부터 시작하는 목표 위치입니다. + date를 생략하면 사용자 타임존 기준 오늘로 처리합니다. + 서버는 대상 TODO를 새 인덱스로 옮기고 영향받는 TODO들의 정렬 순서를 재계산합니다. + + Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. + """ + ) + @RequestBody( + required = true, + description = "순서 변경 요청", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = TodoReorderRequest.class), + examples = @ExampleObject( + name = "순서 변경 요청 예시", + value = """ + { + "newIndex": 2, + "date": "2026-07-22" + } + """ + ) + ) + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "TODO 순서 변경 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = "newIndex가 누락되었거나 음수이거나 그룹 크기를 초과하는 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 TODO이거나 해당 날짜에 발생하지 않는 TODO인 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "409", + description = "완료된 TODO의 순서를 변경하려는 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> reorderTodo( + @Parameter(hidden = true) CustomUserDetails userDetails, + @Parameter(description = "이동할 TODO ID", example = "145") Long todoId, + TodoReorderRequest request + ); + + @Operation( + summary = "하위 태스크 완료 상태 변경", + description = """ + 하위 태스크의 완료 여부를 변경합니다. + + todoId로 소유한 TODO를 확인한 뒤, 해당 TODO에 속한 subtaskId의 완료 상태를 변경합니다. + + Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. + """ + ) + @RequestBody( + required = true, + description = "하위 태스크 완료 상태 변경 요청", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SubtaskStatusUpdateRequest.class), + examples = @ExampleObject( + name = "하위 태스크 완료 상태 변경 요청 예시", + value = """ + { + "isCompleted": true + } + """ + ) + ) + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "하위 태스크 완료 상태 변경 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = "isCompleted가 누락된 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 TODO이거나 해당 TODO에 속하지 않는 하위 태스크인 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> changeSubtaskStatus( + @Parameter(hidden = true) CustomUserDetails userDetails, + @Parameter(description = "대상 TODO ID", example = "5") Long todoId, + @Parameter(description = "대상 하위 태스크 ID", example = "2") Long subtaskId, + SubtaskStatusUpdateRequest request + ); +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/dto/request/SubtaskStatusUpdateRequest.java b/src/main/java/com/Timo/Timo/domain/todo/dto/request/SubtaskStatusUpdateRequest.java new file mode 100644 index 00000000..0d87c51c --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/dto/request/SubtaskStatusUpdateRequest.java @@ -0,0 +1,11 @@ +package com.Timo.Timo.domain.todo.dto.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import jakarta.validation.constraints.NotNull; + +public record SubtaskStatusUpdateRequest( + @NotNull + @JsonProperty("isCompleted") + Boolean isCompleted +) { } diff --git a/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoCreateRequest.java b/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoCreateRequest.java new file mode 100644 index 00000000..75329d52 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoCreateRequest.java @@ -0,0 +1,63 @@ +package com.Timo.Timo.domain.todo.dto.request; + +import java.time.LocalDate; +import java.util.List; + +import com.Timo.Timo.domain.todo.enums.RepeatType; +import com.Timo.Timo.domain.todo.enums.TodoIcon; +import com.Timo.Timo.domain.todo.enums.TodoPriority; +import com.Timo.Timo.domain.todo.enums.Weekday; +import com.Timo.Timo.domain.todo.validation.ValidSubtaskContent; +import com.Timo.Timo.domain.todo.validation.ValidTodoTitle; + +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record TodoCreateRequest( + TodoIcon icon, + + @NotBlank + @ValidTodoTitle + String title, + + List<@NotBlank @ValidSubtaskContent String> subtasks, + + @NotNull + LocalDate date, + + @NotBlank + String duration, + + TodoPriority priority, + + Long tagId, + + @NotNull + RepeatType repeatType, + + List repeatWeekdays, + + @Min(1) + @Max(31) + Integer repeatDayOfMonth, + + @Size(max = 300) + String memo +) { + + @AssertTrue(message = "WEEKLY는 요일, MONTHLY는 일자가 필요합니다.") + public boolean isRepeatRuleValid() { + if (repeatType == RepeatType.WEEKLY) { + return repeatWeekdays != null && !repeatWeekdays.isEmpty(); + } + if (repeatType == RepeatType.MONTHLY) { + return repeatDayOfMonth != null; + } + return true; + } +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoReorderRequest.java b/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoReorderRequest.java new file mode 100644 index 00000000..26b3e440 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoReorderRequest.java @@ -0,0 +1,9 @@ +package com.Timo.Timo.domain.todo.dto.request; + +import java.time.LocalDate; + +public record TodoReorderRequest( + Integer newIndex, + + LocalDate date +) { } diff --git a/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoStatusUpdateRequest.java b/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoStatusUpdateRequest.java new file mode 100644 index 00000000..30be5632 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoStatusUpdateRequest.java @@ -0,0 +1,15 @@ +package com.Timo.Timo.domain.todo.dto.request; + +import java.time.LocalDate; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import jakarta.validation.constraints.NotNull; + +public record TodoStatusUpdateRequest( + @NotNull + @JsonProperty("isCompleted") + Boolean isCompleted, + + LocalDate date +) { } diff --git a/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoSubtaskUpdateRequest.java b/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoSubtaskUpdateRequest.java new file mode 100644 index 00000000..f887cfa7 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoSubtaskUpdateRequest.java @@ -0,0 +1,17 @@ +package com.Timo.Timo.domain.todo.dto.request; + +import com.Timo.Timo.domain.todo.validation.ValidSubtaskContent; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record TodoSubtaskUpdateRequest( + Long subtaskId, + + @NotBlank + @ValidSubtaskContent + String content, + + @NotNull + Boolean completed +) { } diff --git a/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoUpdateRequest.java b/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoUpdateRequest.java new file mode 100644 index 00000000..7df25968 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoUpdateRequest.java @@ -0,0 +1,59 @@ +package com.Timo.Timo.domain.todo.dto.request; + +import java.time.LocalDate; +import java.util.List; + +import com.Timo.Timo.domain.todo.enums.RepeatType; +import com.Timo.Timo.domain.todo.enums.TodoIcon; +import com.Timo.Timo.domain.todo.enums.TodoPriority; +import com.Timo.Timo.domain.todo.enums.Weekday; +import com.Timo.Timo.domain.todo.validation.ValidTodoTitle; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; + +public record TodoUpdateRequest( + TodoIcon icon, + + @ValidTodoTitle + String title, + + LocalDate date, + + @Min(1) + Integer durationSeconds, + + TodoPriority priority, + + Long tagId, + + RepeatType repeatType, + + List repeatWeekdays, + + @Min(1) + @Max(31) + Integer repeatDayOfMonth, + + @Size(max = 300) + String memo, + + @Valid + List subtasks +) { + public boolean hasNoUpdatableField() { + return icon == null + && title == null + && date == null + && durationSeconds == null + && priority == null + && tagId == null + && repeatType == null + && repeatWeekdays == null + && repeatDayOfMonth == null + && memo == null + && subtasks == null; + } +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/dto/response/SubtaskStatusChangeResponse.java b/src/main/java/com/Timo/Timo/domain/todo/dto/response/SubtaskStatusChangeResponse.java new file mode 100644 index 00000000..8fbd72eb --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/dto/response/SubtaskStatusChangeResponse.java @@ -0,0 +1,15 @@ +package com.Timo.Timo.domain.todo.dto.response; + +import com.Timo.Timo.domain.todo.entity.Subtask; + +public record SubtaskStatusChangeResponse( + Long subtaskId, + Boolean completed +) { + public static SubtaskStatusChangeResponse from(Subtask subtask) { + return new SubtaskStatusChangeResponse( + subtask.getId(), + subtask.isCompleted() + ); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/dto/response/TodoCreateResponse.java b/src/main/java/com/Timo/Timo/domain/todo/dto/response/TodoCreateResponse.java new file mode 100644 index 00000000..54d1dd56 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/dto/response/TodoCreateResponse.java @@ -0,0 +1,12 @@ +package com.Timo.Timo.domain.todo.dto.response; + +import com.Timo.Timo.domain.todo.entity.Todo; + +public record TodoCreateResponse( + Long todoId +) { + + public static TodoCreateResponse from(Todo todo) { + return new TodoCreateResponse(todo.getId()); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/dto/response/TodoDetailResponse.java b/src/main/java/com/Timo/Timo/domain/todo/dto/response/TodoDetailResponse.java new file mode 100644 index 00000000..ed808d64 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/dto/response/TodoDetailResponse.java @@ -0,0 +1,94 @@ +package com.Timo.Timo.domain.todo.dto.response; + +import java.time.LocalDate; +import java.util.List; +import java.util.Objects; + +import com.Timo.Timo.domain.tag.entity.Tag; +import com.Timo.Timo.domain.todo.entity.Subtask; +import com.Timo.Timo.domain.todo.entity.Todo; +import com.Timo.Timo.domain.todo.entity.TodoInstance; +import com.Timo.Timo.domain.todo.enums.RepeatType; +import com.Timo.Timo.domain.todo.enums.TodoTimerStatus; +import com.Timo.Timo.domain.todo.enums.Weekday; + +public record TodoDetailResponse( + Long todoId, + String icon, + String title, + boolean completed, + LocalDate date, + String dayOfWeek, + Integer durationSeconds, + String priority, + TagResponse tag, + RepeatResponse repeat, + TodoTimerStatus timerStatus, + String memo, + Integer sortOrder, + List subtasks +) { + + public static TodoDetailResponse of(Todo todo, TodoInstance instance, LocalDate date, Tag tag) { + return new TodoDetailResponse( + todo.getId(), + todo.getIcon() != null ? todo.getIcon().name() : null, + todo.getTitle(), + instance != null && instance.isCompleted(), + date, + Weekday.from(date.getDayOfWeek()).name(), + todo.getDurationSeconds(), + todo.getPriority() != null ? todo.getPriority().name() : null, + TagResponse.from(tag), + RepeatResponse.from(todo), + instance != null ? instance.getTimerStatus() : TodoTimerStatus.STOPPED, + todo.getMemo(), + instance != null ? instance.getSortOrder() : null, + todo.getSubtasks().stream() + .map(SubtaskResponse::from) + .toList() + ); + } + + public record TagResponse( + Long tagId, + String name + ) { + public static TagResponse from(Tag tag) { + return tag != null ? new TagResponse(tag.getId(), tag.getName()) : null; + } + } + + public record RepeatResponse( + String type, + List weekdays, + Integer dayOfMonth + ) { + public static RepeatResponse from(Todo todo) { + RepeatType repeatType = todo.getRepeatType(); + List weekdays = repeatType == RepeatType.WEEKLY + ? List.copyOf(todo.getRepeatWeekdays()) + : null; + Integer dayOfMonth = repeatType == RepeatType.MONTHLY + ? todo.getRepeatDayOfMonth() + : null; + + return new RepeatResponse(repeatType.name(), weekdays, dayOfMonth); + } + } + + public record SubtaskResponse( + Long subtaskId, + String content, + boolean completed + ) { + public static SubtaskResponse from(Subtask subtask) { + Objects.requireNonNull(subtask, "하위 태스크는 null일 수 없습니다."); + return new SubtaskResponse( + subtask.getId(), + subtask.getContent(), + subtask.isCompleted() + ); + } + } +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/dto/response/TodoReorderResponse.java b/src/main/java/com/Timo/Timo/domain/todo/dto/response/TodoReorderResponse.java new file mode 100644 index 00000000..e0b0c3f2 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/dto/response/TodoReorderResponse.java @@ -0,0 +1,16 @@ +package com.Timo.Timo.domain.todo.dto.response; + +import com.Timo.Timo.domain.todo.entity.TodoInstance; + +public record TodoReorderResponse( + Long todoId, + Integer sortOrder +) { + + public static TodoReorderResponse from(Long todoId, TodoInstance instance) { + return new TodoReorderResponse( + todoId, + instance.getSortOrder() + ); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/dto/response/TodoStatusChangeResponse.java b/src/main/java/com/Timo/Timo/domain/todo/dto/response/TodoStatusChangeResponse.java new file mode 100644 index 00000000..d01b8108 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/dto/response/TodoStatusChangeResponse.java @@ -0,0 +1,18 @@ +package com.Timo.Timo.domain.todo.dto.response; + +import com.Timo.Timo.domain.todo.entity.TodoInstance; + +public record TodoStatusChangeResponse( + Long todoId, + Boolean completed, + Integer sortOrder +) { + + public static TodoStatusChangeResponse from(Long todoId, TodoInstance instance) { + return new TodoStatusChangeResponse( + todoId, + instance.isCompleted(), + instance.getSortOrder() + ); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/entity/Subtask.java b/src/main/java/com/Timo/Timo/domain/todo/entity/Subtask.java new file mode 100644 index 00000000..4a6f1b95 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/entity/Subtask.java @@ -0,0 +1,67 @@ +package com.Timo.Timo.domain.todo.entity; + +import com.Timo.Timo.global.common.BaseTimeEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "subtasks") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Subtask extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "todo_id", nullable = false) + private Todo todo; + + @Column(name = "content", nullable = false, length = 20) + private String content; + + @Column(name = "sort_order", nullable = false) + private Integer sortOrder; + + @Column(name = "completed", nullable = false) + private boolean completed; + + public static Subtask of(String content, int sortOrder) { + return of(content, sortOrder, false); + } + + public static Subtask of(String content, int sortOrder, boolean completed) { + Subtask subtask = new Subtask(); + subtask.content = content; + subtask.sortOrder = sortOrder; + subtask.completed = completed; + return subtask; + } + + public void update(String content, boolean completed, int sortOrder) { + this.content = content; + this.completed = completed; + this.sortOrder = sortOrder; + } + + void assignTodo(Todo todo) { + this.todo = todo; + } + + public void updateCompleted(boolean completed) { + this.completed = completed; + } +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java b/src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java new file mode 100644 index 00000000..9cf8dafd --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java @@ -0,0 +1,260 @@ +package com.Timo.Timo.domain.todo.entity; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.Timo.Timo.domain.todo.enums.RepeatType; +import com.Timo.Timo.domain.todo.enums.TodoIcon; +import com.Timo.Timo.domain.todo.enums.TodoPriority; +import com.Timo.Timo.domain.todo.enums.Weekday; +import com.Timo.Timo.domain.todo.exception.TodoErrorCode; +import com.Timo.Timo.domain.user.entity.User; +import com.Timo.Timo.global.common.BaseTimeEntity; +import com.Timo.Timo.global.exception.CustomException; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Lob; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "todos") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Todo extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "icon", length = 20) + private TodoIcon icon; + + @Column(name = "title", nullable = false, length = 30) + private String title; + + @Getter(AccessLevel.NONE) + @OneToMany(mappedBy = "todo", cascade = CascadeType.ALL, orphanRemoval = true) + private List subtasks = new ArrayList<>(); + + @Column(name = "start_date", nullable = false) + private LocalDate startDate; + + @Column(name = "end_date", nullable = false) + private LocalDate endDate; + + @Enumerated(EnumType.STRING) + @Column(name = "repeat_type", nullable = false, length = 20) + private RepeatType repeatType; + + @Getter(AccessLevel.NONE) + @ElementCollection + @CollectionTable(name = "todo_repeat_weekdays", joinColumns = @JoinColumn(name = "todo_id")) + @Enumerated(EnumType.STRING) + @Column(name = "weekday", nullable = false, length = 3) + private List repeatWeekdays = new ArrayList<>(); + + @Column(name = "repeat_day_of_month") + private Integer repeatDayOfMonth; + + @Column(name = "duration_seconds", nullable = false) + private Integer durationSeconds; + + @Enumerated(EnumType.STRING) + @Column(name = "priority", length = 20) + private TodoPriority priority; + + @Column(name = "tag_id") + private Long tagId; + + @Lob + @Column(name = "memo") + private String memo; + + @Builder(access = AccessLevel.PRIVATE) + private Todo( + User user, + TodoIcon icon, + String title, + LocalDate startDate, + LocalDate endDate, + RepeatType repeatType, + List repeatWeekdays, + Integer repeatDayOfMonth, + Integer durationSeconds, + TodoPriority priority, + Long tagId, + String memo + ) { + this.user = user; + this.icon = icon; + this.title = title; + this.startDate = startDate; + this.endDate = endDate; + this.repeatType = repeatType; + this.repeatWeekdays = repeatType == RepeatType.WEEKLY && repeatWeekdays != null + ? new ArrayList<>(repeatWeekdays) : new ArrayList<>(); + this.repeatDayOfMonth = repeatType == RepeatType.MONTHLY ? repeatDayOfMonth : null; + this.durationSeconds = durationSeconds; + this.priority = priority; + this.tagId = tagId; + this.memo = memo; + } + + public static Todo create( + User user, + TodoIcon icon, + String title, + List subtaskContents, + LocalDate startDate, + LocalDate endDate, + RepeatType repeatType, + List repeatWeekdays, + Integer repeatDayOfMonth, + int durationSeconds, + TodoPriority priority, + Long tagId, + String memo + ) { + Todo todo = Todo.builder() + .user(user) + .icon(icon) + .title(title) + .startDate(startDate) + .endDate(endDate) + .repeatType(repeatType) + .repeatWeekdays(repeatWeekdays) + .repeatDayOfMonth(repeatDayOfMonth) + .durationSeconds(durationSeconds) + .priority(priority) + .tagId(tagId) + .memo(memo) + .build(); + + if (subtaskContents != null) { + for (int i = 0; i < subtaskContents.size(); i++) { + todo.addSubtask(Subtask.of(subtaskContents.get(i), i + 1)); + } + } + return todo; + } + + public void updateFields( + TodoIcon icon, + String title, + Integer durationSeconds, + TodoPriority priority, + Long tagId, + String memo + ) { + if (icon != null) { + this.icon = icon; + } + if (title != null) { + this.title = title; + } + if (durationSeconds != null) { + this.durationSeconds = durationSeconds; + } + if (priority != null) { + this.priority = priority; + } + if (tagId != null) { + this.tagId = tagId; + } + if (memo != null) { + this.memo = memo; + } + } + + public void changeSchedule( + LocalDate startDate, + LocalDate endDate, + RepeatType repeatType, + List repeatWeekdays, + Integer repeatDayOfMonth + ) { + this.startDate = startDate; + this.endDate = endDate; + this.repeatType = repeatType; + this.repeatWeekdays = repeatType == RepeatType.WEEKLY && repeatWeekdays != null + ? new ArrayList<>(repeatWeekdays) : new ArrayList<>(); + this.repeatDayOfMonth = repeatType == RepeatType.MONTHLY ? repeatDayOfMonth : null; + } + + public void replaceSubtasks(List edits) { + Map existingById = subtasks.stream() + .filter(subtask -> subtask.getId() != null) + .collect(Collectors.toMap(Subtask::getId, subtask -> subtask)); + + List retained = new ArrayList<>(); + for (int i = 0; i < edits.size(); i++) { + SubtaskEdit edit = edits.get(i); + int sortOrder = i + 1; + + if (edit.subtaskId() != null) { + Subtask existing = existingById.get(edit.subtaskId()); + if (existing == null) { + throw new CustomException(TodoErrorCode.SUBTASK_NOT_FOUND); + } + existing.update(edit.content(), edit.completed(), sortOrder); + retained.add(existing); + } else { + Subtask created = Subtask.of(edit.content(), sortOrder, edit.completed()); + created.assignTodo(this); + retained.add(created); + } + } + + subtasks.removeIf(subtask -> !retained.contains(subtask)); + for (Subtask subtask : retained) { + if (!subtasks.contains(subtask)) { + subtasks.add(subtask); + } + } + } + + public record SubtaskEdit(Long subtaskId, String content, boolean completed) { } + + public List getSubtasks() { + if (subtasks == null) { + return List.of(); + } + return Collections.unmodifiableList(subtasks); + } + + public List getRepeatWeekdays() { + return Collections.unmodifiableList(repeatWeekdays); + } + + private void addSubtask(Subtask subtask) { + this.subtasks.add(subtask); + subtask.assignTodo(this); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/entity/TodoInstance.java b/src/main/java/com/Timo/Timo/domain/todo/entity/TodoInstance.java new file mode 100644 index 00000000..a8692ed5 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/entity/TodoInstance.java @@ -0,0 +1,95 @@ +package com.Timo.Timo.domain.todo.entity; + +import java.time.LocalDate; + +import com.Timo.Timo.domain.todo.enums.TodoTimerStatus; +import com.Timo.Timo.global.common.BaseTimeEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "todo_instances", + uniqueConstraints = @UniqueConstraint( + name = "uk_todo_instance", + columnNames = {"todo_id", "instance_date"} + ) +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TodoInstance extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "todo_id", nullable = false) + private Todo todo; + + @Column(name = "instance_date", nullable = false) + private LocalDate date; + + @Column(name = "completed", nullable = false) + private boolean completed; + + @Column(name = "sort_order", nullable = false) + private Integer sortOrder; + + @Enumerated(EnumType.STRING) + @Column(name = "timer_status", nullable = false, length = 20) + private TodoTimerStatus timerStatus = TodoTimerStatus.STOPPED; + + public static TodoInstance of(Todo todo, LocalDate date, int sortOrder) { + TodoInstance instance = new TodoInstance(); + instance.todo = todo; + instance.date = date; + instance.completed = false; + instance.sortOrder = sortOrder; + instance.timerStatus = TodoTimerStatus.STOPPED; + return instance; + } + + public TodoTimerStatus getTimerStatus() { + return timerStatus != null ? timerStatus : TodoTimerStatus.STOPPED; + } + + public void markCompleted() { + this.completed = true; + } + + public void markIncomplete() { + this.completed = false; + } + + public void updateSortOrder(int sortOrder) { + this.sortOrder = sortOrder; + } + + public void startTimer() { + this.timerStatus = TodoTimerStatus.RUNNING; + } + + public void pauseTimer() { + this.timerStatus = TodoTimerStatus.PAUSED; + } + + public void stopTimer() { + this.timerStatus = TodoTimerStatus.STOPPED; + } +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/enums/RepeatType.java b/src/main/java/com/Timo/Timo/domain/todo/enums/RepeatType.java new file mode 100644 index 00000000..b4314d9e --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/enums/RepeatType.java @@ -0,0 +1,8 @@ +package com.Timo.Timo.domain.todo.enums; + +public enum RepeatType { + NONE, + DAILY, + WEEKLY, + MONTHLY +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/enums/TodoIcon.java b/src/main/java/com/Timo/Timo/domain/todo/enums/TodoIcon.java new file mode 100644 index 00000000..98763a64 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/enums/TodoIcon.java @@ -0,0 +1,12 @@ +package com.Timo.Timo.domain.todo.enums; + +public enum TodoIcon { + ICON_1, + ICON_2, + ICON_3, + ICON_4, + ICON_5, + ICON_6, + ICON_7, + ICON_8 +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/enums/TodoPriority.java b/src/main/java/com/Timo/Timo/domain/todo/enums/TodoPriority.java new file mode 100644 index 00000000..c0ec5d36 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/enums/TodoPriority.java @@ -0,0 +1,8 @@ +package com.Timo.Timo.domain.todo.enums; + +public enum TodoPriority { + VERY_HIGH, + HIGH, + MEDIUM, + LOW +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/enums/TodoTimerStatus.java b/src/main/java/com/Timo/Timo/domain/todo/enums/TodoTimerStatus.java new file mode 100644 index 00000000..9f90d0bb --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/enums/TodoTimerStatus.java @@ -0,0 +1,7 @@ +package com.Timo.Timo.domain.todo.enums; + +public enum TodoTimerStatus { + STOPPED, + RUNNING, + PAUSED +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/enums/Weekday.java b/src/main/java/com/Timo/Timo/domain/todo/enums/Weekday.java new file mode 100644 index 00000000..6a9beb77 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/enums/Weekday.java @@ -0,0 +1,25 @@ +package com.Timo.Timo.domain.todo.enums; + +import java.time.DayOfWeek; + +public enum Weekday { + MON, + TUE, + WED, + THU, + FRI, + SAT, + SUN; + + public static Weekday from(DayOfWeek dayOfWeek) { + return switch (dayOfWeek) { + case MONDAY -> MON; + case TUESDAY -> TUE; + case WEDNESDAY -> WED; + case THURSDAY -> THU; + case FRIDAY -> FRI; + case SATURDAY -> SAT; + case SUNDAY -> SUN; + }; + } +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java new file mode 100644 index 00000000..bd3ddbef --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java @@ -0,0 +1,29 @@ +package com.Timo.Timo.domain.todo.exception; + +import org.springframework.http.HttpStatus; + +import com.Timo.Timo.global.exception.code.BaseErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TodoErrorCode implements BaseErrorCode { + + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "TODO_400", "필수 필드가 누락되었거나 형식이 올바르지 않습니다."), + INVALID_TITLE(HttpStatus.BAD_REQUEST, "TODO_400", "투두명은 한국어 20자/영어 30자를 초과할 수 없습니다."), + NO_UPDATE_FIELDS(HttpStatus.BAD_REQUEST, "TODO_400", "수정할 필드가 없습니다."), + INVALID_INDEX(HttpStatus.BAD_REQUEST, "TODO_400", "유효하지 않은 인덱스 값입니다."), + TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_404", "존재하지 않는 투두입니다."), + TODO_NOT_FOUND_ON_DATE(HttpStatus.NOT_FOUND, "TODO_404", "해당 날짜에 존재하지 않는 투두입니다."), + SUBTASK_NOT_FOUND(HttpStatus.NOT_FOUND, "SUBTASK_404", "존재하지 않는 하위 태스크입니다."), + MAX_COUNT_EXCEEDED(HttpStatus.CONFLICT, "TODO_409", "해당 날짜의 투두가 최대 개수(20개)를 초과했습니다."), + COMPLETED_CANNOT_REORDER(HttpStatus.CONFLICT, "TODO_409", "완료된 투두는 순서를 변경할 수 없습니다."), + TIMER_RUNNING(HttpStatus.CONFLICT, "TODO_409", "타이머가 실행 중인 TODO는 변경할 수 없습니다. 타이머를 먼저 종료해주세요."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoExceptionHandler.java b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoExceptionHandler.java new file mode 100644 index 00000000..6e0e659d --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoExceptionHandler.java @@ -0,0 +1,52 @@ +package com.Timo.Timo.domain.todo.exception; + +import java.time.LocalDateTime; + +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import com.Timo.Timo.domain.todo.controller.TodoController; +import com.Timo.Timo.global.exception.dto.ErrorDto; + +import jakarta.servlet.http.HttpServletRequest; + +@RestControllerAdvice(assignableTypes = TodoController.class) +@Order(Ordered.HIGHEST_PRECEDENCE) +public class TodoExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException( + MethodArgumentNotValidException exception, + HttpServletRequest request + ) { + return createInvalidRequestResponse(request); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException( + HttpMessageNotReadableException exception, + HttpServletRequest request + ) { + return createInvalidRequestResponse(request); + } + + private ResponseEntity createInvalidRequestResponse(HttpServletRequest request) { + TodoErrorCode errorCode = TodoErrorCode.INVALID_REQUEST; + ErrorDto response = new ErrorDto( + LocalDateTime.now(), + errorCode.getHttpStatus().value(), + errorCode.getCode(), + errorCode.getMessage(), + request.getRequestURI() + ); + + return ResponseEntity + .status(errorCode.getHttpStatus()) + .body(response); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoSuccessCode.java b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoSuccessCode.java new file mode 100644 index 00000000..aa52e72d --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoSuccessCode.java @@ -0,0 +1,25 @@ +package com.Timo.Timo.domain.todo.exception; + +import org.springframework.http.HttpStatus; + +import com.Timo.Timo.global.exception.code.BaseSuccessCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TodoSuccessCode implements BaseSuccessCode { + + CREATED(HttpStatus.CREATED, "TODO가 생성되었습니다."), + GET_DETAIL(HttpStatus.OK, "TODO 상세 조회 성공"), + STATUS_CHANGED(HttpStatus.OK, "TODO 상태가 변경되었습니다."), + SUBTASK_STATUS_CHANGED(HttpStatus.OK, "하위 태스크 상태가 변경되었습니다."), + UPDATED(HttpStatus.OK, "TODO가 수정되었습니다."), + DELETED(HttpStatus.OK, "TODO가 삭제되었습니다."), + REORDERED(HttpStatus.OK, "TODO 순서가 변경되었습니다."), + ; + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoDailyCompletionStats.java b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoDailyCompletionStats.java new file mode 100644 index 00000000..6511b7ef --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoDailyCompletionStats.java @@ -0,0 +1,9 @@ +package com.Timo.Timo.domain.todo.repository; + +import java.time.LocalDate; + +public interface TodoDailyCompletionStats { + LocalDate getDate(); + Long getTotalCount(); + Long getCompletedCount(); +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoInstanceRepository.java b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoInstanceRepository.java new file mode 100644 index 00000000..2d360a1f --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoInstanceRepository.java @@ -0,0 +1,45 @@ +package com.Timo.Timo.domain.todo.repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +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 com.Timo.Timo.domain.todo.entity.TodoInstance; + +public interface TodoInstanceRepository extends JpaRepository { + + Optional findByTodo_IdAndDate(Long todoId, LocalDate date); + + @Modifying(clearAutomatically = true) + @Query("delete from TodoInstance i where i.todo.id = :todoId") + void deleteByTodoId(@Param("todoId") Long todoId); + + @Query(""" + select ti + from TodoInstance ti + join fetch ti.todo t + where t.user.id = :userId + and ti.date = :date + order by ti.sortOrder asc, t.id asc + """) + List findDailyInstances( + @Param("userId") Long userId, + @Param("date") LocalDate date + ); + + @Query(""" + select i from TodoInstance i + where i.todo.id in :todoIds + and i.date between :from and :to + """) + List findByTodoIdsAndDateRange( + @Param("todoIds") List todoIds, + @Param("from") LocalDate from, + @Param("to") LocalDate to + ); +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoMonthlySummaryStats.java b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoMonthlySummaryStats.java new file mode 100644 index 00000000..7d1b42c6 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoMonthlySummaryStats.java @@ -0,0 +1,7 @@ +package com.Timo.Timo.domain.todo.repository; + +public interface TodoMonthlySummaryStats { + Long getActiveDayCount(); + Long getCompletedTodoCount(); + Long getTotalTodoCount(); +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java new file mode 100644 index 00000000..b6cff949 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java @@ -0,0 +1,82 @@ +package com.Timo.Timo.domain.todo.repository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.Timo.Timo.domain.todo.entity.Todo; + +public interface TodoRepository extends JpaRepository { + + Optional findByIdAndUser_Id(Long id, Long userId); + + @Query(""" + select t from Todo t + where t.user.id = :userId + and t.startDate <= :to + and t.endDate >= :from + order by t.createdAt asc, t.id asc + """) + List findRulesInRange( + @Param("userId") Long userId, + @Param("from") LocalDate from, + @Param("to") LocalDate to + ); + + long countByUser_IdAndStartDateLessThanEqualAndEndDateGreaterThanEqual( + Long userId, LocalDate to, LocalDate from + ); + + @Query(""" + select + ti.date as date, + count(ti.id) as totalCount, + sum(case when ti.completed = true then 1L else 0L end) as completedCount + from TodoInstance ti + join ti.todo t + where t.user.id = :userId + and ti.date between :from and :to + group by ti.date + order by ti.date asc + """) + List findDailyCompletionStats( + @Param("userId") Long userId, + @Param("from") LocalDate from, + @Param("to") LocalDate to + ); + + @Query(""" + select + count(distinct function('date', t.createdAt)) as activeDayCount, + count(distinct case when ti.completed = true then t.id else null end) as completedTodoCount, + count(distinct t.id) as totalTodoCount + from Todo t + left join TodoInstance ti on ti.todo = t + where t.user.id = :userId + and t.createdAt >= :fromInclusive + and t.createdAt < :toExclusive + """) + TodoMonthlySummaryStats findMonthlySummaryStats( + @Param("userId") Long userId, + @Param("fromInclusive") LocalDateTime fromInclusive, + @Param("toExclusive") LocalDateTime toExclusive + ); + + @Query(""" + select t.createdAt + from Todo t + where t.user.id = :userId + and t.createdAt >= :fromInclusive + and t.createdAt < :toExclusive + """) + List findMonthlyTodoCreatedAtTimes( + @Param("userId") Long userId, + @Param("fromInclusive") LocalDateTime fromInclusive, + @Param("toExclusive") LocalDateTime toExclusive + ); +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/service/TodoCapacityChecker.java b/src/main/java/com/Timo/Timo/domain/todo/service/TodoCapacityChecker.java new file mode 100644 index 00000000..89bd5842 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoCapacityChecker.java @@ -0,0 +1,49 @@ +package com.Timo.Timo.domain.todo.service; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.Timo.Timo.domain.todo.entity.Todo; +import com.Timo.Timo.domain.todo.exception.TodoErrorCode; +import com.Timo.Timo.domain.todo.repository.TodoRepository; +import com.Timo.Timo.global.exception.CustomException; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class TodoCapacityChecker { + + private static final int MAX_TODO_COUNT_PER_DATE = 20; + + private final TodoRepository todoRepository; + private final TodoDateCalculator todoDateCalculator; + + public void assertCapacity(Long userId, List newRuleDates) { + assertCapacity(userId, null, newRuleDates); + } + + public void assertCapacity(Long userId, Long excludeTodoId, List newRuleDates) { + if (newRuleDates.isEmpty()) { + return; + } + + LocalDate from = newRuleDates.get(0); + LocalDate to = newRuleDates.get(newRuleDates.size() - 1); + + List existingRules = todoRepository.findRulesInRange(userId, from, to); + + for (LocalDate date : newRuleDates) { + long existingCount = existingRules.stream() + .filter(rule -> !rule.getId().equals(excludeTodoId)) + .filter(rule -> todoDateCalculator.occursOn(rule, date)) + .count(); + + if (existingCount >= MAX_TODO_COUNT_PER_DATE) { + throw new CustomException(TodoErrorCode.MAX_COUNT_EXCEEDED); + } + } + } +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/service/TodoDateCalculator.java b/src/main/java/com/Timo/Timo/domain/todo/service/TodoDateCalculator.java new file mode 100644 index 00000000..746e36b0 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoDateCalculator.java @@ -0,0 +1,147 @@ +package com.Timo.Timo.domain.todo.service; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.Period; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.stereotype.Component; + +import com.Timo.Timo.domain.todo.dto.request.TodoCreateRequest; +import com.Timo.Timo.domain.todo.entity.Todo; +import com.Timo.Timo.domain.todo.enums.RepeatType; +import com.Timo.Timo.domain.todo.enums.Weekday; +import com.Timo.Timo.domain.todo.exception.TodoErrorCode; +import com.Timo.Timo.global.exception.CustomException; + +@Component +public class TodoDateCalculator { + + public static final Period REPEAT_PERIOD = Period.ofYears(1); + + private static final Map DAY_OF_WEEKS = Map.of( + Weekday.MON, DayOfWeek.MONDAY, + Weekday.TUE, DayOfWeek.TUESDAY, + Weekday.WED, DayOfWeek.WEDNESDAY, + Weekday.THU, DayOfWeek.THURSDAY, + Weekday.FRI, DayOfWeek.FRIDAY, + Weekday.SAT, DayOfWeek.SATURDAY, + Weekday.SUN, DayOfWeek.SUNDAY + ); + + public List calculate(TodoCreateRequest request) { + return calculate( + request.date(), + request.repeatType(), + request.repeatWeekdays(), + request.repeatDayOfMonth() + ); + } + + public List calculate( + LocalDate start, + RepeatType repeatType, + List repeatWeekdays, + Integer repeatDayOfMonth + ) { + LocalDate end = start.plus(REPEAT_PERIOD); + + Set dates = switch (repeatType) { + case NONE -> Set.of(start); + case DAILY -> daily(start, end); + case WEEKLY -> weekly(start, end, repeatWeekdays); + case MONTHLY -> monthly(start, end, repeatDayOfMonth); + }; + + if (dates.isEmpty()) { + throw new CustomException(TodoErrorCode.INVALID_REQUEST); + } + return new ArrayList<>(dates); + } + + public boolean occursOn(Todo rule, LocalDate date) { + if (date.isBefore(rule.getStartDate()) || date.isAfter(rule.getEndDate())) { + return false; + } + + return switch (rule.getRepeatType()) { + case NONE -> date.equals(rule.getStartDate()); + case DAILY -> true; + case WEEKLY -> matchesWeekly(date, rule.getRepeatWeekdays()); + case MONTHLY -> matchesMonthly(date, rule.getStartDate(), rule.getRepeatDayOfMonth()); + }; + } + + private boolean matchesWeekly(LocalDate date, List weekdays) { + Set targets = weekdays.stream() + .map(DAY_OF_WEEKS::get) + .collect(Collectors.toSet()); + return targets.contains(date.getDayOfWeek()); + } + + private boolean matchesMonthly(LocalDate date, LocalDate startDate, int dayOfMonth) { + if (date.getDayOfMonth() != dayOfMonth) { + return false; + } + // 시작 월의 지정 일자가 시작일보다 이전이면 그 달은 스킵 + if (date.getYear() == startDate.getYear() + && date.getMonth() == startDate.getMonth() + && dayOfMonth < startDate.getDayOfMonth()) { + return false; + } + return true; + } + + private Set daily(LocalDate start, LocalDate end) { + return streamDates(start, end) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + private Set weekly(LocalDate start, LocalDate end, List weekdays) { + Set targets = weekdays.stream() + .map(DAY_OF_WEEKS::get) + .collect(Collectors.toSet()); + + return streamDates(start, end) + .filter(date -> targets.contains(date.getDayOfWeek())) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + private Set monthly(LocalDate start, LocalDate end, int dayOfMonth) { + Set dates = new LinkedHashSet<>(); + LocalDate firstMonth = firstApplicableMonth(start, dayOfMonth); + + for (LocalDate month = firstMonth; !month.isAfter(end); month = month.plusMonths(1)) { + resolveDateInMonth(month, dayOfMonth) + .filter(date -> !date.isAfter(end)) + .ifPresent(dates::add); + } + return dates; + } + + private LocalDate firstApplicableMonth(LocalDate start, int dayOfMonth) { + LocalDate startMonth = start.withDayOfMonth(1); + if (dayOfMonth < start.getDayOfMonth()) { + return startMonth.plusMonths(1); + } + return startMonth; + } + + private Optional resolveDateInMonth(LocalDate month, int dayOfMonth) { + if (dayOfMonth > month.lengthOfMonth()) { + return Optional.empty(); + } + return Optional.of(month.withDayOfMonth(dayOfMonth)); + } + + private Stream streamDates(LocalDate start, LocalDate end) { + return start.datesUntil(end.plusDays(1)); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/service/TodoInstanceReorderer.java b/src/main/java/com/Timo/Timo/domain/todo/service/TodoInstanceReorderer.java new file mode 100644 index 00000000..be2e230d --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoInstanceReorderer.java @@ -0,0 +1,140 @@ +package com.Timo.Timo.domain.todo.service; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Component; + +import com.Timo.Timo.domain.todo.entity.Todo; +import com.Timo.Timo.domain.todo.entity.TodoInstance; +import com.Timo.Timo.domain.todo.exception.TodoErrorCode; +import com.Timo.Timo.domain.todo.repository.TodoInstanceRepository; +import com.Timo.Timo.domain.todo.repository.TodoRepository; +import com.Timo.Timo.global.exception.CustomException; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class TodoInstanceReorderer { + + private final TodoRepository todoRepository; + private final TodoInstanceRepository todoInstanceRepository; + private final TodoDateCalculator todoDateCalculator; + + public TodoInstance applyCompletion(Long userId, Todo targetRule, LocalDate date, boolean completed) { + Map instancesByTodoId = materializeDayGroup(userId, date); + TodoInstance target = requireTarget(instancesByTodoId, targetRule); + + if (completed) { + moveToCompletedBottom(target, instancesByTodoId.values()); + } else { + moveToIncompleteTop(target, instancesByTodoId.values()); + } + return target; + } + + public TodoInstance applyReorder(Long userId, Todo targetRule, LocalDate date, Integer newIndex) { + Map instancesByTodoId = materializeDayGroup(userId, date); + TodoInstance target = requireTarget(instancesByTodoId, targetRule); + + if (target.isCompleted()) { + throw new CustomException(TodoErrorCode.COMPLETED_CANNOT_REORDER); + } + + List incomplete = instancesByTodoId.values().stream() + .filter(instance -> !instance.isCompleted()) + .sorted(Comparator.comparingInt(TodoInstance::getSortOrder)) + .collect(Collectors.toCollection(ArrayList::new)); + + if (newIndex == null || newIndex < 0 || newIndex >= incomplete.size()) { + throw new CustomException(TodoErrorCode.INVALID_INDEX); + } + + incomplete.remove(target); + incomplete.add(newIndex, target); + + List completed = instancesByTodoId.values().stream() + .filter(TodoInstance::isCompleted) + .sorted(Comparator.comparingInt(TodoInstance::getSortOrder)) + .toList(); + + int sortOrder = 0; + for (TodoInstance instance : incomplete) { + instance.updateSortOrder(sortOrder++); + } + for (TodoInstance instance : completed) { + instance.updateSortOrder(sortOrder++); + } + return target; + } + + private TodoInstance requireTarget(Map instancesByTodoId, Todo targetRule) { + TodoInstance target = instancesByTodoId.get(targetRule.getId()); + if (target == null) { + // 해당 날짜에 발생하지 않는 규칙이면 그룹에 인스턴스가 없다. + throw new CustomException(TodoErrorCode.TODO_NOT_FOUND); + } + return target; + } + + private Map materializeDayGroup(Long userId, LocalDate date) { + List occurringRules = todoRepository.findRulesInRange(userId, date, date).stream() + .filter(rule -> todoDateCalculator.occursOn(rule, date)) + .toList(); + + List todoIds = occurringRules.stream() + .map(Todo::getId) + .toList(); + + Map existing = todoIds.isEmpty() + ? Map.of() + : todoInstanceRepository.findByTodoIdsAndDateRange(todoIds, date, date).stream() + .collect(Collectors.toMap( + instance -> instance.getTodo().getId(), + Function.identity() + )); + + Map result = new LinkedHashMap<>(); + for (int index = 0; index < occurringRules.size(); index++) { + Todo rule = occurringRules.get(index); + TodoInstance instance = existing.get(rule.getId()); + if (instance == null) { + instance = todoInstanceRepository.save(TodoInstance.of(rule, date, index)); + } + result.put(rule.getId(), instance); + } + return result; + } + + private void moveToCompletedBottom(TodoInstance target, Iterable group) { + target.markCompleted(); + + int maxCompletedSortOrder = -1; + for (TodoInstance instance : group) { + if (instance != target && instance.isCompleted()) { + maxCompletedSortOrder = Math.max(maxCompletedSortOrder, instance.getSortOrder()); + } + } + target.updateSortOrder(maxCompletedSortOrder + 1); + } + + private void moveToIncompleteTop(TodoInstance target, Iterable group) { + target.markIncomplete(); + + List others = new ArrayList<>(); + for (TodoInstance instance : group) { + if (instance != target && !instance.isCompleted()) { + others.add(instance); + } + } + others.forEach(instance -> instance.updateSortOrder(instance.getSortOrder() + 1)); + target.updateSortOrder(0); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java b/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java new file mode 100644 index 00000000..207a28fd --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java @@ -0,0 +1,290 @@ +package com.Timo.Timo.domain.todo.service; + +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.time.ZoneId; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.Timo.Timo.domain.tag.entity.Tag; +import com.Timo.Timo.domain.tag.exception.TagErrorCode; +import com.Timo.Timo.domain.tag.repository.TagRepository; +import com.Timo.Timo.domain.timer.service.TimerService; +import com.Timo.Timo.domain.todo.dto.request.SubtaskStatusUpdateRequest; +import com.Timo.Timo.domain.todo.dto.request.TodoCreateRequest; +import com.Timo.Timo.domain.todo.dto.request.TodoReorderRequest; +import com.Timo.Timo.domain.todo.dto.request.TodoStatusUpdateRequest; +import com.Timo.Timo.domain.todo.dto.request.TodoSubtaskUpdateRequest; +import com.Timo.Timo.domain.todo.dto.request.TodoUpdateRequest; +import com.Timo.Timo.domain.todo.dto.response.SubtaskStatusChangeResponse; +import com.Timo.Timo.domain.todo.dto.response.TodoCreateResponse; +import com.Timo.Timo.domain.todo.dto.response.TodoDetailResponse; +import com.Timo.Timo.domain.todo.dto.response.TodoReorderResponse; +import com.Timo.Timo.domain.todo.dto.response.TodoStatusChangeResponse; +import com.Timo.Timo.domain.todo.entity.Subtask; +import com.Timo.Timo.domain.todo.entity.Todo; +import com.Timo.Timo.domain.todo.entity.TodoInstance; +import com.Timo.Timo.domain.todo.enums.RepeatType; +import com.Timo.Timo.domain.todo.enums.Weekday; +import com.Timo.Timo.domain.todo.exception.TodoErrorCode; +import com.Timo.Timo.domain.todo.repository.TodoInstanceRepository; +import com.Timo.Timo.domain.todo.repository.TodoRepository; +import com.Timo.Timo.domain.todo.vo.Duration; +import com.Timo.Timo.domain.user.entity.User; +import com.Timo.Timo.domain.user.exception.UserErrorCode; +import com.Timo.Timo.domain.user.repository.UserRepository; +import com.Timo.Timo.global.exception.CustomException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TodoService { + + private final TodoRepository todoRepository; + private final TodoInstanceRepository todoInstanceRepository; + private final UserRepository userRepository; + private final TagRepository tagRepository; + private final TodoDateCalculator todoDateCalculator; + private final TodoCapacityChecker todoCapacityChecker; + private final TodoInstanceReorderer todoInstanceReorderer; + private final TimerService timerService; + + @Transactional + public TodoCreateResponse createTodo(Long userId, TodoCreateRequest request) { + User user = userRepository.findByIdForUpdate(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + validateTagExists(request.tagId()); + + Duration duration = Duration.parse(request.duration()); + List todoDates = todoDateCalculator.calculate(request); + todoCapacityChecker.assertCapacity(userId, todoDates); + + LocalDate startDate = request.date(); + LocalDate endDate = resolveEndDate(startDate, request.repeatType()); + + Todo todo = Todo.create( + user, + request.icon(), + request.title(), + request.subtasks(), + startDate, + endDate, + request.repeatType(), + request.repeatWeekdays(), + request.repeatDayOfMonth(), + duration.seconds(), + request.priority(), + request.tagId(), + request.memo() + ); + + Todo savedTodo = todoRepository.save(todo); + return TodoCreateResponse.from(savedTodo); + } + + public TodoDetailResponse getTodoDetail(Long userId, Long todoId, String dateValue) { + Todo todo = todoRepository.findByIdAndUser_Id(todoId, userId) + .orElseThrow(() -> new CustomException(TodoErrorCode.TODO_NOT_FOUND)); + + LocalDate date = parseDate(dateValue); + if (!todoDateCalculator.occursOn(todo, date)) { + throw new CustomException(TodoErrorCode.TODO_NOT_FOUND_ON_DATE); + } + + TodoInstance instance = todoInstanceRepository + .findByTodo_IdAndDate(todo.getId(), date) + .orElse(null); + + Tag tag = todo.getTagId() != null + ? tagRepository.findById(todo.getTagId()).orElse(null) + : null; + + return TodoDetailResponse.of(todo, instance, date, tag); + } + + @Transactional + public TodoStatusChangeResponse changeCompletion(Long userId, Long todoId, TodoStatusUpdateRequest request) { + Todo todo = todoRepository.findByIdAndUser_Id(todoId, userId) + .orElseThrow(() -> new CustomException(TodoErrorCode.TODO_NOT_FOUND)); + + LocalDate date = resolveDate(userId, request.date()); + if (!todoDateCalculator.occursOn(todo, date)) { + throw new CustomException(TodoErrorCode.TODO_NOT_FOUND); + } + + if (timerService.hasActiveTimer(todoId)) { + throw new CustomException(TodoErrorCode.TIMER_RUNNING); + } + + TodoInstance instance = todoInstanceReorderer.applyCompletion(userId, todo, date, request.isCompleted()); + return TodoStatusChangeResponse.from(todoId, instance); + } + + @Transactional + public SubtaskStatusChangeResponse changeSubtaskCompletion( + Long userId, Long todoId, Long subtaskId, SubtaskStatusUpdateRequest request) { + Todo todo = todoRepository.findByIdAndUser_Id(todoId, userId) + .orElseThrow(() -> new CustomException(TodoErrorCode.TODO_NOT_FOUND)); + + Subtask subtask = todo.getSubtasks().stream() + .filter(it -> it.getId().equals(subtaskId)) + .findFirst() + .orElseThrow(() -> new CustomException(TodoErrorCode.SUBTASK_NOT_FOUND)); + + subtask.updateCompleted(request.isCompleted()); + return SubtaskStatusChangeResponse.from(subtask); + } + + @Transactional + public TodoReorderResponse reorderTodo(Long userId, Long todoId, TodoReorderRequest request) { + Todo todo = todoRepository.findByIdAndUser_Id(todoId, userId) + .orElseThrow(() -> new CustomException(TodoErrorCode.TODO_NOT_FOUND)); + + LocalDate date = resolveDate(userId, request.date()); + if (!todoDateCalculator.occursOn(todo, date)) { + throw new CustomException(TodoErrorCode.TODO_NOT_FOUND); + } + + TodoInstance instance = todoInstanceReorderer.applyReorder(userId, todo, date, request.newIndex()); + return TodoReorderResponse.from(todoId, instance); + } + + @Transactional + public void updateTodo(Long userId, Long todoId, TodoUpdateRequest request) { + if (request.hasNoUpdatableField()) { + throw new CustomException(TodoErrorCode.NO_UPDATE_FIELDS); + } + + Todo todo = todoRepository.findByIdAndUser_Id(todoId, userId) + .orElseThrow(() -> new CustomException(TodoErrorCode.TODO_NOT_FOUND)); + + // 타이머 실행 중에는 일정/소요시간 변경을 막는다. (그 외 필드 수정은 허용) + boolean changesTimerSensitiveFields = request.durationSeconds() != null || isScheduleChanged(request); + if (changesTimerSensitiveFields && timerService.hasActiveTimer(todoId)) { + throw new CustomException(TodoErrorCode.TIMER_RUNNING); + } + + validateTagExists(request.tagId()); + + todo.updateFields( + request.icon(), + request.title(), + request.durationSeconds(), + request.priority(), + request.tagId(), + request.memo() + ); + + applyScheduleChange(todo, request); + + if (request.subtasks() != null) { + todo.replaceSubtasks(toSubtaskEdits(request.subtasks())); + } + } + + @Transactional + public void deleteTodo(Long userId, Long todoId) { + Todo todo = todoRepository.findByIdAndUser_Id(todoId, userId) + .orElseThrow(() -> new CustomException(TodoErrorCode.TODO_NOT_FOUND)); + + if (timerService.hasActiveTimer(todoId)) { + throw new CustomException(TodoErrorCode.TIMER_RUNNING); + } + + timerService.deleteTimersByTodo(todoId); + todoInstanceRepository.deleteByTodoId(todoId); + todoRepository.delete(todo); + } + + private boolean isScheduleChanged(TodoUpdateRequest request) { + return request.date() != null + || request.repeatType() != null + || request.repeatWeekdays() != null + || request.repeatDayOfMonth() != null; + } + + private void applyScheduleChange(Todo todo, TodoUpdateRequest request) { + if (!isScheduleChanged(request)) { + return; + } + + LocalDate startDate = request.date() != null ? request.date() : todo.getStartDate(); + RepeatType repeatType = request.repeatType() != null ? request.repeatType() : todo.getRepeatType(); + List repeatWeekdays = request.repeatWeekdays() != null + ? request.repeatWeekdays() : todo.getRepeatWeekdays(); + Integer repeatDayOfMonth = request.repeatDayOfMonth() != null + ? request.repeatDayOfMonth() : todo.getRepeatDayOfMonth(); + + validateRepeatRule(repeatType, repeatWeekdays, repeatDayOfMonth); + + Long userId = todo.getUser().getId(); + userRepository.findByIdForUpdate(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + List todoDates = todoDateCalculator.calculate(startDate, repeatType, repeatWeekdays, repeatDayOfMonth); + todoCapacityChecker.assertCapacity(userId, todo.getId(), todoDates); + + LocalDate endDate = resolveEndDate(startDate, repeatType); + todo.changeSchedule(startDate, endDate, repeatType, repeatWeekdays, repeatDayOfMonth); + } + + private void validateRepeatRule(RepeatType repeatType, List repeatWeekdays, Integer repeatDayOfMonth) { + boolean valid = switch (repeatType) { + case WEEKLY -> repeatWeekdays != null && !repeatWeekdays.isEmpty(); + case MONTHLY -> repeatDayOfMonth != null; + case NONE, DAILY -> true; + }; + if (!valid) { + throw new CustomException(TodoErrorCode.INVALID_REQUEST); + } + } + + private LocalDate resolveDate(Long userId, LocalDate requestedDate) { + if (requestedDate != null) { + return requestedDate; + } + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + return LocalDate.now(ZoneId.of(user.getZoneId())); + } + + private LocalDate parseDate(String dateValue) { + if (dateValue == null || dateValue.isBlank()) { + throw new CustomException(TodoErrorCode.INVALID_REQUEST); + } + + try { + return LocalDate.parse(dateValue); + } catch (DateTimeParseException exception) { + throw new CustomException(TodoErrorCode.INVALID_REQUEST); + } + } + + private List toSubtaskEdits(List subtasks) { + return subtasks.stream() + .map(subtask -> new Todo.SubtaskEdit( + subtask.subtaskId(), + subtask.content(), + subtask.completed() + )) + .toList(); + } + + private void validateTagExists(Long tagId) { + if (tagId != null && !tagRepository.existsById(tagId)) { + throw new CustomException(TagErrorCode.TAG_NOT_FOUND); + } + } + + private LocalDate resolveEndDate(LocalDate startDate, RepeatType repeatType) { + if (repeatType == RepeatType.NONE) { + return startDate; + } + return startDate.plus(TodoDateCalculator.REPEAT_PERIOD); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/validation/SubtaskContentValidator.java b/src/main/java/com/Timo/Timo/domain/todo/validation/SubtaskContentValidator.java new file mode 100644 index 00000000..9296c605 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/validation/SubtaskContentValidator.java @@ -0,0 +1,23 @@ +package com.Timo.Timo.domain.todo.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class SubtaskContentValidator implements ConstraintValidator { + + private static final int MAX_KOREAN_LENGTH = 12; + private static final int MAX_ENGLISH_LENGTH = 20; + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null) { + return true; + } + int maxLength = containsKorean(value) ? MAX_KOREAN_LENGTH : MAX_ENGLISH_LENGTH; + return value.length() <= maxLength; + } + + private boolean containsKorean(String value) { + return value.chars().anyMatch(character -> character >= 0xAC00 && character <= 0xD7A3); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/validation/TodoTitleValidator.java b/src/main/java/com/Timo/Timo/domain/todo/validation/TodoTitleValidator.java new file mode 100644 index 00000000..3dbf5642 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/validation/TodoTitleValidator.java @@ -0,0 +1,23 @@ +package com.Timo.Timo.domain.todo.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class TodoTitleValidator implements ConstraintValidator { + + private static final int MAX_KOREAN_LENGTH = 20; + private static final int MAX_ENGLISH_LENGTH = 30; + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null) { + return true; // @NotBlank가 별도로 처리 + } + int maxLength = containsKorean(value) ? MAX_KOREAN_LENGTH : MAX_ENGLISH_LENGTH; + return value.length() <= maxLength; + } + + private boolean containsKorean(String value) { + return value.chars().anyMatch(character -> character >= 0xAC00 && character <= 0xD7A3); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/validation/ValidSubtaskContent.java b/src/main/java/com/Timo/Timo/domain/todo/validation/ValidSubtaskContent.java new file mode 100644 index 00000000..5e43a6ce --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/validation/ValidSubtaskContent.java @@ -0,0 +1,21 @@ +package com.Timo.Timo.domain.todo.validation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.TYPE_USE}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = SubtaskContentValidator.class) +public @interface ValidSubtaskContent { + + String message() default "하위 태스크는 한국어 12자/영어 20자를 초과할 수 없습니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/validation/ValidTodoTitle.java b/src/main/java/com/Timo/Timo/domain/todo/validation/ValidTodoTitle.java new file mode 100644 index 00000000..13be1c26 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/validation/ValidTodoTitle.java @@ -0,0 +1,21 @@ +package com.Timo.Timo.domain.todo.validation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = TodoTitleValidator.class) +public @interface ValidTodoTitle { + + String message() default "제목은 한국어 20자/영어 30자를 초과할 수 없습니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/vo/Duration.java b/src/main/java/com/Timo/Timo/domain/todo/vo/Duration.java new file mode 100644 index 00000000..bb3e6e03 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/vo/Duration.java @@ -0,0 +1,31 @@ +package com.Timo.Timo.domain.todo.vo; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.Timo.Timo.domain.todo.exception.TodoErrorCode; +import com.Timo.Timo.global.exception.CustomException; + +public record Duration(int seconds) { + + private static final Pattern PATTERN = Pattern.compile("^(\\d+):([0-5]\\d)$"); + + public static Duration parse(String value) { + if (value == null || value.isBlank()) { + throw new CustomException(TodoErrorCode.INVALID_REQUEST); + } + + Matcher matcher = PATTERN.matcher(value); + if (!matcher.matches()) { + throw new CustomException(TodoErrorCode.INVALID_REQUEST); + } + + try { + int minutes = Integer.parseInt(matcher.group(1)); + int seconds = Integer.parseInt(matcher.group(2)); + return new Duration(Math.addExact(Math.multiplyExact(minutes, 60), seconds)); + } catch (ArithmeticException | NumberFormatException e) { + throw new CustomException(TodoErrorCode.INVALID_REQUEST); + } + } +} diff --git a/src/main/java/com/Timo/Timo/domain/user/controller/OnboardingController.java b/src/main/java/com/Timo/Timo/domain/user/controller/OnboardingController.java new file mode 100644 index 00000000..3e182760 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/controller/OnboardingController.java @@ -0,0 +1,39 @@ +package com.Timo.Timo.domain.user.controller; + +import com.Timo.Timo.domain.user.docs.OnboardingControllerDocs; +import com.Timo.Timo.domain.user.dto.request.OnboardingRequest; +import com.Timo.Timo.domain.user.dto.response.OnboardingResponse; +import com.Timo.Timo.domain.user.factory.OnboardingResponseFactory; +import com.Timo.Timo.domain.user.service.OnboardingService; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.response.BaseResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +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; + +@RestController +@RequestMapping("/api/v1/users") +@RequiredArgsConstructor +@Tag(name = "Onboarding", description = "온보딩 API") +public class OnboardingController implements OnboardingControllerDocs { + + private final OnboardingService onboardingService; + + @Override + @PostMapping("/onboarding") + public ResponseEntity> completeOnboarding( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody OnboardingRequest request + ){ + Long userId = userDetails.getUserId(); + OnboardingResponse response = onboardingService.completeOnboarding(userId, request); + + return OnboardingResponseFactory.completed(response); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/user/controller/UserController.java b/src/main/java/com/Timo/Timo/domain/user/controller/UserController.java new file mode 100644 index 00000000..e8a9d859 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/controller/UserController.java @@ -0,0 +1,76 @@ +package com.Timo.Timo.domain.user.controller; + +import com.Timo.Timo.domain.user.docs.UserLanguageDocs; +import com.Timo.Timo.domain.user.docs.UserProfileDocs; +import com.Timo.Timo.domain.user.docs.UserTimezoneDocs; +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.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.Timo.Timo.domain.user.dto.request.UpdateLanguageRequest; +import com.Timo.Timo.domain.user.dto.request.UpdateTimezoneRequest; +import com.Timo.Timo.domain.user.dto.response.UpdateLanguageResponse; +import com.Timo.Timo.domain.user.dto.response.UpdateTimezoneResponse; +import com.Timo.Timo.domain.user.dto.response.UserProfileResponse; +import com.Timo.Timo.domain.user.exception.UserSuccessCode; +import com.Timo.Timo.domain.user.service.UserService; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/users") +@RequiredArgsConstructor +@Tag(name = "User", description = "사용자 API") +public class UserController implements UserProfileDocs, UserLanguageDocs, UserTimezoneDocs { + + private final UserService userService; + + @Override + @GetMapping + public ResponseEntity> getMyProfile( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Long userId = userDetails.getUserId(); + UserProfileResponse response = userService.getMyProfile(userId); + + return ResponseEntity.ok( + BaseResponse.onSuccess(UserSuccessCode.PROFILE_RETRIEVED, response) + ); + } + + @Override + @PatchMapping + public ResponseEntity> updateLanguage( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody UpdateLanguageRequest request + ) { + Long userId = userDetails.getUserId(); + UpdateLanguageResponse response = userService.updateLanguage(userId, request); + + return ResponseEntity.ok( + BaseResponse.onSuccess(UserSuccessCode.LANGUAGE_UPDATED, response) + ); + } + + @Override + @PatchMapping("/timezone") + public ResponseEntity> updateTimezone( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody UpdateTimezoneRequest request + ) { + Long userId = userDetails.getUserId(); + UpdateTimezoneResponse response = userService.updateTimezone(userId, request); + + return ResponseEntity.ok( + BaseResponse.onSuccess(UserSuccessCode.TIMEZONE_UPDATED, response) + ); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/user/docs/OnboardingControllerDocs.java b/src/main/java/com/Timo/Timo/domain/user/docs/OnboardingControllerDocs.java new file mode 100644 index 00000000..13b7de82 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/docs/OnboardingControllerDocs.java @@ -0,0 +1,58 @@ +package com.Timo.Timo.domain.user.docs; + +import com.Timo.Timo.domain.user.dto.request.OnboardingRequest; +import com.Timo.Timo.domain.user.dto.response.OnboardingResponse; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.exception.dto.ErrorDto; +import com.Timo.Timo.global.response.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import org.springframework.http.ResponseEntity; + +public interface OnboardingControllerDocs { + + @Operation( + summary = "온보딩 완료", + description = "언어, 예측 정확도, 기상/취침 시간을 저장하고 온보딩을 완료 처리합니다.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "온보딩 완료 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = BaseResponse.class) + ) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청인 경우 (형식 오류, 필드 누락, 범위 초과 등)", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> completeOnboarding( + CustomUserDetails userDetails, + OnboardingRequest request + ); +} diff --git a/src/main/java/com/Timo/Timo/domain/user/docs/UserLanguageDocs.java b/src/main/java/com/Timo/Timo/domain/user/docs/UserLanguageDocs.java new file mode 100644 index 00000000..4455f852 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/docs/UserLanguageDocs.java @@ -0,0 +1,79 @@ +package com.Timo.Timo.domain.user.docs; + +import org.springframework.http.ResponseEntity; + +import com.Timo.Timo.domain.user.dto.request.UpdateLanguageRequest; +import com.Timo.Timo.domain.user.dto.response.UpdateLanguageResponse; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.exception.dto.ErrorDto; +import com.Timo.Timo.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +public interface UserLanguageDocs { + + @Operation( + summary = "서비스 언어 수정", + description = """ + 현재 로그인한 사용자의 서비스 언어를 변경합니다. + + 변경 가능한 값은 `KO`, `EN`입니다. + 이름, 이메일, 프로필 이미지는 구글 계정 기반 정보이므로 해당 API에서 변경할 수 없습니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "언어 설정 수정 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = "language가 누락되었거나 KO, EN 이외의 값인 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "Access Token의 사용자 ID에 해당하는 사용자를 찾을 수 없는 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> updateLanguage( + @Parameter(hidden = true) CustomUserDetails userDetails, + @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + description = "변경할 서비스 언어", + content = @Content( + schema = @Schema(implementation = UpdateLanguageRequest.class) + ) + ) + UpdateLanguageRequest request + ); +} diff --git a/src/main/java/com/Timo/Timo/domain/user/docs/UserProfileDocs.java b/src/main/java/com/Timo/Timo/domain/user/docs/UserProfileDocs.java new file mode 100644 index 00000000..4c114e65 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/docs/UserProfileDocs.java @@ -0,0 +1,62 @@ +package com.Timo.Timo.domain.user.docs; + +import org.springframework.http.ResponseEntity; + +import com.Timo.Timo.domain.user.dto.response.UserProfileResponse; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.exception.dto.ErrorDto; +import com.Timo.Timo.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +public interface UserProfileDocs { + + @Operation( + summary = "내 프로필 조회", + description = """ + 현재 로그인한 사용자의 프로필 정보를 조회합니다. + + Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. + Access Token의 사용자 ID와 일치하는 사용자의 정보가 반환됩니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "프로필 조회 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "Access Token의 사용자 ID에 해당하는 사용자를 찾을 수 없는 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> getMyProfile( + @Parameter(hidden = true) CustomUserDetails userDetails + ); +} diff --git a/src/main/java/com/Timo/Timo/domain/user/docs/UserTimezoneDocs.java b/src/main/java/com/Timo/Timo/domain/user/docs/UserTimezoneDocs.java new file mode 100644 index 00000000..436d1fb5 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/docs/UserTimezoneDocs.java @@ -0,0 +1,80 @@ +package com.Timo.Timo.domain.user.docs; + +import org.springframework.http.ResponseEntity; + +import com.Timo.Timo.domain.user.dto.request.UpdateTimezoneRequest; +import com.Timo.Timo.domain.user.dto.response.UpdateTimezoneResponse; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.exception.dto.ErrorDto; +import com.Timo.Timo.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +public interface UserTimezoneDocs { + + @Operation( + summary = "시간대 수정", + description = """ + 현재 로그인한 사용자의 시간대를 변경합니다. + + `zoneId`는 IANA 시간대 ID(예: `Asia/Seoul`, `America/New_York`, `UTC`)여야 합니다. + 클라이언트는 기기의 실제 시간대를 감지해 전달하는 것을 권장합니다. + 홈 화면의 '오늘' 판정 등 서버의 날짜 계산이 이 값을 기준으로 동작합니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "시간대 설정 수정 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = "zoneId가 누락되었거나 유효한 IANA 시간대 ID가 아닌 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "Access Token의 사용자 ID에 해당하는 사용자를 찾을 수 없는 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> updateTimezone( + @Parameter(hidden = true) CustomUserDetails userDetails, + @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + description = "변경할 시간대 ID", + content = @Content( + schema = @Schema(implementation = UpdateTimezoneRequest.class) + ) + ) + UpdateTimezoneRequest request + ); +} diff --git a/src/main/java/com/Timo/Timo/domain/user/dto/request/OnboardingRequest.java b/src/main/java/com/Timo/Timo/domain/user/dto/request/OnboardingRequest.java new file mode 100644 index 00000000..7b11a4dc --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/dto/request/OnboardingRequest.java @@ -0,0 +1,27 @@ +package com.Timo.Timo.domain.user.dto.request; + +import com.Timo.Timo.domain.user.enums.Language; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +public record OnboardingRequest( + + @NotNull(message = "language는 필수입니다.") + Language language, + + @NotNull(message = "predictionAccuracy는 필수입니다.") + @Min(value = 1, message = "predictionAccuracy는 1~4 사이여야 합니다.") + @Max(value = 4, message = "predictionAccuracy는 1~4 사이여야 합니다.") + Long predictionAccuracy, + + @NotNull(message = "wakeupTime은 필수입니다.") + @Pattern(regexp = "^([01]\\d|2[0-3]):[0-5]\\d$", message = "wakeUpTime 형식은 HH:MM 이어야 합니다.") + String wakeUpTime, + + @NotNull(message = "bedTime은 필수입니다.") + @Pattern(regexp = "^([01]\\d|2[0-3]):[0-5]\\d$", message = "bedTime 형식은 HH:MM 이어야 합니다.") + String bedTime +){ +} diff --git a/src/main/java/com/Timo/Timo/domain/user/dto/request/UpdateLanguageRequest.java b/src/main/java/com/Timo/Timo/domain/user/dto/request/UpdateLanguageRequest.java new file mode 100644 index 00000000..007368a2 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/dto/request/UpdateLanguageRequest.java @@ -0,0 +1,13 @@ +package com.Timo.Timo.domain.user.dto.request; + +import com.Timo.Timo.domain.user.enums.Language; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record UpdateLanguageRequest( + @NotNull + @Schema(description = "변경할 서비스 언어") + Language language +) { +} diff --git a/src/main/java/com/Timo/Timo/domain/user/dto/request/UpdateTimezoneRequest.java b/src/main/java/com/Timo/Timo/domain/user/dto/request/UpdateTimezoneRequest.java new file mode 100644 index 00000000..0cd611bd --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/dto/request/UpdateTimezoneRequest.java @@ -0,0 +1,11 @@ +package com.Timo.Timo.domain.user.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record UpdateTimezoneRequest( + @NotBlank + @Schema(description = "IANA 시간대 ID", example = "Asia/Seoul") + String zoneId +) { +} diff --git a/src/main/java/com/Timo/Timo/domain/user/dto/response/OnboardingResponse.java b/src/main/java/com/Timo/Timo/domain/user/dto/response/OnboardingResponse.java new file mode 100644 index 00000000..de52f7e4 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/dto/response/OnboardingResponse.java @@ -0,0 +1,8 @@ +package com.Timo.Timo.domain.user.dto.response; + +import lombok.Builder; + +@Builder +public record OnboardingResponse( + boolean onboardingCompleted +) {} diff --git a/src/main/java/com/Timo/Timo/domain/user/dto/response/UpdateLanguageResponse.java b/src/main/java/com/Timo/Timo/domain/user/dto/response/UpdateLanguageResponse.java new file mode 100644 index 00000000..8d9be926 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/dto/response/UpdateLanguageResponse.java @@ -0,0 +1,11 @@ +package com.Timo.Timo.domain.user.dto.response; + +import com.Timo.Timo.domain.user.enums.Language; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record UpdateLanguageResponse( + @Schema(description = "변경된 서비스 언어") + Language language +) { +} diff --git a/src/main/java/com/Timo/Timo/domain/user/dto/response/UpdateTimezoneResponse.java b/src/main/java/com/Timo/Timo/domain/user/dto/response/UpdateTimezoneResponse.java new file mode 100644 index 00000000..0a3f4888 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/dto/response/UpdateTimezoneResponse.java @@ -0,0 +1,9 @@ +package com.Timo.Timo.domain.user.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record UpdateTimezoneResponse( + @Schema(description = "변경된 시간대 ID", example = "Asia/Seoul") + String zoneId +) { +} diff --git a/src/main/java/com/Timo/Timo/domain/user/dto/response/UserProfileResponse.java b/src/main/java/com/Timo/Timo/domain/user/dto/response/UserProfileResponse.java new file mode 100644 index 00000000..5f7a3918 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/dto/response/UserProfileResponse.java @@ -0,0 +1,28 @@ +package com.Timo.Timo.domain.user.dto.response; + +import com.Timo.Timo.domain.user.entity.User; + +public record UserProfileResponse( + Long id, + String name, + String email, + String profileImageUrl, + String language, + String zoneId, + boolean calendarConnected, + String calendarEmail +) { + + public static UserProfileResponse from(User user) { + return new UserProfileResponse( + user.getId(), + user.getName(), + user.getEmail(), + user.getProfileImageUrl(), + user.getLanguage().name(), + user.getZoneId(), + user.isCalendarConnected(), + user.getCalendarEmail() + ); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/user/entity/User.java b/src/main/java/com/Timo/Timo/domain/user/entity/User.java index cc34d31b..f46dbc14 100644 --- a/src/main/java/com/Timo/Timo/domain/user/entity/User.java +++ b/src/main/java/com/Timo/Timo/domain/user/entity/User.java @@ -1,5 +1,6 @@ package com.Timo.Timo.domain.user.entity; +import com.Timo.Timo.domain.user.enums.Language; import com.Timo.Timo.domain.user.enums.Provider; import com.Timo.Timo.global.common.BaseTimeEntity; import jakarta.persistence.Column; @@ -29,26 +30,31 @@ public class User extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") private Long id; @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 20) + @Column(name = "provider", nullable = false, length = 20) private Provider provider; @Column(name = "provider_id", nullable = false) private String providerId; - @Column(nullable = false, length = 50) + @Column(name = "name", nullable = false, length = 50) private String name; - @Column(nullable = false, unique = true) + @Column(name = "email", nullable = false, unique = true) private String email; @Column(name = "profile_image_url", length = 500) private String profileImageUrl; - @Column(nullable = false, length = 5) - private String language; + @Enumerated(EnumType.STRING) + @Column(name = "language", nullable = false, length = 2) + private Language language; + + @Column(name = "zone_id", nullable = false, length = 64) + private String zoneId; @Column(name = "wake_up_time", nullable = false) private LocalTime wakeUpTime; @@ -68,6 +74,32 @@ public class User extends BaseTimeEntity { @Column(name = "calendar_email") private String calendarEmail; + public void update(String name, String profileImageUrl) { + this.name = name; + this.profileImageUrl = profileImageUrl; + } + + public void completeOnboarding( + Language language, + Long predictionAccuracy, + LocalTime wakeUpTime, + LocalTime bedTime + ) { + this.language = language; + this.predictionAccuracy = predictionAccuracy; + this.wakeUpTime = wakeUpTime; + this.bedTime = bedTime; + this.onboardingCompleted = true; + } + + public void updateLanguage(Language language) { + this.language = language; + } + + public void updateZoneId(String zoneId) { + this.zoneId = zoneId; + } + @Builder private User( Provider provider, @@ -82,7 +114,8 @@ private User( this.email = email; this.profileImageUrl = profileImageUrl; - this.language = "ko"; + this.language = Language.KO; + this.zoneId = Language.KO.getDefaultZoneId(); this.wakeUpTime = LocalTime.of(7, 0); this.bedTime = LocalTime.of(23, 0); this.predictionAccuracy = 0L; @@ -91,13 +124,4 @@ private User( this.calendarConnected = false; this.calendarEmail = null; } - - public void update(String name, String profileImageUrl) { - this.name = name; - this.profileImageUrl = profileImageUrl; - } - - public void completeOnboarding() { - this.onboardingCompleted = true; - } } diff --git a/src/main/java/com/Timo/Timo/domain/user/enums/Language.java b/src/main/java/com/Timo/Timo/domain/user/enums/Language.java new file mode 100644 index 00000000..9327283d --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/enums/Language.java @@ -0,0 +1,17 @@ +package com.Timo.Timo.domain.user.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Language { + KO("Asia/Seoul"), + EN("UTC"); + + /** + * 클라이언트가 실제 시간대를 보내주기 전까지 사용할 언어별 기본 시간대(IANA tz). + * 언어와 시간대는 1:1이 아니므로 어디까지나 "초기 기본값"으로만 사용한다. + */ + private final String defaultZoneId; +} diff --git a/src/main/java/com/Timo/Timo/domain/user/exception/UserErrorCode.java b/src/main/java/com/Timo/Timo/domain/user/exception/UserErrorCode.java index 5ddbf59f..3a73c0e6 100644 --- a/src/main/java/com/Timo/Timo/domain/user/exception/UserErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/user/exception/UserErrorCode.java @@ -9,6 +9,8 @@ @RequiredArgsConstructor public enum UserErrorCode implements BaseErrorCode { + USER_400_INVALID_TIME(HttpStatus.BAD_REQUEST, "USER_400", "취침 시간은 기상 시간보다 이후여야 합니다."), + INVALID_TIMEZONE(HttpStatus.BAD_REQUEST, "USER_400", "유효하지 않은 시간대 ID입니다."), USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404", "존재하지 않는 사용자입니다."); private final HttpStatus httpStatus; diff --git a/src/main/java/com/Timo/Timo/domain/user/exception/UserSuccessCode.java b/src/main/java/com/Timo/Timo/domain/user/exception/UserSuccessCode.java new file mode 100644 index 00000000..3f2f2df3 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/exception/UserSuccessCode.java @@ -0,0 +1,21 @@ +package com.Timo.Timo.domain.user.exception; + +import org.springframework.http.HttpStatus; + +import com.Timo.Timo.global.exception.code.BaseSuccessCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum UserSuccessCode implements BaseSuccessCode { + + PROFILE_RETRIEVED(HttpStatus.OK, "프로필을 조회했습니다."), + LANGUAGE_UPDATED(HttpStatus.OK, "언어설정이 수정되었습니다."), + TIMEZONE_UPDATED(HttpStatus.OK, "시간대설정이 수정되었습니다."), + ONBOARDING_COMPLETED(HttpStatus.OK, "온보딩이 완료되었습니다."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/Timo/Timo/domain/user/factory/OnboardingResponseFactory.java b/src/main/java/com/Timo/Timo/domain/user/factory/OnboardingResponseFactory.java new file mode 100644 index 00000000..dd0d2cdb --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/factory/OnboardingResponseFactory.java @@ -0,0 +1,17 @@ +package com.Timo.Timo.domain.user.factory; + +import com.Timo.Timo.domain.user.dto.response.OnboardingResponse; +import com.Timo.Timo.domain.user.exception.UserSuccessCode; +import com.Timo.Timo.global.response.BaseResponse; +import org.springframework.http.ResponseEntity; + +public class OnboardingResponseFactory { + + public static ResponseEntity> completed( + OnboardingResponse response + ){ + return ResponseEntity.ok( + BaseResponse.onSuccess(UserSuccessCode.ONBOARDING_COMPLETED, response) + ); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/user/repository/UserRepository.java b/src/main/java/com/Timo/Timo/domain/user/repository/UserRepository.java index 966eb51b..f474a9f8 100644 --- a/src/main/java/com/Timo/Timo/domain/user/repository/UserRepository.java +++ b/src/main/java/com/Timo/Timo/domain/user/repository/UserRepository.java @@ -2,9 +2,23 @@ import com.Timo.Timo.domain.user.entity.User; import com.Timo.Timo.domain.user.enums.Provider; +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.QueryHints; +import org.springframework.data.repository.query.Param; public interface UserRepository extends JpaRepository { Optional findByProviderAndProviderId(Provider provider, String providerId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints({ + @QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"), + @QueryHint(name = "jakarta.persistence.lock.scope", value = "NORMAL") + }) + @Query("select u from User u where u.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); } diff --git a/src/main/java/com/Timo/Timo/domain/user/service/OnboardingService.java b/src/main/java/com/Timo/Timo/domain/user/service/OnboardingService.java new file mode 100644 index 00000000..2ed7f29a --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/service/OnboardingService.java @@ -0,0 +1,44 @@ +package com.Timo.Timo.domain.user.service; + +import com.Timo.Timo.domain.user.dto.request.OnboardingRequest; +import com.Timo.Timo.domain.user.dto.response.OnboardingResponse; +import com.Timo.Timo.domain.user.entity.User; +import com.Timo.Timo.domain.user.exception.UserErrorCode; +import com.Timo.Timo.domain.user.repository.UserRepository; +import com.Timo.Timo.global.exception.CustomException; +import java.time.LocalTime; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class OnboardingService { + + private final UserRepository userRepository; + + @Transactional + public OnboardingResponse completeOnboarding(Long userId, OnboardingRequest request){ + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + LocalTime wakeUpTime = LocalTime.parse(request.wakeUpTime()); + LocalTime bedTime = LocalTime.parse(request.bedTime()); + + if (!bedTime.isAfter(wakeUpTime)) { + throw new CustomException(UserErrorCode.USER_400_INVALID_TIME); + } + + user.completeOnboarding( + request.language(), + request.predictionAccuracy(), + wakeUpTime, + bedTime + ); + + return OnboardingResponse.builder() + .onboardingCompleted(true) + .build(); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/user/service/UserService.java b/src/main/java/com/Timo/Timo/domain/user/service/UserService.java new file mode 100644 index 00000000..ef3e3c05 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/service/UserService.java @@ -0,0 +1,63 @@ +package com.Timo.Timo.domain.user.service; + +import java.time.DateTimeException; +import java.time.ZoneId; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.Timo.Timo.domain.user.dto.request.UpdateLanguageRequest; +import com.Timo.Timo.domain.user.dto.request.UpdateTimezoneRequest; +import com.Timo.Timo.domain.user.dto.response.UserProfileResponse; +import com.Timo.Timo.domain.user.dto.response.UpdateLanguageResponse; +import com.Timo.Timo.domain.user.dto.response.UpdateTimezoneResponse; +import com.Timo.Timo.domain.user.entity.User; +import com.Timo.Timo.domain.user.exception.UserErrorCode; +import com.Timo.Timo.domain.user.repository.UserRepository; +import com.Timo.Timo.global.exception.CustomException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { + + private final UserRepository userRepository; + + public UserProfileResponse getMyProfile(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + return UserProfileResponse.from(user); + } + + @Transactional + public UpdateLanguageResponse updateLanguage(Long userId, UpdateLanguageRequest request) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + user.updateLanguage(request.language()); + + return new UpdateLanguageResponse(user.getLanguage()); + } + + @Transactional + public UpdateTimezoneResponse updateTimezone(Long userId, UpdateTimezoneRequest request) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + String zoneId = normalizeZoneId(request.zoneId()); + user.updateZoneId(zoneId); + + return new UpdateTimezoneResponse(user.getZoneId()); + } + + private String normalizeZoneId(String zoneId) { + try { + return ZoneId.of(zoneId).getId(); + } catch (DateTimeException exception) { + throw new CustomException(UserErrorCode.INVALID_TIMEZONE); + } + } +} diff --git a/src/main/java/com/Timo/Timo/global/auth/controller/AuthController.java b/src/main/java/com/Timo/Timo/global/auth/controller/AuthController.java index c7622fb6..3578b7b7 100644 --- a/src/main/java/com/Timo/Timo/global/auth/controller/AuthController.java +++ b/src/main/java/com/Timo/Timo/global/auth/controller/AuthController.java @@ -1,16 +1,23 @@ package com.Timo.Timo.global.auth.controller; +import com.Timo.Timo.global.auth.docs.AuthControllerDocs; +import com.Timo.Timo.global.auth.dto.ReissueResult; +import com.Timo.Timo.global.auth.dto.request.AuthTokenRequest; +import com.Timo.Timo.global.auth.dto.response.AuthReissueResponse; import com.Timo.Timo.global.auth.dto.response.AuthTokenResponse; -import com.Timo.Timo.global.auth.exception.AuthSuccessCode; +import com.Timo.Timo.global.auth.factory.AuthResponseFactory; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; import com.Timo.Timo.global.auth.service.AuthService; +import com.Timo.Timo.global.auth.utils.TokenExtractor; import com.Timo.Timo.global.response.BaseResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; -import java.util.Map; +import jakarta.servlet.http.HttpServletRequest; +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.CookieValue; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -20,27 +27,53 @@ @RestController @RequestMapping("/api/v1/auth") @RequiredArgsConstructor -public class AuthController { +public class AuthController implements AuthControllerDocs { private final AuthService authService; + private final AuthResponseFactory authResponseFactory; - @Operation(summary = "AccessToken 발급", description = "1회성 code로 AccessToken을 발급합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "로그인 성공"), - @ApiResponse(responseCode = "400", description = "code 누락"), - @ApiResponse(responseCode = "401", description = "유효하지 않거나 만료된 인증 코드"), - @ApiResponse(responseCode = "404", description = "존재하지 않는 사용자"), - @ApiResponse(responseCode = "500", description = "서버 내부 오류") - }) + @Override @PostMapping("/token") public ResponseEntity> token( - @RequestBody Map body + @Valid @RequestBody AuthTokenRequest request ) { - String code = body.get("code"); - AuthTokenResponse authTokenResponse = authService.exchangeCodeForToken(code); + AuthTokenResponse authTokenResponse = authService.exchangeCodeForToken(request.code()); + return authResponseFactory.tokenResponse(authTokenResponse); + } + + @Override + @PostMapping("/reissue") + public ResponseEntity> reissue( + @CookieValue(name = "refreshToken", required=false) String refreshToken, + @CookieValue(name = "sessionId", required = false) String sessionId + ) { + ReissueResult result = authService.reissue(refreshToken, sessionId); + return authResponseFactory.reissueResponse(result); + } + + @Override + @PostMapping("/logout") + public ResponseEntity> logout( + @AuthenticationPrincipal CustomUserDetails userDetails, + @CookieValue(name = "sessionId", required = false) String sessionId, + HttpServletRequest request + ) { + String accessToken = TokenExtractor.resolveToken(request); + authService.logout(accessToken, userDetails.getUserId(), sessionId); + + return authResponseFactory.logoutResponse(); + } + + @Override + @DeleteMapping("/withdraw") + public ResponseEntity> withdraw( + @AuthenticationPrincipal CustomUserDetails userDetails, + @CookieValue(name = "sessionId", required = false) String sessionId, + HttpServletRequest request + ) { + String accessToken = TokenExtractor.resolveToken(request); + authService.withdraw(accessToken, userDetails.getUserId(), sessionId); - return ResponseEntity.ok() - .header("Cache-Control", "no-store") - .body(BaseResponse.onSuccess(AuthSuccessCode.LOGIN_SUCCESS, authTokenResponse)); + return authResponseFactory.withdrawResponse(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java b/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java new file mode 100644 index 00000000..d8250639 --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java @@ -0,0 +1,213 @@ +package com.Timo.Timo.global.auth.docs; + +import com.Timo.Timo.global.auth.dto.request.AuthTokenRequest; +import com.Timo.Timo.global.auth.dto.response.AuthReissueResponse; +import com.Timo.Timo.global.auth.dto.response.AuthTokenResponse; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.exception.dto.ErrorDto; +import com.Timo.Timo.global.response.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirements; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.RequestBody; + +public interface AuthControllerDocs { + + @Operation( + summary = "AccessToken 발급", + description = """ + 1회성 인증 코드(code)를 AccessToken으로 교환합니다. + """ + ) + @SecurityRequirements + @io.swagger.v3.oas.annotations.parameters.RequestBody( + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = AuthTokenRequest.class), + examples = @ExampleObject( + name = "로그인 요청", + value = + """ + { + "code": "550e8400-e29b-41d4-a716-446655440000" + } + """ + ) + ) + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "로그인 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = "요청 바디에 code가 누락된 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "유효하지 않거나 만료된 인증 코드인 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "인증 코드에 해당하는 사용자를 찾을 수 없는 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> token( + @RequestBody AuthTokenRequest body + ); + + @Operation( + summary = "AccessToken 재발급", + description = """ + 쿠키로 전달된 RefreshToken과 sessionId를 검증하여 AccessToken 재발급합니다.
+ 재발급 성공 시 RefreshToken과 sessionId 쿠키를 갱신합니다. + """ + + ) + @SecurityRequirements + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "재발급 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "401", + description = "RefreshToken이 없거나 유효하지 않거나 만료된 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "RefreshToken에 해당하는 사용자를 찾을 수 없는 경우 (탈퇴 등으로 삭제된 사용자)", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> reissue( + @CookieValue(name = "refreshToken", required = false) String refreshToken, + @CookieValue(name = "sessionId", required = false) String sessionId + ); + + @Operation( + summary = "로그아웃", + description = """ + 현재 세션을 로그아웃하고 RefreshToken 및 sessionId 쿠키를 만료시킵니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "로그아웃 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> logout( + @Parameter(hidden = true) CustomUserDetails userDetails, + @CookieValue(name = "sessionId", required = false) String sessionId, + @Parameter(hidden = true) HttpServletRequest request + ); + + @Operation( + summary = "회원 탈퇴", + description = """ + 회원 탈퇴를 진행하며, 사용자와 관련된 모든 데이터를 영구 삭제합니다.
+ 이 작업은 되돌릴 수 없습니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "회원 탈퇴 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "Access Token의 사용자 ID에 해당하는 사용자를 찾을 수 없는 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + + ResponseEntity> withdraw( + @Parameter(hidden = true) CustomUserDetails userDetails, + @CookieValue(name = "sessionId", required = false) String sessionId, + @Parameter(hidden = true) HttpServletRequest request + ); +} diff --git a/src/main/java/com/Timo/Timo/global/auth/dto/ReissueResult.java b/src/main/java/com/Timo/Timo/global/auth/dto/ReissueResult.java new file mode 100644 index 00000000..48be6968 --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/dto/ReissueResult.java @@ -0,0 +1,12 @@ +package com.Timo.Timo.global.auth.dto; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class ReissueResult { + private final String accessToken; + private final String refreshToken; + private final String sessionId; +} diff --git a/src/main/java/com/Timo/Timo/global/auth/dto/request/AuthTokenRequest.java b/src/main/java/com/Timo/Timo/global/auth/dto/request/AuthTokenRequest.java new file mode 100644 index 00000000..1aafaa47 --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/dto/request/AuthTokenRequest.java @@ -0,0 +1,12 @@ +package com.Timo.Timo.global.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record AuthTokenRequest( + @Schema(description = "로그인 성공 리다이렉트 시 쿼리스트링으로 전달받은 일회용 인가코드", + example = "550e8400-e29b-41d4-a716-446655440000") + @NotBlank + String code +) { +} diff --git a/src/main/java/com/Timo/Timo/global/auth/dto/response/AuthReissueResponse.java b/src/main/java/com/Timo/Timo/global/auth/dto/response/AuthReissueResponse.java new file mode 100644 index 00000000..e7708bcd --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/dto/response/AuthReissueResponse.java @@ -0,0 +1,13 @@ +package com.Timo.Timo.global.auth.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class AuthReissueResponse { + + @Schema(description = "새로 발급된 AccessToken", example = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.def456signature") + private final String accessToken; +} diff --git a/src/main/java/com/Timo/Timo/global/auth/dto/response/AuthTokenResponse.java b/src/main/java/com/Timo/Timo/global/auth/dto/response/AuthTokenResponse.java index f9e17e42..3fb1d8aa 100644 --- a/src/main/java/com/Timo/Timo/global/auth/dto/response/AuthTokenResponse.java +++ b/src/main/java/com/Timo/Timo/global/auth/dto/response/AuthTokenResponse.java @@ -24,5 +24,4 @@ public static class UserInfo { private final String profileImageUrl; private final boolean onboardingCompleted; } - } diff --git a/src/main/java/com/Timo/Timo/global/auth/exception/AuthErrorCode.java b/src/main/java/com/Timo/Timo/global/auth/exception/AuthErrorCode.java index d8e0bc04..b20768b1 100644 --- a/src/main/java/com/Timo/Timo/global/auth/exception/AuthErrorCode.java +++ b/src/main/java/com/Timo/Timo/global/auth/exception/AuthErrorCode.java @@ -10,9 +10,9 @@ public enum AuthErrorCode implements BaseErrorCode { OAUTH2_INVALID_USER_INFO(HttpStatus.UNAUTHORIZED, "AUTH_401", "OAuth2 유저 정보가 유효하지 않습니다."), - OAUTH_LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "AUTH_402", "소셜 로그인에 실패했습니다."), - INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_403", "유효하지 않거나 만료된 리프레시 토큰입니다."), - INVALID_AUTH_CODE(HttpStatus.UNAUTHORIZED, "AUTH_404", "유효하지 않거나 만료된 인증 코드입니다."); + OAUTH_LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "AUTH_401", "소셜 로그인에 실패했습니다."), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_401", "유효하지 않거나 만료된 리프레시 토큰입니다."), + INVALID_AUTH_CODE(HttpStatus.UNAUTHORIZED, "AUTH_401", "유효하지 않거나 만료된 인증 코드입니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/Timo/Timo/global/auth/exception/AuthSuccessCode.java b/src/main/java/com/Timo/Timo/global/auth/exception/AuthSuccessCode.java index 773ddcff..d8cc80bb 100644 --- a/src/main/java/com/Timo/Timo/global/auth/exception/AuthSuccessCode.java +++ b/src/main/java/com/Timo/Timo/global/auth/exception/AuthSuccessCode.java @@ -10,9 +10,9 @@ public enum AuthSuccessCode implements BaseSuccessCode { LOGIN_SUCCESS(HttpStatus.OK, "AUTH_200", "로그인에 성공했습니다."), - REISSUE_SUCCESS(HttpStatus.OK, "AUTH_201", "토큰 재발급에 성공했습니다."), - LOGOUT_SUCCESS(HttpStatus.OK, "AUTH_202", "로그아웃에 성공했습니다."), - WITHDRAW_SUCCESS(HttpStatus.OK, "AUTH_203", "회원 탈퇴에 성공했습니다."); + REISSUE_SUCCESS(HttpStatus.OK, "AUTH_200", "토큰 재발급에 성공했습니다."), + LOGOUT_SUCCESS(HttpStatus.OK, "AUTH_200", "로그아웃에 성공했습니다."), + WITHDRAW_SUCCESS(HttpStatus.OK, "AUTH_200", "회원 탈퇴에 성공했습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/Timo/Timo/global/auth/factory/AuthResponseFactory.java b/src/main/java/com/Timo/Timo/global/auth/factory/AuthResponseFactory.java new file mode 100644 index 00000000..c470653b --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/factory/AuthResponseFactory.java @@ -0,0 +1,69 @@ +package com.Timo.Timo.global.auth.factory; + +import com.Timo.Timo.global.auth.dto.ReissueResult; +import com.Timo.Timo.global.auth.dto.response.AuthReissueResponse; +import com.Timo.Timo.global.auth.dto.response.AuthTokenResponse; +import com.Timo.Timo.global.auth.exception.AuthSuccessCode; +import com.Timo.Timo.global.auth.utils.CookieUtil; +import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; +import com.Timo.Timo.global.response.BaseResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AuthResponseFactory { + + private final JwtTokenProvider jwtTokenProvider; + + @Value("${app.auth.cookie-secure}") + private boolean cookieSecure; + + public ResponseEntity> tokenResponse(AuthTokenResponse authTokenResponse) { + return ResponseEntity.ok() + .header("Cache-Control", "no-store") + .body(BaseResponse.onSuccess(AuthSuccessCode.LOGIN_SUCCESS, authTokenResponse)); + } + + public ResponseEntity> reissueResponse(ReissueResult result) { + AuthReissueResponse body = AuthReissueResponse.builder() + .accessToken(result.getAccessToken()) + .build(); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, refreshTokenCookie(result.getRefreshToken())) + .header(HttpHeaders.SET_COOKIE, sessionIdCookie(result.getSessionId())) + .header("Cache-Control", "no-store") + .body(BaseResponse.onSuccess(AuthSuccessCode.REISSUE_SUCCESS, body)); + } + + public ResponseEntity> logoutResponse() { + return expiredCookieResponse(AuthSuccessCode.LOGOUT_SUCCESS); + } + + public ResponseEntity> withdrawResponse() { + return expiredCookieResponse(AuthSuccessCode.WITHDRAW_SUCCESS); + } + + private ResponseEntity> expiredCookieResponse(AuthSuccessCode successCode) { + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, CookieUtil.expireCookie("refreshToken", cookieSecure).toString()) + .header(HttpHeaders.SET_COOKIE, CookieUtil.expireCookie("sessionId", cookieSecure).toString()) + .header(HttpHeaders.CACHE_CONTROL, "no-store") + .body(BaseResponse.onSuccess(successCode, null)); + } + + private String refreshTokenCookie(String refreshToken) { + return CookieUtil.createCookie("refreshToken", refreshToken, + jwtTokenProvider.getRefreshTokenExpiry(), cookieSecure).toString(); + } + + private String sessionIdCookie(String sessionId) { + return CookieUtil.createCookie("sessionId", sessionId, + jwtTokenProvider.getRefreshTokenExpiry(), cookieSecure).toString(); + } +} + diff --git a/src/main/java/com/Timo/Timo/global/auth/handler/AuthErrorResponseWriter.java b/src/main/java/com/Timo/Timo/global/auth/handler/AuthErrorResponseWriter.java index 9d5a4313..4ee43475 100644 --- a/src/main/java/com/Timo/Timo/global/auth/handler/AuthErrorResponseWriter.java +++ b/src/main/java/com/Timo/Timo/global/auth/handler/AuthErrorResponseWriter.java @@ -32,5 +32,4 @@ public void write(HttpServletResponse response, BaseErrorCode errorCode, String response.setCharacterEncoding("UTF-8"); response.getWriter().write(objectMapper.writeValueAsString(errorDto)); } - } diff --git a/src/main/java/com/Timo/Timo/global/auth/handler/JwtAuthenticationEntryPoint.java b/src/main/java/com/Timo/Timo/global/auth/handler/JwtAuthenticationEntryPoint.java index 8a9f2ce8..a8a50ffb 100644 --- a/src/main/java/com/Timo/Timo/global/auth/handler/JwtAuthenticationEntryPoint.java +++ b/src/main/java/com/Timo/Timo/global/auth/handler/JwtAuthenticationEntryPoint.java @@ -23,4 +23,4 @@ public void commence( ) throws IOException { authErrorResponseWriter.write(response, ErrorCode.UNAUTHORIZED, request.getRequestURI()); } -} \ No newline at end of file +} diff --git a/src/main/java/com/Timo/Timo/global/auth/handler/OAuthSuccessHandler.java b/src/main/java/com/Timo/Timo/global/auth/handler/OAuthSuccessHandler.java index 39ab62f2..6d1344af 100644 --- a/src/main/java/com/Timo/Timo/global/auth/handler/OAuthSuccessHandler.java +++ b/src/main/java/com/Timo/Timo/global/auth/handler/OAuthSuccessHandler.java @@ -3,15 +3,14 @@ import com.Timo.Timo.global.auth.principal.CustomUserDetails; import com.Timo.Timo.global.auth.service.AuthCodeService; import com.Timo.Timo.global.auth.service.RefreshTokenService; +import com.Timo.Timo.global.auth.utils.CookieUtil; import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; -import java.time.Duration; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; -import org.springframework.http.ResponseCookie; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; import org.springframework.stereotype.Component; @@ -39,29 +38,18 @@ public void onAuthenticationSuccess( ) throws IOException { CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal(); - Long userId = userDetails.getUser().getId(); + Long userId = userDetails.getUserId(); boolean onboardingCompleted = userDetails.getUser().isOnboardingCompleted(); String refreshToken = jwtTokenProvider.generateRefreshToken(userId); String sessionId = refreshTokenService.saveRefreshToken(String.valueOf(userId), refreshToken); - ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", refreshToken) - .httpOnly(true) - .secure(cookieSecure) - .path("/api/v1/auth") - .maxAge(Duration.ofSeconds(jwtTokenProvider.getRefreshTokenExpiry())) - .sameSite("Strict") - .build(); - response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString()); - - ResponseCookie sessionCookie = ResponseCookie.from("sessionId", sessionId) - .httpOnly(true) - .secure(cookieSecure) - .path("/api/v1/auth") - .maxAge(Duration.ofSeconds(jwtTokenProvider.getRefreshTokenExpiry())) - .sameSite("Strict") - .build(); - response.addHeader(HttpHeaders.SET_COOKIE, sessionCookie.toString()); + response.addHeader(HttpHeaders.SET_COOKIE, + CookieUtil.createCookie("refreshToken", refreshToken, + jwtTokenProvider.getRefreshTokenExpiry(), cookieSecure).toString()); + response.addHeader(HttpHeaders.SET_COOKIE, + CookieUtil.createCookie("sessionId", sessionId, + jwtTokenProvider.getRefreshTokenExpiry(), cookieSecure).toString()); String code = authCodeService.generateAndSave( String.valueOf(userId), diff --git a/src/main/java/com/Timo/Timo/global/auth/principal/CustomUserDetails.java b/src/main/java/com/Timo/Timo/global/auth/principal/CustomUserDetails.java index a4799d30..6c3cb6b5 100644 --- a/src/main/java/com/Timo/Timo/global/auth/principal/CustomUserDetails.java +++ b/src/main/java/com/Timo/Timo/global/auth/principal/CustomUserDetails.java @@ -31,6 +31,10 @@ public String getName() { return user.getEmail(); } + public Long getUserId() { + return user.getId(); + } + @Override public Collection getAuthorities() { return List.of(new SimpleGrantedAuthority("ROLE_USER")); @@ -45,5 +49,4 @@ public String getPassword(){ public String getUsername(){ return user.getEmail(); } - } diff --git a/src/main/java/com/Timo/Timo/global/auth/service/AuthService.java b/src/main/java/com/Timo/Timo/global/auth/service/AuthService.java index d6279ce2..d4e5720e 100644 --- a/src/main/java/com/Timo/Timo/global/auth/service/AuthService.java +++ b/src/main/java/com/Timo/Timo/global/auth/service/AuthService.java @@ -3,6 +3,7 @@ import com.Timo.Timo.domain.user.entity.User; import com.Timo.Timo.domain.user.exception.UserErrorCode; import com.Timo.Timo.domain.user.repository.UserRepository; +import com.Timo.Timo.global.auth.dto.ReissueResult; import com.Timo.Timo.global.auth.dto.response.AuthTokenResponse; import com.Timo.Timo.global.auth.exception.AuthErrorCode; import com.Timo.Timo.global.exception.CustomException; @@ -10,6 +11,9 @@ import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; @Service @RequiredArgsConstructor @@ -18,8 +22,11 @@ public class AuthService { private final AuthCodeService authCodeService; private final JwtTokenProvider jwtTokenProvider; private final UserRepository userRepository; + private final RefreshTokenService refreshTokenService; + private final BlackListService blacklistService; public AuthTokenResponse exchangeCodeForToken(String code) { + if (code == null) { throw new CustomException(ErrorCode.BAD_REQUEST); } @@ -52,4 +59,62 @@ public AuthTokenResponse exchangeCodeForToken(String code) { ) .build(); } + + public ReissueResult reissue(String refreshToken, String sessionId) { + + if (refreshToken == null || sessionId == null + || !jwtTokenProvider.validateRefreshToken(refreshToken)) { + throw new CustomException(AuthErrorCode.INVALID_REFRESH_TOKEN); + } + + Long userId = jwtTokenProvider.getUserId(refreshToken); + + if (!userRepository.existsById(userId)) { + throw new CustomException(UserErrorCode.USER_NOT_FOUND); + } + + if (!refreshTokenService.isRefreshTokenValid(String.valueOf(userId), sessionId, refreshToken)){ + throw new CustomException(AuthErrorCode.INVALID_REFRESH_TOKEN); + } + + refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); + + String newAccessToken = jwtTokenProvider.generateAccessToken(userId); + String newRefreshToken = jwtTokenProvider.generateRefreshToken(userId); + String newSessionId = refreshTokenService.saveRefreshToken(String.valueOf(userId), newRefreshToken); + + return new ReissueResult(newAccessToken, newRefreshToken, newSessionId); + } + + public void logout(String accessToken, Long userId, String sessionId) { + if (accessToken != null) { + long remainingExpiry = jwtTokenProvider.getRemainingExpiry(accessToken); + blacklistService.addToBlacklist(accessToken, remainingExpiry); + } + + if (sessionId != null) { + refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); + } + } + + @Transactional + public void withdraw(String accessToken, Long userId, String sessionId) { + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + userRepository.delete(user); + + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit(){ + if (accessToken != null) { + long remainingExpiry = jwtTokenProvider.getRemainingExpiry(accessToken); + blacklistService.addToBlacklist(accessToken, remainingExpiry); + } + } + }); + + refreshTokenService.deleteAllRefreshTokens(String.valueOf(userId)); + } } diff --git a/src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java b/src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java new file mode 100644 index 00000000..8e42a136 --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java @@ -0,0 +1,33 @@ +package com.Timo.Timo.global.auth.service; + +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class BlackListService { + + private final RedisTemplate redisTemplate; + + private static final String KEY_PREFIX = "blacklist:"; + + public void addToBlacklist(String accessToken, long remainingExpiry){ + + if (remainingExpiry <= 0) { + return; + } + + redisTemplate.opsForValue().set( + KEY_PREFIX + accessToken, + "logout", + remainingExpiry, + TimeUnit.SECONDS + ); + } + + public boolean isBlackListed(String accessToken){ + return Boolean.TRUE.equals(redisTemplate.hasKey(KEY_PREFIX + accessToken)); + } +} diff --git a/src/main/java/com/Timo/Timo/global/auth/service/RefreshTokenService.java b/src/main/java/com/Timo/Timo/global/auth/service/RefreshTokenService.java index b1fa2b07..ef704d23 100644 --- a/src/main/java/com/Timo/Timo/global/auth/service/RefreshTokenService.java +++ b/src/main/java/com/Timo/Timo/global/auth/service/RefreshTokenService.java @@ -1,11 +1,16 @@ package com.Timo.Timo.global.auth.service; import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; +import java.util.Set; import java.util.UUID; import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.Cursor; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ScanOptions; import org.springframework.stereotype.Service; @Service @@ -36,6 +41,25 @@ public void deleteRefreshToken(String userId, String sessionId) { redisTemplate.delete(KEY_PREFIX + userId + ":" + sessionId); } + public void deleteAllRefreshTokens(String userId) { + String pattern = KEY_PREFIX + userId + ":*"; + ScanOptions options = ScanOptions.scanOptions().match(pattern).count(100).build(); + List batch = new ArrayList<>(); + + try (Cursor cursor = redisTemplate.scan(options)) { + while (cursor.hasNext()) { + batch.add(cursor.next()); + if (batch.size() >= 100) { + redisTemplate.delete(batch); + batch.clear(); + } + } + if (!batch.isEmpty()) { + redisTemplate.delete(batch); + } + } + } + public boolean isRefreshTokenValid(String userId, String sessionId, String refreshToken) { return Objects.equals(refreshToken, getRefreshToken(userId, sessionId)); } diff --git a/src/main/java/com/Timo/Timo/global/auth/utils/CookieUtil.java b/src/main/java/com/Timo/Timo/global/auth/utils/CookieUtil.java new file mode 100644 index 00000000..8f31f74a --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/utils/CookieUtil.java @@ -0,0 +1,27 @@ +package com.Timo.Timo.global.auth.utils; + +import java.time.Duration; +import org.springframework.http.ResponseCookie; + +public class CookieUtil { + + public static ResponseCookie createCookie(String name, String value, long maxAgeSeconds, boolean secure) { + return ResponseCookie.from(name, value) + .httpOnly(true) + .secure(secure) + .path("/api/v1/auth") + .maxAge(Duration.ofSeconds(maxAgeSeconds)) + .sameSite("Strict") + .build(); + } + + public static ResponseCookie expireCookie(String name, boolean secure) { + return ResponseCookie.from(name, "") + .httpOnly(true) + .secure(secure) + .path("/api/v1/auth") + .maxAge(0) + .sameSite("Strict") + .build(); + } +} diff --git a/src/main/java/com/Timo/Timo/global/auth/utils/TokenExtractor.java b/src/main/java/com/Timo/Timo/global/auth/utils/TokenExtractor.java new file mode 100644 index 00000000..58c77e88 --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/utils/TokenExtractor.java @@ -0,0 +1,15 @@ +package com.Timo.Timo.global.auth.utils; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.util.StringUtils; + +public class TokenExtractor { + + public static String resolveToken(HttpServletRequest request) { + String bearer = request.getHeader("Authorization"); + if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) { + return bearer.substring(7); + } + return null; + } +} diff --git a/src/main/java/com/Timo/Timo/global/common/BaseTimeEntity.java b/src/main/java/com/Timo/Timo/global/common/BaseTimeEntity.java index 05ad70e9..8553f2af 100644 --- a/src/main/java/com/Timo/Timo/global/common/BaseTimeEntity.java +++ b/src/main/java/com/Timo/Timo/global/common/BaseTimeEntity.java @@ -1,5 +1,6 @@ package com.Timo.Timo.global.common; +import com.fasterxml.jackson.annotation.JsonFormat; import jakarta.persistence.Column; import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; @@ -16,9 +17,11 @@ public abstract class BaseTimeEntity { @CreatedDate @Column(name = "created_at", nullable = false, updatable = false) + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") private LocalDateTime createdAt; @LastModifiedDate @Column(name = "updated_at", nullable = false) + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") private LocalDateTime updatedAt; } diff --git a/src/main/java/com/Timo/Timo/global/config/SecurityConfig.java b/src/main/java/com/Timo/Timo/global/config/SecurityConfig.java index 400b8a07..d5a732f8 100644 --- a/src/main/java/com/Timo/Timo/global/config/SecurityConfig.java +++ b/src/main/java/com/Timo/Timo/global/config/SecurityConfig.java @@ -48,7 +48,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/login/**", "/oauth2/**", "/api/v1/auth/reissue", - "/api/v1/auth/token" + "/api/v1/auth/token", + "/api/v1/terms" ).permitAll() .anyRequest().authenticated()) .oauth2Login(oauth2 -> oauth2 diff --git a/src/main/java/com/Timo/Timo/global/config/SwaggerConfig.java b/src/main/java/com/Timo/Timo/global/config/SwaggerConfig.java index 2a526229..28ac02d8 100644 --- a/src/main/java/com/Timo/Timo/global/config/SwaggerConfig.java +++ b/src/main/java/com/Timo/Timo/global/config/SwaggerConfig.java @@ -1,18 +1,34 @@ package com.Timo.Timo.global.config; +import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class SwaggerConfig { + + private static final String BEARER_AUTH = "bearerAuth"; + @Bean public OpenAPI customOpenAPI() { return new OpenAPI() - .info(new Info() - .title("Timo API") - .description("Timo 서버 API 명세서") - .version("v1")); + .components(new Components() + .addSecuritySchemes( + BEARER_AUTH, + new SecurityScheme() + .name(BEARER_AUTH) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + )) + .addSecurityItem(new SecurityRequirement().addList(BEARER_AUTH)) + .info(new Info() + .title("Timo API") + .description("Timo 서버 API 명세서") + .version("v1")); } } diff --git a/src/main/java/com/Timo/Timo/global/exception/GlobalExceptionHandler.java b/src/main/java/com/Timo/Timo/global/exception/GlobalExceptionHandler.java index bc3f9e28..bdf73a2c 100644 --- a/src/main/java/com/Timo/Timo/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/Timo/Timo/global/exception/GlobalExceptionHandler.java @@ -2,7 +2,9 @@ import java.time.LocalDateTime; +import org.springframework.dao.PessimisticLockingFailureException; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -12,6 +14,8 @@ import com.Timo.Timo.global.exception.code.ErrorCode; import com.Timo.Timo.global.exception.dto.ErrorDto; +import jakarta.persistence.LockTimeoutException; +import jakarta.persistence.PessimisticLockException; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; @@ -35,6 +39,14 @@ public ResponseEntity handleMethodArgumentNotValidException( return createErrorResponse(ErrorCode.BAD_REQUEST, request); } + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException( + HttpMessageNotReadableException exception, + HttpServletRequest request + ) { + return createErrorResponse(ErrorCode.BAD_REQUEST, request); + } + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) public ResponseEntity handleHttpRequestMethodNotSupportedException( HttpRequestMethodNotSupportedException exception, @@ -43,6 +55,18 @@ public ResponseEntity handleHttpRequestMethodNotSupportedException( return createErrorResponse(ErrorCode.METHOD_NOT_ALLOWED, request); } + @ExceptionHandler({ + PessimisticLockException.class, + LockTimeoutException.class, + PessimisticLockingFailureException.class + }) + public ResponseEntity handlePessimisticLockException( + RuntimeException exception, + HttpServletRequest request + ) { + return createErrorResponse(ErrorCode.CONCURRENCY_CONFLICT, request); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleException( Exception exception, diff --git a/src/main/java/com/Timo/Timo/global/exception/code/ErrorCode.java b/src/main/java/com/Timo/Timo/global/exception/code/ErrorCode.java index de69d189..5ef12e6a 100644 --- a/src/main/java/com/Timo/Timo/global/exception/code/ErrorCode.java +++ b/src/main/java/com/Timo/Timo/global/exception/code/ErrorCode.java @@ -14,6 +14,7 @@ public enum ErrorCode implements BaseErrorCode { FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON_403", "접근 권한이 없습니다."), NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON_404", "요청한 리소스를 찾을 수 없습니다."), METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "COMMON_405", "지원하지 않는 HTTP 메서드입니다."), + CONCURRENCY_CONFLICT(HttpStatus.CONFLICT, "COMMON_409", "동시에 처리 중인 요청이 있습니다. 잠시 후 다시 시도해 주세요."), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_500", "서버 내부 오류가 발생했습니다."); private final HttpStatus httpStatus; diff --git a/src/main/java/com/Timo/Timo/global/exception/dto/ErrorDto.java b/src/main/java/com/Timo/Timo/global/exception/dto/ErrorDto.java index 8c92067d..e15eb53f 100644 --- a/src/main/java/com/Timo/Timo/global/exception/dto/ErrorDto.java +++ b/src/main/java/com/Timo/Timo/global/exception/dto/ErrorDto.java @@ -2,10 +2,12 @@ import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; public record ErrorDto( @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(example = "2026-07-06 17:51:50", type = "string") LocalDateTime timestamp, int status, String errorCode, diff --git a/src/main/java/com/Timo/Timo/global/jwt/filter/JwtAuthenticationFilter.java b/src/main/java/com/Timo/Timo/global/jwt/filter/JwtAuthenticationFilter.java index 229ab696..e8fb51db 100644 --- a/src/main/java/com/Timo/Timo/global/jwt/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/Timo/Timo/global/jwt/filter/JwtAuthenticationFilter.java @@ -2,6 +2,8 @@ import com.Timo.Timo.domain.user.repository.UserRepository; import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.auth.service.BlackListService; +import com.Timo.Timo.global.auth.utils.TokenExtractor; import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -14,7 +16,6 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; @Component @@ -23,6 +24,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; private final UserRepository userRepository; + private final BlackListService blacklistService; @Override protected void doFilterInternal( @@ -31,9 +33,11 @@ protected void doFilterInternal( FilterChain filterChain ) throws ServletException, IOException { - String token = resolveToken(request); + String token = TokenExtractor.resolveToken(request); - if(token!=null && jwtTokenProvider.validateAccessToken(token)){ + if(token!=null + && jwtTokenProvider.validateAccessToken(token) + && !blacklistService.isBlackListed(token)){ Long userId = jwtTokenProvider.getUserId(token); userRepository.findById(userId).ifPresent(user -> { CustomUserDetails userDetails = new CustomUserDetails(user, Map.of()); @@ -48,13 +52,4 @@ protected void doFilterInternal( filterChain.doFilter(request, response); } - - private String resolveToken(HttpServletRequest request) { - - String bearer = request.getHeader("Authorization"); - if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) { - return bearer.substring(7); - } - return null; - } } diff --git a/src/main/java/com/Timo/Timo/global/jwt/provider/JwtTokenProvider.java b/src/main/java/com/Timo/Timo/global/jwt/provider/JwtTokenProvider.java index 432dee2b..53d7aff3 100644 --- a/src/main/java/com/Timo/Timo/global/jwt/provider/JwtTokenProvider.java +++ b/src/main/java/com/Timo/Timo/global/jwt/provider/JwtTokenProvider.java @@ -82,4 +82,9 @@ public long getAccessTokenExpiry() { public long getRefreshTokenExpiry() { return refreshTokenExpirySeconds; } + + public long getRemainingExpiry(String token){ + Date expiration = getClaims(token).getExpiration(); + return (expiration.getTime() - System.currentTimeMillis()) / 1000; + } } diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 95913ca7..0f904202 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -37,3 +37,14 @@ jwt: secret: ${JWT_SECRET} access-token-expires-in-seconds: 1800 refresh-token-expires-in-seconds: 1209600 + +ai: + gemini: + api-key: ${GEMINI_API_KEY} + model: ${AI_MODEL_NAME} + rate-limit: + user-rpm: ${AI_USER_RPM} + global-rpm: ${AI_GLOBAL_RPM} + user-rpd: ${AI_USER_RPD} + global-rpd: ${AI_GLOBAL_RPD} + global-tpm: ${AI_GLOBAL_TPM} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 490c5b71..910d01fb 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -11,7 +11,7 @@ spring: jpa: hibernate: - ddl-auto: validate + ddl-auto: update properties: hibernate: dialect: org.hibernate.dialect.MySQL8Dialect @@ -47,6 +47,17 @@ jwt: access-token-expires-in-seconds: 1800 refresh-token-expires-in-seconds: 1209600 +ai: + gemini: + api-key: ${GEMINI_API_KEY} + model: ${AI_MODEL_NAME} + rate-limit: + user-rpm: ${AI_USER_RPM} + global-rpm: ${AI_GLOBAL_RPM} + user-rpd: ${AI_USER_RPD} + global-rpd: ${AI_GLOBAL_RPD} + global-tpm: ${AI_GLOBAL_TPM} + logging: level: root: INFO diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8bf5473f..278afffd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -12,8 +12,13 @@ spring: hibernate: dialect: org.hibernate.dialect.MySQL8Dialect format_sql: true + jdbc: + time_zone: UTC show-sql: true + jackson: + time-zone: UTC + server: port: 8080