From 0905a5ac6b3da9b1ac9039b9ff0078c10e2688c9 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 17:03:10 +0900 Subject: [PATCH 001/383] =?UTF-8?q?feat:=20ReissueResult=20DTO=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/dto/ReissueResult.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/global/auth/dto/ReissueResult.java 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; +} From a1f09daa50872fe506fdfce80f8c510f18d003e7 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 17:34:16 +0900 Subject: [PATCH 002/383] =?UTF-8?q?feat:=20AuthService=EC=97=90=20?= =?UTF-8?q?=EC=9E=AC=EB=B0=9C=EA=B8=89=20=EB=B9=84=EC=A6=88=EB=8B=88?= =?UTF-8?q?=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/global/auth/service/AuthService.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) 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..00a9008e 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; @@ -18,8 +19,10 @@ public class AuthService { private final AuthCodeService authCodeService; private final JwtTokenProvider jwtTokenProvider; private final UserRepository userRepository; + private final RefreshTokenService refreshTokenService; public AuthTokenResponse exchangeCodeForToken(String code) { + if (code == null) { throw new CustomException(ErrorCode.BAD_REQUEST); } @@ -52,4 +55,26 @@ 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 (!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); + } } From 48967279695aa7c9f8a12437d17865b6e9d78d75 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 17:53:52 +0900 Subject: [PATCH 003/383] =?UTF-8?q?refactor:=20Auth=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/exception/AuthErrorCode.java | 6 +++--- .../Timo/Timo/global/auth/exception/AuthSuccessCode.java | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) 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; From 9adabee614fab34adf6e5f3d8e2cbfc969af6776 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 17:59:16 +0900 Subject: [PATCH 004/383] =?UTF-8?q?feat:=20=EC=BF=A0=ED=82=A4=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1/=EB=A7=8C=EB=A3=8C=20=EA=B3=B5=ED=86=B5=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=20CookieUtil=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/global/auth/utils/CookieUtil.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/global/auth/utils/CookieUtil.java 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..5cc4a54a --- /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("/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("/auth") + .maxAge(0) + .sameSite("Strict") + .build(); + } +} From cb9a51c9fa3e17eb318882e891876185f54b972e Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 18:00:35 +0900 Subject: [PATCH 005/383] =?UTF-8?q?refactor:=20OAuthSuccessHandler=20?= =?UTF-8?q?=EC=BF=A0=ED=82=A4=20=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=9D=84=20CookieUtil=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/handler/OAuthSuccessHandler.java | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) 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..67a19210 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; @@ -45,23 +44,12 @@ public void onAuthenticationSuccess( 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), From f102f552b5541802c89a70de71c1ab4f960d3425 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 18:18:52 +0900 Subject: [PATCH 006/383] =?UTF-8?q?feat:=20AccessToken=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20API=20=EA=B5=AC=ED=98=84=20(refreshToken?= =?UTF-8?q?=20rotation=20=EC=A0=81=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 39 +++++++++++++++++++ .../Timo/global/auth/service/AuthService.java | 1 + 2 files changed, 40 insertions(+) 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..14f54c28 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,8 +1,12 @@ package com.Timo.Timo.global.auth.controller; +import com.Timo.Timo.global.auth.dto.ReissueResult; import com.Timo.Timo.global.auth.dto.response.AuthTokenResponse; import com.Timo.Timo.global.auth.exception.AuthSuccessCode; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; import com.Timo.Timo.global.auth.service.AuthService; +import com.Timo.Timo.global.auth.utils.CookieUtil; +import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; import com.Timo.Timo.global.response.BaseResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -10,7 +14,11 @@ import io.swagger.v3.oas.annotations.tags.Tag; import java.util.Map; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; 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.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -23,6 +31,10 @@ public class AuthController { private final AuthService authService; + private final JwtTokenProvider jwtTokenProvider; + + @Value("${app.auth.cookie-secure}") + private boolean cookieSecure; @Operation(summary = "AccessToken 발급", description = "1회성 code로 AccessToken을 발급합니다.") @ApiResponses({ @@ -43,4 +55,31 @@ public ResponseEntity> token( .header("Cache-Control", "no-store") .body(BaseResponse.onSuccess(AuthSuccessCode.LOGIN_SUCCESS, authTokenResponse)); } + + @Operation(summary = "AccessToken 재발급", description = "RefreshToken으로 AccessToken을 재발급합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "재발급 성공"), + @ApiResponse(responseCode = "401", description = "유효하지 않거나 만료된 리프레시 토큰"), + @ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + @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 ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, + CookieUtil.createCookie("refreshToken", result.getRefreshToken(), + jwtTokenProvider.getRefreshTokenExpiry(), cookieSecure).toString()) + .header(HttpHeaders.SET_COOKIE, + CookieUtil.createCookie("sessionId", result.getSessionId(), + jwtTokenProvider.getRefreshTokenExpiry(), cookieSecure).toString()) + .header("Cache-Control", "no-store") + .body(BaseResponse.onSuccess(AuthSuccessCode.REISSUE_SUCCESS, + Map.of("accessToken", result.getAccessToken()))); + } + + } \ No newline at end of file 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 00a9008e..0398dd6d 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 @@ -77,4 +77,5 @@ public ReissueResult reissue(String refreshToken, String sessionId) { return new ReissueResult(newAccessToken, newRefreshToken, newSessionId); } + } From 299c20b5cc79a31f5caed6f1d4faf797c9968b3d Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 18:33:59 +0900 Subject: [PATCH 007/383] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 19 +++++++++++++++++++ .../Timo/global/auth/service/AuthService.java | 3 +++ 2 files changed, 22 insertions(+) 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 14f54c28..ac3f85c6 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 @@ -81,5 +81,24 @@ public ResponseEntity>> reissue( Map.of("accessToken", result.getAccessToken()))); } + @Operation(summary = "로그아웃", description = "현재 세션을 로그아웃합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "로그아웃 성공"), + @ApiResponse(responseCode = "401", description = "인증이 필요합니다"), + @ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + @PostMapping("/logout") + public ResponseEntity> logout( + @AuthenticationPrincipal CustomUserDetails userDetails, + @CookieValue(name = "sessionId", required = false) String sessionId + ) { + authService.logout(userDetails.getUser().getId(), sessionId); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, + CookieUtil.expireCookie("refreshToken", cookieSecure).toString()) + .header(HttpHeaders.SET_COOKIE, + CookieUtil.expireCookie("sessionId", cookieSecure).toString()) + .body(BaseResponse.onSuccess(AuthSuccessCode.LOGOUT_SUCCESS, null)); + } } \ No newline at end of file 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 0398dd6d..10c7f190 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 @@ -78,4 +78,7 @@ public ReissueResult reissue(String refreshToken, String sessionId) { return new ReissueResult(newAccessToken, newRefreshToken, newSessionId); } + public void logout(Long userId, String sessionId) { + refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); + } } From 951071eaa91c142c8c9101202628101282a5a6a7 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 18:34:46 +0900 Subject: [PATCH 008/383] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 24 +++++++++++++++++++ .../Timo/global/auth/service/AuthService.java | 11 +++++++++ 2 files changed, 35 insertions(+) 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 ac3f85c6..049400be 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 @@ -19,6 +19,7 @@ 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; @@ -101,4 +102,27 @@ public ResponseEntity> logout( CookieUtil.expireCookie("sessionId", cookieSecure).toString()) .body(BaseResponse.onSuccess(AuthSuccessCode.LOGOUT_SUCCESS, null)); } + + @Operation(summary = "회원 탈퇴", description = "회원 탈퇴 및 모든 데이터를 영구 삭제합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "회원 탈퇴 성공"), + @ApiResponse(responseCode = "401", description = "인증이 필요합니다"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 사용자"), + @ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + + @DeleteMapping("/withdraw") + public ResponseEntity> withdraw( + @AuthenticationPrincipal CustomUserDetails userDetails, + @CookieValue(name = "sessionId", required = false) String sessionId + ) { + authService.withdraw(userDetails.getUser().getId(), sessionId); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, + CookieUtil.expireCookie("refreshToken", cookieSecure).toString()) + .header(HttpHeaders.SET_COOKIE, + CookieUtil.expireCookie("sessionId", cookieSecure).toString()) + .body(BaseResponse.onSuccess(AuthSuccessCode.WITHDRAW_SUCCESS, null)); + } } \ No newline at end of file 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 10c7f190..fd6922e4 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 @@ -81,4 +81,15 @@ public ReissueResult reissue(String refreshToken, String sessionId) { public void logout(Long userId, String sessionId) { refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); } + + public void withdraw(Long userId, String sessionId) { + if (sessionId != null) { + refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); + } + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + userRepository.delete(user); + } } From 186914445c9cb0b50140c6f22b5e482359b02770 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 18:37:09 +0900 Subject: [PATCH 009/383] =?UTF-8?q?refactor:=20Auth=20API=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/global/auth/controller/AuthController.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 049400be..3c4e6a8c 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 @@ -27,7 +27,7 @@ @Tag(name = "Auth", description = "인증 관련 API") @RestController -@RequestMapping("/api/v1/auth") +@RequestMapping("/api/v1") @RequiredArgsConstructor public class AuthController { @@ -45,7 +45,7 @@ public class AuthController { @ApiResponse(responseCode = "404", description = "존재하지 않는 사용자"), @ApiResponse(responseCode = "500", description = "서버 내부 오류") }) - @PostMapping("/token") + @PostMapping("/auth/token") public ResponseEntity> token( @RequestBody Map body ) { @@ -63,7 +63,7 @@ public ResponseEntity> token( @ApiResponse(responseCode = "401", description = "유효하지 않거나 만료된 리프레시 토큰"), @ApiResponse(responseCode = "500", description = "서버 내부 오류") }) - @PostMapping("/reissue") + @PostMapping("/auth/reissue") public ResponseEntity>> reissue( @CookieValue(name = "refreshToken", required=false) String refreshToken, @CookieValue(name = "sessionId", required = false) String sessionId @@ -88,7 +88,7 @@ public ResponseEntity>> reissue( @ApiResponse(responseCode = "401", description = "인증이 필요합니다"), @ApiResponse(responseCode = "500", description = "서버 내부 오류") }) - @PostMapping("/logout") + @PostMapping("/auth/logout") public ResponseEntity> logout( @AuthenticationPrincipal CustomUserDetails userDetails, @CookieValue(name = "sessionId", required = false) String sessionId @@ -111,7 +111,7 @@ public ResponseEntity> logout( @ApiResponse(responseCode = "500", description = "서버 내부 오류") }) - @DeleteMapping("/withdraw") + @DeleteMapping("/users") public ResponseEntity> withdraw( @AuthenticationPrincipal CustomUserDetails userDetails, @CookieValue(name = "sessionId", required = false) String sessionId From bfa92fd60d2c46ad75a917aad21e16d427cfbf50 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 21:21:52 +0900 Subject: [PATCH 010/383] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=EB=90=9C=20accessToken=20=EB=B8=94=EB=9E=99=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B4=80=EB=A6=AC=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20BlacklistService=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/service/BlackListService.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java 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..32be323d --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java @@ -0,0 +1,28 @@ +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){ + redisTemplate.opsForValue().set( + KEY_PREFIX + accessToken, + "logout", + remainingExpiry, + TimeUnit.MILLISECONDS + ); + } + + public boolean isBlackListed(String accessToken){ + return Boolean.TRUE.equals(redisTemplate.hasKey(KEY_PREFIX + accessToken)); + } +} From 2be4fcfb4a717f06253e8655fe4e8b417c1e1561 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 21:26:36 +0900 Subject: [PATCH 011/383] =?UTF-8?q?feat:=20JwtTokenProvider=EC=97=90=20get?= =?UTF-8?q?RemainingExpiry=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/service/BlackListService.java | 2 +- .../com/Timo/Timo/global/jwt/provider/JwtTokenProvider.java | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) 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 index 32be323d..d13f91bb 100644 --- a/src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java +++ b/src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java @@ -18,7 +18,7 @@ public void addToBlackList(String accessToken, long remainingExpiry){ KEY_PREFIX + accessToken, "logout", remainingExpiry, - TimeUnit.MILLISECONDS + TimeUnit.SECONDS ); } 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; + } } From da8dc3eb8fc35916bd605db9e90155f19a1b12b0 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 21:31:30 +0900 Subject: [PATCH 012/383] =?UTF-8?q?feat:=20JwtAuthenticationFilter?= =?UTF-8?q?=EC=97=90=20=EB=B8=94=EB=9E=99=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/global/jwt/filter/JwtAuthenticationFilter.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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..a158f52d 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,7 @@ 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.jwt.provider.JwtTokenProvider; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -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( @@ -33,7 +35,9 @@ protected void doFilterInternal( String token = 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()); From f8b081ca2d6840075664a0baf5a1ae66243013ff Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 21:43:24 +0900 Subject: [PATCH 013/383] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=8B=9C=20Access=20Token=20=EB=B8=94=EB=9E=99?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/controller/AuthController.java | 16 ++++++++++++++-- .../Timo/global/auth/service/AuthService.java | 7 ++++++- 2 files changed, 20 insertions(+), 3 deletions(-) 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 3c4e6a8c..db776068 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 @@ -12,12 +12,14 @@ 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 jakarta.servlet.http.HttpServletRequest; import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -91,9 +93,11 @@ public ResponseEntity>> reissue( @PostMapping("/auth/logout") public ResponseEntity> logout( @AuthenticationPrincipal CustomUserDetails userDetails, - @CookieValue(name = "sessionId", required = false) String sessionId + @CookieValue(name = "sessionId", required = false) String sessionId, + HttpServletRequest request ) { - authService.logout(userDetails.getUser().getId(), sessionId); + String accessToken = resolveToken(request); + authService.logout(accessToken, userDetails.getUser().getId(), sessionId); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, @@ -125,4 +129,12 @@ public ResponseEntity> withdraw( CookieUtil.expireCookie("sessionId", cookieSecure).toString()) .body(BaseResponse.onSuccess(AuthSuccessCode.WITHDRAW_SUCCESS, null)); } + + private String resolveToken(HttpServletRequest request) { + String bearer = request.getHeader("Authorization"); + if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) { + return bearer.substring(7); + } + return null; + } } \ No newline at end of file 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 fd6922e4..ad0451ec 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 @@ -20,6 +20,7 @@ public class AuthService { private final JwtTokenProvider jwtTokenProvider; private final UserRepository userRepository; private final RefreshTokenService refreshTokenService; + private final BlackListService blackListService; public AuthTokenResponse exchangeCodeForToken(String code) { @@ -78,7 +79,11 @@ public ReissueResult reissue(String refreshToken, String sessionId) { return new ReissueResult(newAccessToken, newRefreshToken, newSessionId); } - public void logout(Long userId, String sessionId) { + public void logout(String accessToken, Long userId, String sessionId) { + if(accessToken !=null){ + long remainingExpiry = jwtTokenProvider.getRemainingExpiry(accessToken); + blackListService.addToBlackList(accessToken, remainingExpiry); + } refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); } From d3e807f7f87a360ea75f6140c91ef96bd8d26adc Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 21:48:07 +0900 Subject: [PATCH 014/383] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EC=8B=9C=20Access=20Token=20=EB=B8=94=EB=9E=99?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/controller/AuthController.java | 6 ++++-- .../Timo/global/auth/service/AuthService.java | 18 +++++++++++++----- .../global/auth/service/BlackListService.java | 2 +- 3 files changed, 18 insertions(+), 8 deletions(-) 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 db776068..d416d6e6 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 @@ -118,9 +118,11 @@ public ResponseEntity> logout( @DeleteMapping("/users") public ResponseEntity> withdraw( @AuthenticationPrincipal CustomUserDetails userDetails, - @CookieValue(name = "sessionId", required = false) String sessionId + @CookieValue(name = "sessionId", required = false) String sessionId, + HttpServletRequest request ) { - authService.withdraw(userDetails.getUser().getId(), sessionId); + String accessToken = resolveToken(request); + authService.withdraw(accessToken, userDetails.getUser().getId(), sessionId); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, 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 ad0451ec..f85596cc 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 @@ -20,7 +20,7 @@ public class AuthService { private final JwtTokenProvider jwtTokenProvider; private final UserRepository userRepository; private final RefreshTokenService refreshTokenService; - private final BlackListService blackListService; + private final BlackListService blacklistService; public AuthTokenResponse exchangeCodeForToken(String code) { @@ -80,14 +80,22 @@ public ReissueResult reissue(String refreshToken, String sessionId) { } public void logout(String accessToken, Long userId, String sessionId) { - if(accessToken !=null){ + if (accessToken != null) { long remainingExpiry = jwtTokenProvider.getRemainingExpiry(accessToken); - blackListService.addToBlackList(accessToken, remainingExpiry); + blacklistService.addToBlacklist(accessToken, remainingExpiry); + } + + if (sessionId != null) { + refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); } - refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); } - public void withdraw(Long userId, String sessionId) { + public void withdraw(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); } 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 index d13f91bb..f20b1277 100644 --- a/src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java +++ b/src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java @@ -13,7 +13,7 @@ public class BlackListService { private static final String KEY_PREFIX = "blacklist:"; - public void addToBlackList(String accessToken, long remainingExpiry){ + public void addToBlacklist(String accessToken, long remainingExpiry){ redisTemplate.opsForValue().set( KEY_PREFIX + accessToken, "logout", From 53a3e07031ff9609951d8431172cb8b06241403e Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 21:49:23 +0900 Subject: [PATCH 015/383] =?UTF-8?q?fix:=20JwtAuthenticationFilter=20?= =?UTF-8?q?=EB=B8=94=EB=9E=99=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/global/jwt/filter/JwtAuthenticationFilter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a158f52d..56e11a1e 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 @@ -37,7 +37,7 @@ protected void doFilterInternal( if(token!=null && jwtTokenProvider.validateAccessToken(token) - && blacklistService.isBlackListed(token)){ + && !blacklistService.isBlackListed(token)){ Long userId = jwtTokenProvider.getUserId(token); userRepository.findById(userId).ifPresent(user -> { CustomUserDetails userDetails = new CustomUserDetails(user, Map.of()); From 28ea62d919a1c787cf8e20fcc415b9b439d0247d Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 21:55:48 +0900 Subject: [PATCH 016/383] =?UTF-8?q?refactor:=20resolveToken=EC=9D=84=20Tok?= =?UTF-8?q?enExtractor=20=EC=9C=A0=ED=8B=B8=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/controller/AuthController.java | 15 ++++----------- .../Timo/global/auth/utils/TokenExtractor.java | 15 +++++++++++++++ .../jwt/filter/JwtAuthenticationFilter.java | 12 ++---------- 3 files changed, 21 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/global/auth/utils/TokenExtractor.java 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 d416d6e6..d0e415fa 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 @@ -6,6 +6,7 @@ import com.Timo.Timo.global.auth.principal.CustomUserDetails; import com.Timo.Timo.global.auth.service.AuthService; import com.Timo.Timo.global.auth.utils.CookieUtil; +import com.Timo.Timo.global.auth.utils.TokenExtractor; import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; import com.Timo.Timo.global.response.BaseResponse; import io.swagger.v3.oas.annotations.Operation; @@ -96,7 +97,7 @@ public ResponseEntity> logout( @CookieValue(name = "sessionId", required = false) String sessionId, HttpServletRequest request ) { - String accessToken = resolveToken(request); + String accessToken = TokenExtractor.resolveToken(request); authService.logout(accessToken, userDetails.getUser().getId(), sessionId); return ResponseEntity.ok() @@ -121,7 +122,7 @@ public ResponseEntity> withdraw( @CookieValue(name = "sessionId", required = false) String sessionId, HttpServletRequest request ) { - String accessToken = resolveToken(request); + String accessToken = TokenExtractor.resolveToken(request); authService.withdraw(accessToken, userDetails.getUser().getId(), sessionId); return ResponseEntity.ok() @@ -131,12 +132,4 @@ public ResponseEntity> withdraw( CookieUtil.expireCookie("sessionId", cookieSecure).toString()) .body(BaseResponse.onSuccess(AuthSuccessCode.WITHDRAW_SUCCESS, null)); } - - private String resolveToken(HttpServletRequest request) { - String bearer = request.getHeader("Authorization"); - if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) { - return bearer.substring(7); - } - return null; - } -} \ No newline at end of file +} 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/jwt/filter/JwtAuthenticationFilter.java b/src/main/java/com/Timo/Timo/global/jwt/filter/JwtAuthenticationFilter.java index 56e11a1e..2fd9c70d 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 @@ -3,6 +3,7 @@ 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; @@ -33,7 +34,7 @@ protected void doFilterInternal( FilterChain filterChain ) throws ServletException, IOException { - String token = resolveToken(request); + String token = TokenExtractor.resolveToken(request); if(token!=null && jwtTokenProvider.validateAccessToken(token) @@ -52,13 +53,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; - } } From 57c6ce0b3db40489bc43b936473e142ba5925de9 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 23:01:45 +0900 Subject: [PATCH 017/383] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20import=20=EB=B0=8F=20=EA=B3=B5=EB=B0=B1=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/controller/AuthController.java | 1 - .../Timo/Timo/global/auth/dto/response/AuthTokenResponse.java | 1 - .../Timo/Timo/global/auth/handler/AuthErrorResponseWriter.java | 1 - .../Timo/global/auth/handler/JwtAuthenticationEntryPoint.java | 2 +- .../com/Timo/Timo/global/auth/principal/CustomUserDetails.java | 1 - .../Timo/Timo/global/jwt/filter/JwtAuthenticationFilter.java | 1 - 6 files changed, 1 insertion(+), 6 deletions(-) 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 d0e415fa..6c6109d9 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 @@ -20,7 +20,6 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PostMapping; 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/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/principal/CustomUserDetails.java b/src/main/java/com/Timo/Timo/global/auth/principal/CustomUserDetails.java index a4799d30..e779f937 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 @@ -45,5 +45,4 @@ public String getPassword(){ public String getUsername(){ return user.getEmail(); } - } 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 2fd9c70d..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 @@ -16,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 From e0d27c2ca109402463b40e5d49f5a79849833e00 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Mon, 6 Jul 2026 23:03:07 +0900 Subject: [PATCH 018/383] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=96=B8=EC=96=B4=EB=A5=BC=20enum=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/user/entity/User.java | 33 ++++++++++--------- .../Timo/Timo/domain/user/enums/Language.java | 7 ++++ 2 files changed, 25 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/domain/user/enums/Language.java 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..3d63452e 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,28 @@ 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 = "wake_up_time", nullable = false) private LocalTime wakeUpTime; @@ -68,6 +71,15 @@ 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() { + this.onboardingCompleted = true; + } + @Builder private User( Provider provider, @@ -82,7 +94,7 @@ private User( this.email = email; this.profileImageUrl = profileImageUrl; - this.language = "ko"; + this.language = Language.KO; this.wakeUpTime = LocalTime.of(7, 0); this.bedTime = LocalTime.of(23, 0); this.predictionAccuracy = 0L; @@ -91,13 +103,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..995ce5c7 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/enums/Language.java @@ -0,0 +1,7 @@ +package com.Timo.Timo.domain.user.enums; + +public enum Language { + + KO, + EN +} From 38f7936005b4a7556a560d3afd1a14e45542927f Mon Sep 17 00:00:00 2001 From: aneykrap Date: Mon, 6 Jul 2026 23:09:00 +0900 Subject: [PATCH 019/383] =?UTF-8?q?feat:=20=EB=82=B4=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/UserProfileResponse.java | 26 ++++++++++++++++++ .../user/exception/UserSuccessCode.java | 18 +++++++++++++ .../Timo/domain/user/service/UserService.java | 27 +++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/user/dto/response/UserProfileResponse.java create mode 100644 src/main/java/com/Timo/Timo/domain/user/exception/UserSuccessCode.java create mode 100644 src/main/java/com/Timo/Timo/domain/user/service/UserService.java 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..f9850a35 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/dto/response/UserProfileResponse.java @@ -0,0 +1,26 @@ +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, + 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.isCalendarConnected(), + user.getCalendarEmail() + ); + } +} 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..2f38574d --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/exception/UserSuccessCode.java @@ -0,0 +1,18 @@ +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, "프로필을 조회했습니다."); + + private final HttpStatus httpStatus; + private final String message; +} 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..54cd6c8e --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/service/UserService.java @@ -0,0 +1,27 @@ +package com.Timo.Timo.domain.user.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.Timo.Timo.domain.user.dto.response.UserProfileResponse; +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); + } +} From f5a0a1df46a36c89c6f6a73bb965ee1d258023be Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 23:09:28 +0900 Subject: [PATCH 020/383] =?UTF-8?q?fix:=20=EC=BF=A0=ED=82=A4=20path?= =?UTF-8?q?=EB=A5=BC=20/api/v1/auth=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/global/auth/service/AuthService.java | 2 +- src/main/java/com/Timo/Timo/global/auth/utils/CookieUtil.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 f85596cc..26a77033 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 @@ -58,7 +58,7 @@ public AuthTokenResponse exchangeCodeForToken(String code) { } public ReissueResult reissue(String refreshToken, String sessionId) { - + if (refreshToken == null || sessionId == null || !jwtTokenProvider.validateRefreshToken(refreshToken)) { throw new CustomException(AuthErrorCode.INVALID_REFRESH_TOKEN); 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 index 5cc4a54a..8f31f74a 100644 --- a/src/main/java/com/Timo/Timo/global/auth/utils/CookieUtil.java +++ b/src/main/java/com/Timo/Timo/global/auth/utils/CookieUtil.java @@ -9,7 +9,7 @@ public static ResponseCookie createCookie(String name, String value, long maxAge return ResponseCookie.from(name, value) .httpOnly(true) .secure(secure) - .path("/auth") + .path("/api/v1/auth") .maxAge(Duration.ofSeconds(maxAgeSeconds)) .sameSite("Strict") .build(); @@ -19,7 +19,7 @@ public static ResponseCookie expireCookie(String name, boolean secure) { return ResponseCookie.from(name, "") .httpOnly(true) .secure(secure) - .path("/auth") + .path("/api/v1/auth") .maxAge(0) .sameSite("Strict") .build(); From 7aa87d0cc2d029471fcbb80e7979c9bec6edb6c6 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 23:09:52 +0900 Subject: [PATCH 021/383] =?UTF-8?q?fix:=20=ED=83=88=ED=87=B4=20API=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/global/auth/controller/AuthController.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 6c6109d9..858bc85b 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 @@ -29,7 +29,7 @@ @Tag(name = "Auth", description = "인증 관련 API") @RestController -@RequestMapping("/api/v1") +@RequestMapping("/api/v1/auth") @RequiredArgsConstructor public class AuthController { @@ -47,7 +47,7 @@ public class AuthController { @ApiResponse(responseCode = "404", description = "존재하지 않는 사용자"), @ApiResponse(responseCode = "500", description = "서버 내부 오류") }) - @PostMapping("/auth/token") + @PostMapping("/token") public ResponseEntity> token( @RequestBody Map body ) { @@ -65,7 +65,7 @@ public ResponseEntity> token( @ApiResponse(responseCode = "401", description = "유효하지 않거나 만료된 리프레시 토큰"), @ApiResponse(responseCode = "500", description = "서버 내부 오류") }) - @PostMapping("/auth/reissue") + @PostMapping("/reissue") public ResponseEntity>> reissue( @CookieValue(name = "refreshToken", required=false) String refreshToken, @CookieValue(name = "sessionId", required = false) String sessionId @@ -90,7 +90,7 @@ public ResponseEntity>> reissue( @ApiResponse(responseCode = "401", description = "인증이 필요합니다"), @ApiResponse(responseCode = "500", description = "서버 내부 오류") }) - @PostMapping("/auth/logout") + @PostMapping("/logout") public ResponseEntity> logout( @AuthenticationPrincipal CustomUserDetails userDetails, @CookieValue(name = "sessionId", required = false) String sessionId, @@ -115,7 +115,7 @@ public ResponseEntity> logout( @ApiResponse(responseCode = "500", description = "서버 내부 오류") }) - @DeleteMapping("/users") + @DeleteMapping("/withdraw") public ResponseEntity> withdraw( @AuthenticationPrincipal CustomUserDetails userDetails, @CookieValue(name = "sessionId", required = false) String sessionId, From 2f1cdd20ae8ca02b08e7e1e68b9ff6a894d8516b Mon Sep 17 00:00:00 2001 From: aneykrap Date: Mon, 6 Jul 2026 23:15:02 +0900 Subject: [PATCH 022/383] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EB=B0=8F=20Swagger=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserController.java | 54 +++++++++++++++ .../domain/user/docs/UserControllerDocs.java | 65 +++++++++++++++++++ .../Timo/global/config/SwaggerConfig.java | 22 +++++-- 3 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/domain/user/controller/UserController.java create mode 100644 src/main/java/com/Timo/Timo/domain/user/docs/UserControllerDocs.java 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..2dc1b594 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/controller/UserController.java @@ -0,0 +1,54 @@ +package com.Timo.Timo.domain.user.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.user.docs.UserControllerDocs; +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.exception.CustomException; +import com.Timo.Timo.global.exception.code.ErrorCode; +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/users") +@RequiredArgsConstructor +@Tag(name = "User", description = "사용자 API") +public class UserController implements UserControllerDocs { + + private final UserService userService; + + @Override + @GetMapping + public ResponseEntity> getMyProfile( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Long userId = extractUserId(userDetails); + UserProfileResponse response = userService.getMyProfile(userId); + + return ResponseEntity.ok( + BaseResponse.onSuccess(UserSuccessCode.PROFILE_RETRIEVED, response) + ); + } + + private Long extractUserId(CustomUserDetails userDetails) { + if (userDetails == null || userDetails.getUser() == null) { + throw new CustomException(ErrorCode.UNAUTHORIZED); + } + + Long userId = userDetails.getUser().getId(); + if (userId == null) { + throw new CustomException(ErrorCode.UNAUTHORIZED); + } + + return userId; + } +} diff --git a/src/main/java/com/Timo/Timo/domain/user/docs/UserControllerDocs.java b/src/main/java/com/Timo/Timo/domain/user/docs/UserControllerDocs.java new file mode 100644 index 00000000..a73da22c --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/docs/UserControllerDocs.java @@ -0,0 +1,65 @@ +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; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; + +public interface UserControllerDocs { + + @Operation( + summary = "내 프로필 조회", + description = """ + 현재 로그인한 사용자의 프로필 정보를 조회합니다. + + Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. + Access Token의 사용자 ID와 일치하는 사용자의 정보가 반환됩니다. + """, + security = @SecurityRequirement(name = "bearerAuth") + ) + @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/global/config/SwaggerConfig.java b/src/main/java/com/Timo/Timo/global/config/SwaggerConfig.java index 2a526229..a45550e9 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,32 @@ 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.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") + )) + .info(new Info() + .title("Timo API") + .description("Timo 서버 API 명세서") + .version("v1")); } } From 871daf1e51492ffc025e55c42b9ff9cc757db24d Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 00:15:56 +0900 Subject: [PATCH 023/383] =?UTF-8?q?refactor:=20=ED=83=88=ED=87=B4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=97=90=EC=84=9C=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=A1=B4=EC=9E=AC=20=EA=B2=80=EC=A6=9D=20=EC=88=9C?= =?UTF-8?q?=EC=84=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/service/AuthService.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 26a77033..bd09f6a8 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 @@ -58,7 +58,7 @@ public AuthTokenResponse exchangeCodeForToken(String code) { } public ReissueResult reissue(String refreshToken, String sessionId) { - + if (refreshToken == null || sessionId == null || !jwtTokenProvider.validateRefreshToken(refreshToken)) { throw new CustomException(AuthErrorCode.INVALID_REFRESH_TOKEN); @@ -91,6 +91,10 @@ public void logout(String accessToken, Long userId, String sessionId) { } public void withdraw(String accessToken, Long userId, String sessionId) { + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + if (accessToken != null) { long remainingExpiry = jwtTokenProvider.getRemainingExpiry(accessToken); blacklistService.addToBlacklist(accessToken, remainingExpiry); @@ -100,9 +104,6 @@ public void withdraw(String accessToken, Long userId, String sessionId) { refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); } - User user = userRepository.findById(userId) - .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); - userRepository.delete(user); } } From a06b51ea8c7f770b3030f0d6a12a11175ff7138c Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 00:18:46 +0900 Subject: [PATCH 024/383] =?UTF-8?q?refactor:=20=ED=83=88=ED=87=B4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/global/auth/service/AuthService.java | 2 ++ 1 file changed, 2 insertions(+) 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 bd09f6a8..d3646954 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 @@ -11,6 +11,7 @@ import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -90,6 +91,7 @@ public void logout(String accessToken, Long userId, String sessionId) { } } + @Transactional public void withdraw(String accessToken, Long userId, String sessionId) { User user = userRepository.findById(userId) From a9553f2dabe57f2c98f48bf818d2b04e837a2515 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 00:20:14 +0900 Subject: [PATCH 025/383] =?UTF-8?q?fix:=20=EB=A7=8C=EB=A3=8C=EB=90=9C=20Ac?= =?UTF-8?q?cess=20Token=EC=9D=80=20=EB=B8=94=EB=9E=99=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=97=90=20=EC=A0=80=EC=9E=A5=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/service/BlackListService.java | 5 +++++ 1 file changed, 5 insertions(+) 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 index f20b1277..8e42a136 100644 --- a/src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java +++ b/src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java @@ -14,6 +14,11 @@ public class BlackListService { 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", From 2cf206ccee19acf3a960270ba8c51422a1e2eb58 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Tue, 7 Jul 2026 02:17:51 +0900 Subject: [PATCH 026/383] =?UTF-8?q?refactor:=20CustomUserDetails=EC=97=90?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=EC=9E=90=20ID=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/controller/UserController.java | 17 +---------------- .../auth/handler/OAuthSuccessHandler.java | 2 +- .../auth/principal/CustomUserDetails.java | 4 ++++ 3 files changed, 6 insertions(+), 17 deletions(-) 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 index 2dc1b594..1d6103a0 100644 --- a/src/main/java/com/Timo/Timo/domain/user/controller/UserController.java +++ b/src/main/java/com/Timo/Timo/domain/user/controller/UserController.java @@ -10,8 +10,6 @@ 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.exception.CustomException; -import com.Timo.Timo.global.exception.code.ErrorCode; import com.Timo.Timo.global.auth.principal.CustomUserDetails; import com.Timo.Timo.global.response.BaseResponse; @@ -31,24 +29,11 @@ public class UserController implements UserControllerDocs { public ResponseEntity> getMyProfile( @AuthenticationPrincipal CustomUserDetails userDetails ) { - Long userId = extractUserId(userDetails); + Long userId = userDetails.getUserId(); UserProfileResponse response = userService.getMyProfile(userId); return ResponseEntity.ok( BaseResponse.onSuccess(UserSuccessCode.PROFILE_RETRIEVED, response) ); } - - private Long extractUserId(CustomUserDetails userDetails) { - if (userDetails == null || userDetails.getUser() == null) { - throw new CustomException(ErrorCode.UNAUTHORIZED); - } - - Long userId = userDetails.getUser().getId(); - if (userId == null) { - throw new CustomException(ErrorCode.UNAUTHORIZED); - } - - return userId; - } } 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..29d81d55 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 @@ -39,7 +39,7 @@ 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); 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..b3434ed2 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")); From f150a88b4d5463456a48bfabcc9f4d44610092d1 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 02:31:51 +0900 Subject: [PATCH 027/383] =?UTF-8?q?refactor:=20Controller=EC=97=90?= =?UTF-8?q?=EC=84=9C=20Swagger=20docs=20=EC=9D=B8=ED=84=B0=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EB=B6=84=EB=A6=AC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 38 +--- .../global/auth/docs/AuthControllerDocs.java | 190 ++++++++++++++++++ 2 files changed, 196 insertions(+), 32 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java 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 858bc85b..3a588fee 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,5 +1,6 @@ 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.response.AuthTokenResponse; import com.Timo.Timo.global.auth.exception.AuthSuccessCode; @@ -9,9 +10,6 @@ import com.Timo.Timo.global.auth.utils.TokenExtractor; import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; 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 jakarta.servlet.http.HttpServletRequest; import java.util.Map; @@ -31,7 +29,7 @@ @RestController @RequestMapping("/api/v1/auth") @RequiredArgsConstructor -public class AuthController { +public class AuthController implements AuthControllerDocs { private final AuthService authService; private final JwtTokenProvider jwtTokenProvider; @@ -39,14 +37,7 @@ public class AuthController { @Value("${app.auth.cookie-secure}") private boolean cookieSecure; - @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 @@ -59,12 +50,7 @@ public ResponseEntity> token( .body(BaseResponse.onSuccess(AuthSuccessCode.LOGIN_SUCCESS, authTokenResponse)); } - @Operation(summary = "AccessToken 재발급", description = "RefreshToken으로 AccessToken을 재발급합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "재발급 성공"), - @ApiResponse(responseCode = "401", description = "유효하지 않거나 만료된 리프레시 토큰"), - @ApiResponse(responseCode = "500", description = "서버 내부 오류") - }) + @Override @PostMapping("/reissue") public ResponseEntity>> reissue( @CookieValue(name = "refreshToken", required=false) String refreshToken, @@ -84,12 +70,7 @@ public ResponseEntity>> reissue( Map.of("accessToken", result.getAccessToken()))); } - @Operation(summary = "로그아웃", description = "현재 세션을 로그아웃합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "로그아웃 성공"), - @ApiResponse(responseCode = "401", description = "인증이 필요합니다"), - @ApiResponse(responseCode = "500", description = "서버 내부 오류") - }) + @Override @PostMapping("/logout") public ResponseEntity> logout( @AuthenticationPrincipal CustomUserDetails userDetails, @@ -107,14 +88,7 @@ public ResponseEntity> logout( .body(BaseResponse.onSuccess(AuthSuccessCode.LOGOUT_SUCCESS, null)); } - @Operation(summary = "회원 탈퇴", description = "회원 탈퇴 및 모든 데이터를 영구 삭제합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "회원 탈퇴 성공"), - @ApiResponse(responseCode = "401", description = "인증이 필요합니다"), - @ApiResponse(responseCode = "404", description = "존재하지 않는 사용자"), - @ApiResponse(responseCode = "500", description = "서버 내부 오류") - }) - + @Override @DeleteMapping("/withdraw") public ResponseEntity> withdraw( @AuthenticationPrincipal CustomUserDetails userDetails, 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..98232929 --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java @@ -0,0 +1,190 @@ +package com.Timo.Timo.global.auth.docs; + +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.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Map; +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으로 교환합니다. + 최초 로그인인 경우 isNewUser가 true로 반환됩니다. + RefreshToken은 Set-Cookie 헤더로 전달됩니다. + """ + ) + @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 Map body + ); + + @Operation( + summary = "AccessToken 재발급", + description = """ + 쿠키로 전달된 RefreshToken과 sessionId를 검증하여 AccessToken을 재발급합니다. + 재발급 성공 시 RefreshToken과 sessionId 쿠키가 갱신됩니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "재발급 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "401", + 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 쿠키를 만료시킵니다. + Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. + """, + security = @SecurityRequirement(name = "bearerAuth") + ) + @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 = """ + 회원 탈퇴를 진행하며, 사용자와 관련된 모든 데이터를 영구 삭제합니다. + Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. + 이 작업은 되돌릴 수 없습니다. + """, + security = @SecurityRequirement(name = "bearerAuth") + ) + @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 + ); +} \ No newline at end of file From 6153a78733a09dccc14369cfb08e5e324a0e1f0d Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 02:47:06 +0900 Subject: [PATCH 028/383] =?UTF-8?q?fix:=20createdAt=20JSON=20=EC=A7=81?= =?UTF-8?q?=EB=A0=AC=ED=99=94=20=ED=8F=AC=EB=A7=B7=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/Timo/Timo/global/common/BaseTimeEntity.java | 3 +++ 1 file changed, 3 insertions(+) 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..bc4e6703 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 HH:mm:ss") private LocalDateTime createdAt; @LastModifiedDate @Column(name = "updated_at", nullable = false) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime updatedAt; } From 1d791c9b45ebb30dd13a95f5591bbd2d20814f7b Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 03:18:59 +0900 Subject: [PATCH 029/383] =?UTF-8?q?fix:=20AuthControllerDocs=20token=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=20=ED=83=80=EC=9E=85=20=EB=B6=88=EC=9D=BC=EC=B9=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/global/auth/controller/AuthController.java | 6 +++--- .../Timo/Timo/global/auth/docs/AuthControllerDocs.java | 4 +++- .../Timo/global/auth/dto/request/AuthTokenRequest.java | 10 ++++++++++ 3 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/global/auth/dto/request/AuthTokenRequest.java 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 3a588fee..13ca7a57 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 @@ -2,6 +2,7 @@ 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.AuthTokenResponse; import com.Timo.Timo.global.auth.exception.AuthSuccessCode; import com.Timo.Timo.global.auth.principal.CustomUserDetails; @@ -40,10 +41,9 @@ public class AuthController implements AuthControllerDocs { @Override @PostMapping("/token") public ResponseEntity> token( - @RequestBody Map body + @RequestBody AuthTokenRequest request ) { - String code = body.get("code"); - AuthTokenResponse authTokenResponse = authService.exchangeCodeForToken(code); + AuthTokenResponse authTokenResponse = authService.exchangeCodeForToken(request.code()); return ResponseEntity.ok() .header("Cache-Control", "no-store") 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 index 98232929..44dfd276 100644 --- a/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java +++ b/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java @@ -1,5 +1,6 @@ package com.Timo.Timo.global.auth.docs; +import com.Timo.Timo.global.auth.dto.request.AuthTokenRequest; 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; @@ -26,6 +27,7 @@ public interface AuthControllerDocs { 최초 로그인인 경우 isNewUser가 true로 반환됩니다. RefreshToken은 Set-Cookie 헤더로 전달됩니다. """ + ) @ApiResponses({ @ApiResponse( @@ -67,7 +69,7 @@ public interface AuthControllerDocs { ) }) ResponseEntity> token( - @RequestBody Map body + @RequestBody AuthTokenRequest body ); @Operation( 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..5f180071 --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/dto/request/AuthTokenRequest.java @@ -0,0 +1,10 @@ +package com.Timo.Timo.global.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AuthTokenRequest( + @Schema(description = "로그인 성공 리다이렉트 시 쿼리스트링으로 전달받은 일회용 인가코드", + example = "550e8400-e29b-41d4-a716-446655440000") + String code +) { +} From b640600a39b6c30bd71037207e15b8a6df71c786 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 03:26:21 +0900 Subject: [PATCH 030/383] =?UTF-8?q?fix:=20ErrorDto=20timestamp=20Swagger?= =?UTF-8?q?=20=EC=98=88=EC=8B=9C=20=ED=8F=AC=EB=A7=B7=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/Timo/Timo/global/exception/dto/ErrorDto.java | 2 ++ 1 file changed, 2 insertions(+) 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, From 8c1990a85069c5219725fea62ae9d8c3888c2afa Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 03:41:19 +0900 Subject: [PATCH 031/383] =?UTF-8?q?refactor:=20Swagger=20Auth=20API=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/docs/AuthControllerDocs.java | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) 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 index 44dfd276..64c5b7a5 100644 --- a/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java +++ b/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java @@ -8,6 +8,7 @@ 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; @@ -23,11 +24,27 @@ public interface AuthControllerDocs { @Operation( summary = "AccessToken 발급", description = """ - 1회성 인증 코드(code)를 AccessToken으로 교환합니다. - 최초 로그인인 경우 isNewUser가 true로 반환됩니다. - RefreshToken은 Set-Cookie 헤더로 전달됩니다. + 1회성 인증 코드(code)를 AccessToken으로 교환합니다. + - isNewUser: 이번 요청으로 신규 회원가입이 이루어졌는지 여부입니다. true면 아직 온보딩을 진행한 적이 없는 사용자입니다. + - user.onboardingCompleted: 온보딩(초기 설정) 완료 여부입니다. false면 온보딩 화면으로, true면 홈 화면으로 이동시켜 주세요. + - isNewUser가 true인 경우 user.onboardingCompleted는 항상 false입니다. 기존 가입자가 온보딩을 중단한 경우에도 isNewUser는 false, onboardingCompleted는 false로 반환될 수 있습니다. + - RefreshToken과 sessionId는 Set-Cookie 헤더로 전달되며, 응답 바디에는 포함되지 않습니다. """ - + ) + @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( From 3b5fcfb14f1ad8e38ee6f43e483bce051561c191 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 03:59:19 +0900 Subject: [PATCH 032/383] =?UTF-8?q?refactor:=20AuthReissueResponse=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/controller/AuthController.java | 10 ++++++---- .../global/auth/docs/AuthControllerDocs.java | 18 ++++++++++-------- .../auth/dto/response/AuthReissueResponse.java | 13 +++++++++++++ 3 files changed, 29 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/global/auth/dto/response/AuthReissueResponse.java 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 13ca7a57..73774fce 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 @@ -3,6 +3,7 @@ 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.principal.CustomUserDetails; @@ -13,7 +14,6 @@ import com.Timo.Timo.global.response.BaseResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; -import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; @@ -52,11 +52,14 @@ public ResponseEntity> token( @Override @PostMapping("/reissue") - public ResponseEntity>> reissue( + public ResponseEntity> reissue( @CookieValue(name = "refreshToken", required=false) String refreshToken, @CookieValue(name = "sessionId", required = false) String sessionId ) { ReissueResult result = authService.reissue(refreshToken, sessionId); + AuthReissueResponse authReissueResponse = AuthReissueResponse.builder() + .accessToken(result.getAccessToken()) + .build(); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, @@ -66,8 +69,7 @@ public ResponseEntity>> reissue( CookieUtil.createCookie("sessionId", result.getSessionId(), jwtTokenProvider.getRefreshTokenExpiry(), cookieSecure).toString()) .header("Cache-Control", "no-store") - .body(BaseResponse.onSuccess(AuthSuccessCode.REISSUE_SUCCESS, - Map.of("accessToken", result.getAccessToken()))); + .body(BaseResponse.onSuccess(AuthSuccessCode.REISSUE_SUCCESS, authReissueResponse)); } @Override 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 index 64c5b7a5..9e4c31b6 100644 --- a/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java +++ b/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java @@ -1,6 +1,7 @@ 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; @@ -24,11 +25,11 @@ public interface AuthControllerDocs { @Operation( summary = "AccessToken 발급", description = """ - 1회성 인증 코드(code)를 AccessToken으로 교환합니다. - - isNewUser: 이번 요청으로 신규 회원가입이 이루어졌는지 여부입니다. true면 아직 온보딩을 진행한 적이 없는 사용자입니다. - - user.onboardingCompleted: 온보딩(초기 설정) 완료 여부입니다. false면 온보딩 화면으로, true면 홈 화면으로 이동시켜 주세요. - - isNewUser가 true인 경우 user.onboardingCompleted는 항상 false입니다. 기존 가입자가 온보딩을 중단한 경우에도 isNewUser는 false, onboardingCompleted는 false로 반환될 수 있습니다. - - RefreshToken과 sessionId는 Set-Cookie 헤더로 전달되며, 응답 바디에는 포함되지 않습니다. + 1회성 인증 코드(code)를 AccessToken으로 교환 + - isNewUser: 이번 요청으로 신규 회원가입이 이루어졌는지 여부 (true면 아직 온보딩을 진행한 적이 없는 사용자) + - user.onboardingCompleted: 온보딩 완료 여부 (false면 온보딩 화면으로, true면 홈 화면으로 이동) + - 기존 가입자가 온보딩을 중단한 경우에도 isNewUser는 false, onboardingCompleted는 false로 반환될 수 가능 + - RefreshToken과 sessionId는 Set-Cookie 헤더로 전달되며, 응답 바디에는 포함되지 않음 """ ) @io.swagger.v3.oas.annotations.parameters.RequestBody( @@ -92,9 +93,10 @@ ResponseEntity> token( @Operation( summary = "AccessToken 재발급", description = """ - 쿠키로 전달된 RefreshToken과 sessionId를 검증하여 AccessToken을 재발급합니다. - 재발급 성공 시 RefreshToken과 sessionId 쿠키가 갱신됩니다. + 쿠키로 전달된 RefreshToken과 sessionId를 검증하여 AccessToken 재발급 + 재발급 성공 시 RefreshToken과 sessionId 쿠키 갱신 """ + ) @ApiResponses({ @ApiResponse( @@ -119,7 +121,7 @@ ResponseEntity> token( ) ) }) - ResponseEntity>> reissue( + ResponseEntity> reissue( @CookieValue(name = "refreshToken", required = false) String refreshToken, @CookieValue(name = "sessionId", required = false) String sessionId ); 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; +} From 9f93491a13d3b7242c33caaed22424b679fbd214 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 04:58:18 +0900 Subject: [PATCH 033/383] =?UTF-8?q?feat:=20code=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=EC=97=90=20=EC=9E=85=EB=A0=A5=20=EA=B2=80=EC=A6=9D=20=EC=96=B4?= =?UTF-8?q?=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/dto/request/AuthTokenRequest.java | 2 ++ 1 file changed, 2 insertions(+) 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 index 5f180071..1aafaa47 100644 --- 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 @@ -1,10 +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 ) { } From 015cda4435e7dd120b4a98c6db649fee5856e0d3 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:05:33 +0900 Subject: [PATCH 034/383] =?UTF-8?q?fix:=20autoLabel=20=EC=A3=BC=EC=84=9D?= =?UTF-8?q?=20=ED=95=B4=EC=A0=9C=20=EB=B0=8F=20=EA=B4=84=ED=98=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/auto-label.yml | 237 ++++++++++++++++--------------- 1 file changed, 119 insertions(+), 118 deletions(-) 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] + }); + } From f3aba38a8ebc3573f8dd8fd69507d4b88facbba1 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:09:26 +0900 Subject: [PATCH 035/383] =?UTF-8?q?fix:=20autoLabel=20=EC=A3=BC=EC=84=9D?= =?UTF-8?q?=20=ED=95=B4=EC=A0=9C=20=EB=B0=8F=20=EA=B4=84=ED=98=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 0 -> 6148 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..a3497ae2c49347eef94ab256c8a3b33cbec77025 GIT binary patch literal 6148 zcmeHKJxeP=6uqNKjJ}5kEVK~ZA0Uq>2$pY^h+u1BCuXx60|_hoEMB*Tf55_4`~!l5 zU?JGrSXlZWto;BR&z%`JbCaxCi9F`Q%$dC(b5BmPJ6R%9i~I2`QH6-g7>tDxOnr>w zJbG-vdZvMj_h>a{SL;c8rzg`pyaHZ<@2CLZy9N414QkR~asR5*pKh$3RI5=diQp^W zK8&5bJl`zHc>G~}XWMx;4F5(c4ZZiJZQ7?ubf@u^-VB$+%+dVCz2VTcPdn)_$Nb(flT zNm3J6*wjXH=w#83P7VX0GV9JyNO{uc47|N8xAK1LmVrtNolhU2BkL|4N8;a81;SY2= zsnDRGy#ihVslcGQtnm52l>h!Oi~N;Wz$@@yDWD3~)#@TXl092{J{+I50mcRf8|S44 lbqOYW9P0%i#giD?5DRz#3@xSx;eq)-0$K(?c?G^yfj4Vg?sWhF literal 0 HcmV?d00001 From b49b6141d3c924078805f7910e8e8b177e22f970 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Tue, 7 Jul 2026 02:30:47 +0900 Subject: [PATCH 036/383] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=96=B8=EC=96=B4=20=EB=B3=80=EA=B2=BD=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/dto/request/UpdateLanguageRequest.java | 13 +++++++++++++ .../user/dto/response/UpdateLanguageResponse.java | 11 +++++++++++ .../java/com/Timo/Timo/domain/user/entity/User.java | 4 ++++ .../Timo/domain/user/exception/UserSuccessCode.java | 3 ++- .../Timo/Timo/domain/user/service/UserService.java | 12 ++++++++++++ 5 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/Timo/Timo/domain/user/dto/request/UpdateLanguageRequest.java create mode 100644 src/main/java/com/Timo/Timo/domain/user/dto/response/UpdateLanguageResponse.java 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/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/entity/User.java b/src/main/java/com/Timo/Timo/domain/user/entity/User.java index 3d63452e..763e0be4 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 @@ -80,6 +80,10 @@ public void completeOnboarding() { this.onboardingCompleted = true; } + public void updateLanguage(Language language) { + this.language = language; + } + @Builder private User( Provider provider, 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 index 2f38574d..61c25b7d 100644 --- a/src/main/java/com/Timo/Timo/domain/user/exception/UserSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/user/exception/UserSuccessCode.java @@ -11,7 +11,8 @@ @RequiredArgsConstructor public enum UserSuccessCode implements BaseSuccessCode { - PROFILE_RETRIEVED(HttpStatus.OK, "프로필을 조회했습니다."); + PROFILE_RETRIEVED(HttpStatus.OK, "프로필을 조회했습니다."), + LANGUAGE_UPDATED(HttpStatus.OK, "언어설정이 수정되었습니다."); private final HttpStatus httpStatus; private final String message; 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 index 54cd6c8e..22b575e6 100644 --- a/src/main/java/com/Timo/Timo/domain/user/service/UserService.java +++ b/src/main/java/com/Timo/Timo/domain/user/service/UserService.java @@ -3,7 +3,9 @@ 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.response.UserProfileResponse; +import com.Timo.Timo.domain.user.dto.response.UpdateLanguageResponse; import com.Timo.Timo.domain.user.entity.User; import com.Timo.Timo.domain.user.exception.UserErrorCode; import com.Timo.Timo.domain.user.repository.UserRepository; @@ -24,4 +26,14 @@ public UserProfileResponse getMyProfile(Long userId) { 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()); + } } From d327301e4df53180a4b26f1dfc8c2279bdf011ac Mon Sep 17 00:00:00 2001 From: aneykrap Date: Tue, 7 Jul 2026 14:23:42 +0900 Subject: [PATCH 037/383] =?UTF-8?q?fix:=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserController.java | 7 +- .../domain/user/docs/UserLanguageDocs.java | 82 +++++++++++++++++++ ...ntrollerDocs.java => UserProfileDocs.java} | 3 +- 3 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/domain/user/docs/UserLanguageDocs.java rename src/main/java/com/Timo/Timo/domain/user/docs/{UserControllerDocs.java => UserProfileDocs.java} (98%) 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 index 1d6103a0..9796a191 100644 --- a/src/main/java/com/Timo/Timo/domain/user/controller/UserController.java +++ b/src/main/java/com/Timo/Timo/domain/user/controller/UserController.java @@ -1,15 +1,18 @@ package com.Timo.Timo.domain.user.controller; +import com.Timo.Timo.domain.user.docs.UserLanguageDocs; +import com.Timo.Timo.domain.user.docs.UserProfileDocs; 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.user.docs.UserControllerDocs; 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.exception.CustomException; +import com.Timo.Timo.global.exception.code.ErrorCode; import com.Timo.Timo.global.auth.principal.CustomUserDetails; import com.Timo.Timo.global.response.BaseResponse; @@ -20,7 +23,7 @@ @RequestMapping("/api/v1/users") @RequiredArgsConstructor @Tag(name = "User", description = "사용자 API") -public class UserController implements UserControllerDocs { +public class UserController implements UserProfileDocs, UserLanguageDocs { private final UserService userService; 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..b19ce021 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/docs/UserLanguageDocs.java @@ -0,0 +1,82 @@ +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.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.SecurityRequirement; + +public interface UserLanguageDocs { + + @Operation( + summary = "서비스 언어 수정", + description = """ + 현재 로그인한 사용자의 서비스 언어를 변경합니다. + + 변경 가능한 값은 `KO`, `EN`입니다. + 이름, 이메일, 프로필 이미지는 구글 계정 기반 정보이므로 해당 API에서 변경할 수 없습니다. + """, + security = @SecurityRequirement(name = "bearerAuth") + ) + @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/UserControllerDocs.java b/src/main/java/com/Timo/Timo/domain/user/docs/UserProfileDocs.java similarity index 98% rename from src/main/java/com/Timo/Timo/domain/user/docs/UserControllerDocs.java rename to src/main/java/com/Timo/Timo/domain/user/docs/UserProfileDocs.java index a73da22c..f0da8d82 100644 --- a/src/main/java/com/Timo/Timo/domain/user/docs/UserControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/user/docs/UserProfileDocs.java @@ -15,7 +15,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.security.SecurityRequirement; -public interface UserControllerDocs { +public interface UserProfileDocs { @Operation( summary = "내 프로필 조회", @@ -55,7 +55,6 @@ public interface UserControllerDocs { content = @Content( mediaType = "application/json", schema = @Schema(implementation = ErrorDto.class) - ) ) }) From e631dcc847710ff88c7010d2014243ece5549917 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Tue, 7 Jul 2026 02:33:32 +0900 Subject: [PATCH 038/383] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EA=B3=B5=EB=B0=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/Timo/Timo/domain/user/enums/Language.java | 1 - 1 file changed, 1 deletion(-) 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 index 995ce5c7..31483389 100644 --- a/src/main/java/com/Timo/Timo/domain/user/enums/Language.java +++ b/src/main/java/com/Timo/Timo/domain/user/enums/Language.java @@ -1,7 +1,6 @@ package com.Timo.Timo.domain.user.enums; public enum Language { - KO, EN } From 0edaf1bc01e2803a7618f1d150782346d609b069 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Tue, 7 Jul 2026 02:37:43 +0900 Subject: [PATCH 039/383] =?UTF-8?q?fix:=20=EC=9E=98=EB=AA=BB=EB=90=9C=20en?= =?UTF-8?q?um=20=EC=9A=94=EC=B2=AD=EC=9D=84=20400=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/global/exception/GlobalExceptionHandler.java | 9 +++++++++ 1 file changed, 9 insertions(+) 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..c81aee51 100644 --- a/src/main/java/com/Timo/Timo/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/Timo/Timo/global/exception/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import java.time.LocalDateTime; 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; @@ -35,6 +36,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, From 1662237b8108572ce45258127d07c879a9d8fc6e Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 14:27:44 +0900 Subject: [PATCH 040/383] =?UTF-8?q?refactor:=20=EC=BF=A0=ED=82=A4/?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=A1=B0=EB=A6=BD=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=9D=84=20AuthResponseFactory=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 40 ++--------- .../auth/factory/AuthResponseFactory.java | 68 +++++++++++++++++++ 2 files changed, 74 insertions(+), 34 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/global/auth/factory/AuthResponseFactory.java 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 73774fce..54cb81e1 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 @@ -5,18 +5,15 @@ 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.CookieUtil; import com.Timo.Timo.global.auth.utils.TokenExtractor; -import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; import com.Timo.Timo.global.response.BaseResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.CookieValue; @@ -33,7 +30,7 @@ public class AuthController implements AuthControllerDocs { private final AuthService authService; - private final JwtTokenProvider jwtTokenProvider; + private final AuthResponseFactory authResponseFactory; @Value("${app.auth.cookie-secure}") private boolean cookieSecure; @@ -44,10 +41,7 @@ public ResponseEntity> token( @RequestBody AuthTokenRequest request ) { AuthTokenResponse authTokenResponse = authService.exchangeCodeForToken(request.code()); - - return ResponseEntity.ok() - .header("Cache-Control", "no-store") - .body(BaseResponse.onSuccess(AuthSuccessCode.LOGIN_SUCCESS, authTokenResponse)); + return authResponseFactory.tokenResponse(authTokenResponse); } @Override @@ -57,19 +51,7 @@ public ResponseEntity> reissue( @CookieValue(name = "sessionId", required = false) String sessionId ) { ReissueResult result = authService.reissue(refreshToken, sessionId); - AuthReissueResponse authReissueResponse = AuthReissueResponse.builder() - .accessToken(result.getAccessToken()) - .build(); - - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, - CookieUtil.createCookie("refreshToken", result.getRefreshToken(), - jwtTokenProvider.getRefreshTokenExpiry(), cookieSecure).toString()) - .header(HttpHeaders.SET_COOKIE, - CookieUtil.createCookie("sessionId", result.getSessionId(), - jwtTokenProvider.getRefreshTokenExpiry(), cookieSecure).toString()) - .header("Cache-Control", "no-store") - .body(BaseResponse.onSuccess(AuthSuccessCode.REISSUE_SUCCESS, authReissueResponse)); + return authResponseFactory.reissueResponse(result); } @Override @@ -82,12 +64,7 @@ public ResponseEntity> logout( String accessToken = TokenExtractor.resolveToken(request); authService.logout(accessToken, userDetails.getUser().getId(), sessionId); - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, - CookieUtil.expireCookie("refreshToken", cookieSecure).toString()) - .header(HttpHeaders.SET_COOKIE, - CookieUtil.expireCookie("sessionId", cookieSecure).toString()) - .body(BaseResponse.onSuccess(AuthSuccessCode.LOGOUT_SUCCESS, null)); + return authResponseFactory.logoutResponse(); } @Override @@ -100,11 +77,6 @@ public ResponseEntity> withdraw( String accessToken = TokenExtractor.resolveToken(request); authService.withdraw(accessToken, userDetails.getUser().getId(), sessionId); - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, - CookieUtil.expireCookie("refreshToken", cookieSecure).toString()) - .header(HttpHeaders.SET_COOKIE, - CookieUtil.expireCookie("sessionId", cookieSecure).toString()) - .body(BaseResponse.onSuccess(AuthSuccessCode.WITHDRAW_SUCCESS, null)); + return authResponseFactory.withdrawResponse(); } } 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..03208c03 --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/factory/AuthResponseFactory.java @@ -0,0 +1,68 @@ +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()) + .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(); + } +} + From c0ede3e4e1f5865f47f486f46c7903597d04f9d8 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 14:28:45 +0900 Subject: [PATCH 041/383] =?UTF-8?q?fix:=20token=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=97=90=20@Valid=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/controller/AuthController.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 54cb81e1..3a4304d0 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 @@ -12,6 +12,7 @@ import com.Timo.Timo.global.response.BaseResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; @@ -38,7 +39,7 @@ public class AuthController implements AuthControllerDocs { @Override @PostMapping("/token") public ResponseEntity> token( - @RequestBody AuthTokenRequest request + @Valid @RequestBody AuthTokenRequest request ) { AuthTokenResponse authTokenResponse = authService.exchangeCodeForToken(request.code()); return authResponseFactory.tokenResponse(authTokenResponse); From be4cc5c7383bff32be12716941c0909ca2f778cd Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 14:42:19 +0900 Subject: [PATCH 042/383] =?UTF-8?q?fix:=20=ED=83=88=ED=87=B4=20=EC=8B=9C?= =?UTF-8?q?=20=EC=A0=84=EC=B2=B4=20=EC=84=B8=EC=85=98=20refresh=20token=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=B0=8F=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?=EC=8B=9C=20=EC=9C=A0=EC=A0=80=20=EC=A1=B4=EC=9E=AC=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/global/auth/docs/AuthControllerDocs.java | 9 +++++++++ .../com/Timo/Timo/global/auth/service/AuthService.java | 8 +++++--- .../Timo/global/auth/service/RefreshTokenService.java | 8 ++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) 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 index 9e4c31b6..b941270f 100644 --- a/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java +++ b/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java @@ -112,6 +112,14 @@ ResponseEntity> token( schema = @Schema(implementation = ErrorDto.class) ) ), + @ApiResponse( + responseCode = "404", + description = "RefreshToken에 해당하는 사용자를 찾을 수 없는 경우 (탈퇴 등으로 삭제된 사용자)", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), @ApiResponse( responseCode = "500", description = "서버 내부 오류", @@ -203,6 +211,7 @@ ResponseEntity> logout( ) ) }) + ResponseEntity> withdraw( @Parameter(hidden = true) CustomUserDetails userDetails, @CookieValue(name = "sessionId", required = false) String sessionId, 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 d3646954..5b24c793 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 @@ -67,6 +67,10 @@ public ReissueResult reissue(String refreshToken, String sessionId) { 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); } @@ -102,9 +106,7 @@ public void withdraw(String accessToken, Long userId, String sessionId) { blacklistService.addToBlacklist(accessToken, remainingExpiry); } - if (sessionId != null) { - refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); - } + refreshTokenService.deleteAllRefreshTokens(String.valueOf(userId)); userRepository.delete(user); } 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..0c994cb2 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 @@ -2,6 +2,7 @@ import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; import java.util.Objects; +import java.util.Set; import java.util.UUID; import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; @@ -36,6 +37,13 @@ public void deleteRefreshToken(String userId, String sessionId) { redisTemplate.delete(KEY_PREFIX + userId + ":" + sessionId); } + public void deleteAllRefreshTokens(String userId) { + Set keys = redisTemplate.keys(KEY_PREFIX + userId + ":*"); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + } + public boolean isRefreshTokenValid(String userId, String sessionId, String refreshToken) { return Objects.equals(refreshToken, getRefreshToken(userId, sessionId)); } From 5f46700b3f06e8b0fa2c1ecdc86a3ec1535e1de0 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 14:51:13 +0900 Subject: [PATCH 043/383] =?UTF-8?q?chore:=20AuthController=EC=9D=98=20?= =?UTF-8?q?=EB=AF=B8=EC=82=AC=EC=9A=A9=20cookieSecure=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/controller/AuthController.java | 3 --- 1 file changed, 3 deletions(-) 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 3a4304d0..cbc5ca49 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 @@ -33,9 +33,6 @@ public class AuthController implements AuthControllerDocs { private final AuthService authService; private final AuthResponseFactory authResponseFactory; - @Value("${app.auth.cookie-secure}") - private boolean cookieSecure; - @Override @PostMapping("/token") public ResponseEntity> token( From 8fd40a91b5225b393bd37902f001905a47d3a059 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Tue, 7 Jul 2026 15:38:36 +0900 Subject: [PATCH 044/383] =?UTF-8?q?fix:=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserController.java | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) 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 index 9796a191..770e169d 100644 --- a/src/main/java/com/Timo/Timo/domain/user/controller/UserController.java +++ b/src/main/java/com/Timo/Timo/domain/user/controller/UserController.java @@ -5,18 +5,21 @@ 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.response.UpdateLanguageResponse; 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.exception.CustomException; -import com.Timo.Timo.global.exception.code.ErrorCode; 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 @@ -39,4 +42,18 @@ public ResponseEntity> getMyProfile( 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) + ); + } } From 9f4cceaaabaed31a8ba62e32406da38830ea4b1d Mon Sep 17 00:00:00 2001 From: aneykrap Date: Tue, 7 Jul 2026 16:01:38 +0900 Subject: [PATCH 045/383] =?UTF-8?q?fix:=20Swagger=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=EC=97=90=20=EB=B3=B4=EC=95=88=20=EC=9A=94=EA=B5=AC=20=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/user/docs/UserLanguageDocs.java | 5 +---- .../java/com/Timo/Timo/domain/user/docs/UserProfileDocs.java | 4 +--- .../com/Timo/Timo/global/auth/controller/AuthController.java | 4 +++- src/main/java/com/Timo/Timo/global/config/SwaggerConfig.java | 2 ++ 4 files changed, 7 insertions(+), 8 deletions(-) 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 index b19ce021..4455f852 100644 --- a/src/main/java/com/Timo/Timo/domain/user/docs/UserLanguageDocs.java +++ b/src/main/java/com/Timo/Timo/domain/user/docs/UserLanguageDocs.java @@ -11,11 +11,9 @@ 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.SecurityRequirement; public interface UserLanguageDocs { @@ -26,8 +24,7 @@ public interface UserLanguageDocs { 변경 가능한 값은 `KO`, `EN`입니다. 이름, 이메일, 프로필 이미지는 구글 계정 기반 정보이므로 해당 API에서 변경할 수 없습니다. - """, - security = @SecurityRequirement(name = "bearerAuth") + """ ) @ApiResponses({ @ApiResponse( 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 index f0da8d82..4c114e65 100644 --- a/src/main/java/com/Timo/Timo/domain/user/docs/UserProfileDocs.java +++ b/src/main/java/com/Timo/Timo/domain/user/docs/UserProfileDocs.java @@ -13,7 +13,6 @@ 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.SecurityRequirement; public interface UserProfileDocs { @@ -24,8 +23,7 @@ public interface UserProfileDocs { Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. Access Token의 사용자 ID와 일치하는 사용자의 정보가 반환됩니다. - """, - security = @SecurityRequirement(name = "bearerAuth") + """ ) @ApiResponses({ @ApiResponse( 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..09c0af30 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 @@ -7,6 +7,7 @@ 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.security.SecurityRequirements; import io.swagger.v3.oas.annotations.tags.Tag; import java.util.Map; import lombok.RequiredArgsConstructor; @@ -25,6 +26,7 @@ public class AuthController { private final AuthService authService; @Operation(summary = "AccessToken 발급", description = "1회성 code로 AccessToken을 발급합니다.") + @SecurityRequirements @ApiResponses({ @ApiResponse(responseCode = "200", description = "로그인 성공"), @ApiResponse(responseCode = "400", description = "code 누락"), @@ -43,4 +45,4 @@ public ResponseEntity> token( .header("Cache-Control", "no-store") .body(BaseResponse.onSuccess(AuthSuccessCode.LOGIN_SUCCESS, authTokenResponse)); } -} \ No newline at end of file +} 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 a45550e9..28ac02d8 100644 --- a/src/main/java/com/Timo/Timo/global/config/SwaggerConfig.java +++ b/src/main/java/com/Timo/Timo/global/config/SwaggerConfig.java @@ -3,6 +3,7 @@ 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; @@ -24,6 +25,7 @@ public OpenAPI customOpenAPI() { .scheme("bearer") .bearerFormat("JWT") )) + .addSecurityItem(new SecurityRequirement().addList(BEARER_AUTH)) .info(new Info() .title("Timo API") .description("Timo 서버 API 명세서") From 7534bf45c504be4c3546707780e4b373a0b64c4a Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:35:43 +0900 Subject: [PATCH 046/383] =?UTF-8?q?feat:=20TODO=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20enum=20=EC=84=B8?= =?UTF-8?q?=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/todo/entity/Subtask.java | 50 ++++++ .../Timo/Timo/domain/todo/entity/Todo.java | 162 ++++++++++++++++++ .../Timo/domain/todo/enums/RepeatType.java | 8 + .../Timo/Timo/domain/todo/enums/TodoIcon.java | 12 ++ .../Timo/domain/todo/enums/TodoPriority.java | 8 + .../Timo/Timo/domain/todo/enums/Weekday.java | 11 ++ 6 files changed, 251 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/todo/entity/Subtask.java create mode 100644 src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java create mode 100644 src/main/java/com/Timo/Timo/domain/todo/enums/RepeatType.java create mode 100644 src/main/java/com/Timo/Timo/domain/todo/enums/TodoIcon.java create mode 100644 src/main/java/com/Timo/Timo/domain/todo/enums/TodoPriority.java create mode 100644 src/main/java/com/Timo/Timo/domain/todo/enums/Weekday.java 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..4f390634 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/entity/Subtask.java @@ -0,0 +1,50 @@ +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.Builder; +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 = "completed", nullable = false) + private boolean completed; + + public static Subtask of(String content) { + Subtask subtask = new Subtask(); + subtask.content = content; + subtask.completed = false; + return subtask; + } + + void assignTodo(Todo todo) { + this.todo = todo; + } +} 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..901a48ca --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java @@ -0,0 +1,162 @@ +package com.Timo.Timo.domain.todo.entity; + +import java.time.LocalDate; +import java.util.ArrayList; +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.user.entity.User; +import com.Timo.Timo.global.common.BaseTimeEntity; + +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; + + @OneToMany(mappedBy = "todo", cascade = CascadeType.ALL, orphanRemoval = true) + private List subtasks = new ArrayList<>(); + + @Column(name = "todo_date", nullable = false) + private LocalDate date; + + @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; + + @Enumerated(EnumType.STRING) + @Column(name = "repeat_type", nullable = false, length = 20) + private RepeatType repeatType; + + @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; + + @Lob + @Column(name = "memo") + private String memo; + + @Column(name = "sort_order", nullable = false) + private Integer sortOrder; + + @Builder(access = AccessLevel.PRIVATE) + private Todo( + User user, + TodoIcon icon, + String title, + LocalDate date, + Integer durationSeconds, + TodoPriority priority, + Long tagId, + RepeatType repeatType, + List repeatWeekdays, + Integer repeatDayOfMonth, + String memo, + Integer sortOrder + ) { + this.user = user; + this.icon = icon; + this.title = title; + this.date = date; + this.durationSeconds = durationSeconds; + this.priority = priority; + this.tagId = tagId; + this.repeatType = repeatType; + this.repeatWeekdays = repeatWeekdays != null ? new ArrayList<>(repeatWeekdays) : new ArrayList<>(); + this.repeatDayOfMonth = repeatDayOfMonth; + this.memo = memo; + this.sortOrder = sortOrder; + } + + public static Todo create( + User user, + TodoIcon icon, + String title, + List subtaskContents, + LocalDate date, + int durationSeconds, + TodoPriority priority, + Long tagId, + RepeatType repeatType, + List repeatWeekdays, + Integer repeatDayOfMonth, + String memo, + int sortOrder + ) { + Todo todo = Todo.builder() + .user(user) + .icon(icon) + .title(title) + .date(date) + .durationSeconds(durationSeconds) + .priority(priority) + .tagId(tagId) + .repeatType(repeatType) + .repeatWeekdays(repeatWeekdays) + .repeatDayOfMonth(repeatDayOfMonth) + .memo(memo) + .sortOrder(sortOrder) + .build(); + + if (subtaskContents != null) { + subtaskContents.forEach(content -> todo.addSubtask(Subtask.of(content))); + } + return todo; + } + + private void addSubtask(Subtask subtask) { + this.subtasks.add(subtask); + subtask.assignTodo(this); + } +} 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/Weekday.java b/src/main/java/com/Timo/Timo/domain/todo/enums/Weekday.java new file mode 100644 index 00000000..73ccd0ae --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/enums/Weekday.java @@ -0,0 +1,11 @@ +package com.Timo.Timo.domain.todo.enums; + +public enum Weekday { + MON, + TUE, + WED, + THU, + FRI, + SAT, + SUN +} From 8c8ad7a732b0481d07695e03b2d9459b8a782174 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:52:08 +0900 Subject: [PATCH 047/383] =?UTF-8?q?feat:=20TODO=20=EB=A0=88=ED=8F=AC?= =?UTF-8?q?=EC=A7=80=ED=86=A0=EB=A6=AC=20=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../todo/repository/TodoRepository.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java 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..c78c0a85 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java @@ -0,0 +1,21 @@ +package com.Timo.Timo.domain.todo.repository; + +import java.time.LocalDate; + +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 { + + long countByUser_IdAndDate(Long userId, LocalDate date); + + @Query(""" + select coalesce(max(t.sortOrder), 0) + from Todo t + where t.user.id = :userId and t.date = :date + """) + int findMaxSortOrderByUserIdAndDate(@Param("userId") Long userId, @Param("date") LocalDate date); +} From ce57ecc291886acfb643ba6977616f1f4467e154 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:52:42 +0900 Subject: [PATCH 048/383] =?UTF-8?q?feat:=20TODO=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EC=BD=94=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/todo/exception/TodoErrorCode.java | 21 ++++++++ .../todo/exception/TodoExceptionHandler.java | 49 +++++++++++++++++++ .../todo/exception/TodoSuccessCode.java | 18 +++++++ 3 files changed, 88 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java create mode 100644 src/main/java/com/Timo/Timo/domain/todo/exception/TodoExceptionHandler.java create mode 100644 src/main/java/com/Timo/Timo/domain/todo/exception/TodoSuccessCode.java 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..4d880f40 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java @@ -0,0 +1,21 @@ +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자를 초과할 수 없습니다."), + MAX_COUNT_EXCEEDED(HttpStatus.CONFLICT, "COMMON_409", "해당 날짜의 투두가 최대 개수(20개)를 초과했습니다."); + + 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..1981141e --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoExceptionHandler.java @@ -0,0 +1,49 @@ +package com.Timo.Timo.domain.todo.exception; + +import java.time.LocalDateTime; + +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) +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..f36845e4 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoSuccessCode.java @@ -0,0 +1,18 @@ +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가 생성되었습니다."); + + private final HttpStatus httpStatus; + private final String message; +} From 8c3adf9c0d252307d1431716ab3fec02ca1e986f Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:53:22 +0900 Subject: [PATCH 049/383] =?UTF-8?q?refactor:=20handler=20=EC=9A=94?= =?UTF-8?q?=EC=86=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/global/exception/GlobalExceptionHandler.java | 9 +++++++++ 1 file changed, 9 insertions(+) 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..c81aee51 100644 --- a/src/main/java/com/Timo/Timo/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/Timo/Timo/global/exception/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import java.time.LocalDateTime; 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; @@ -35,6 +36,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, From c8353da125db2080d45f3bdc26d491fdf6ae8174 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 17:03:10 +0900 Subject: [PATCH 050/383] =?UTF-8?q?feat:=20ReissueResult=20DTO=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/dto/ReissueResult.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/global/auth/dto/ReissueResult.java 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; +} From 7d5c21ba0e408eca7018e2ae1fb532554ed8a865 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 17:34:16 +0900 Subject: [PATCH 051/383] =?UTF-8?q?feat:=20AuthService=EC=97=90=20?= =?UTF-8?q?=EC=9E=AC=EB=B0=9C=EA=B8=89=20=EB=B9=84=EC=A6=88=EB=8B=88?= =?UTF-8?q?=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/global/auth/service/AuthService.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) 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..00a9008e 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; @@ -18,8 +19,10 @@ public class AuthService { private final AuthCodeService authCodeService; private final JwtTokenProvider jwtTokenProvider; private final UserRepository userRepository; + private final RefreshTokenService refreshTokenService; public AuthTokenResponse exchangeCodeForToken(String code) { + if (code == null) { throw new CustomException(ErrorCode.BAD_REQUEST); } @@ -52,4 +55,26 @@ 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 (!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); + } } From 8b8c574a56c5f2a3f8f3cd3bc76f30ca99ee8071 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 17:53:52 +0900 Subject: [PATCH 052/383] =?UTF-8?q?refactor:=20Auth=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/exception/AuthErrorCode.java | 6 +++--- .../Timo/Timo/global/auth/exception/AuthSuccessCode.java | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) 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; From 75634540492862694fad34ce674c3e8983aa35cf Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 17:59:16 +0900 Subject: [PATCH 053/383] =?UTF-8?q?feat:=20=EC=BF=A0=ED=82=A4=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1/=EB=A7=8C=EB=A3=8C=20=EA=B3=B5=ED=86=B5=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=20CookieUtil=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/global/auth/utils/CookieUtil.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/global/auth/utils/CookieUtil.java 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..5cc4a54a --- /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("/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("/auth") + .maxAge(0) + .sameSite("Strict") + .build(); + } +} From dac2794be1544f87b9dcf5ae4e463ffbc0689756 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 18:00:35 +0900 Subject: [PATCH 054/383] =?UTF-8?q?refactor:=20OAuthSuccessHandler=20?= =?UTF-8?q?=EC=BF=A0=ED=82=A4=20=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=9D=84=20CookieUtil=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/handler/OAuthSuccessHandler.java | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) 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 29d81d55..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; @@ -45,23 +44,12 @@ public void onAuthenticationSuccess( 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), From aa763a9a9e513bf1633904d5d0c83889dd744e0a Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 18:18:52 +0900 Subject: [PATCH 055/383] =?UTF-8?q?feat:=20AccessToken=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20API=20=EA=B5=AC=ED=98=84=20(refreshToken?= =?UTF-8?q?=20rotation=20=EC=A0=81=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 39 +++++++++++++++++++ .../Timo/global/auth/service/AuthService.java | 1 + 2 files changed, 40 insertions(+) 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..14f54c28 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,8 +1,12 @@ package com.Timo.Timo.global.auth.controller; +import com.Timo.Timo.global.auth.dto.ReissueResult; import com.Timo.Timo.global.auth.dto.response.AuthTokenResponse; import com.Timo.Timo.global.auth.exception.AuthSuccessCode; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; import com.Timo.Timo.global.auth.service.AuthService; +import com.Timo.Timo.global.auth.utils.CookieUtil; +import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; import com.Timo.Timo.global.response.BaseResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -10,7 +14,11 @@ import io.swagger.v3.oas.annotations.tags.Tag; import java.util.Map; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; 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.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -23,6 +31,10 @@ public class AuthController { private final AuthService authService; + private final JwtTokenProvider jwtTokenProvider; + + @Value("${app.auth.cookie-secure}") + private boolean cookieSecure; @Operation(summary = "AccessToken 발급", description = "1회성 code로 AccessToken을 발급합니다.") @ApiResponses({ @@ -43,4 +55,31 @@ public ResponseEntity> token( .header("Cache-Control", "no-store") .body(BaseResponse.onSuccess(AuthSuccessCode.LOGIN_SUCCESS, authTokenResponse)); } + + @Operation(summary = "AccessToken 재발급", description = "RefreshToken으로 AccessToken을 재발급합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "재발급 성공"), + @ApiResponse(responseCode = "401", description = "유효하지 않거나 만료된 리프레시 토큰"), + @ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + @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 ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, + CookieUtil.createCookie("refreshToken", result.getRefreshToken(), + jwtTokenProvider.getRefreshTokenExpiry(), cookieSecure).toString()) + .header(HttpHeaders.SET_COOKIE, + CookieUtil.createCookie("sessionId", result.getSessionId(), + jwtTokenProvider.getRefreshTokenExpiry(), cookieSecure).toString()) + .header("Cache-Control", "no-store") + .body(BaseResponse.onSuccess(AuthSuccessCode.REISSUE_SUCCESS, + Map.of("accessToken", result.getAccessToken()))); + } + + } \ No newline at end of file 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 00a9008e..0398dd6d 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 @@ -77,4 +77,5 @@ public ReissueResult reissue(String refreshToken, String sessionId) { return new ReissueResult(newAccessToken, newRefreshToken, newSessionId); } + } From 7ab9ed70d4b665ee69a2010ab9bfaaddfacb325b Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 18:33:59 +0900 Subject: [PATCH 056/383] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 19 +++++++++++++++++++ .../Timo/global/auth/service/AuthService.java | 3 +++ 2 files changed, 22 insertions(+) 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 14f54c28..ac3f85c6 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 @@ -81,5 +81,24 @@ public ResponseEntity>> reissue( Map.of("accessToken", result.getAccessToken()))); } + @Operation(summary = "로그아웃", description = "현재 세션을 로그아웃합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "로그아웃 성공"), + @ApiResponse(responseCode = "401", description = "인증이 필요합니다"), + @ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + @PostMapping("/logout") + public ResponseEntity> logout( + @AuthenticationPrincipal CustomUserDetails userDetails, + @CookieValue(name = "sessionId", required = false) String sessionId + ) { + authService.logout(userDetails.getUser().getId(), sessionId); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, + CookieUtil.expireCookie("refreshToken", cookieSecure).toString()) + .header(HttpHeaders.SET_COOKIE, + CookieUtil.expireCookie("sessionId", cookieSecure).toString()) + .body(BaseResponse.onSuccess(AuthSuccessCode.LOGOUT_SUCCESS, null)); + } } \ No newline at end of file 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 0398dd6d..10c7f190 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 @@ -78,4 +78,7 @@ public ReissueResult reissue(String refreshToken, String sessionId) { return new ReissueResult(newAccessToken, newRefreshToken, newSessionId); } + public void logout(Long userId, String sessionId) { + refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); + } } From 88358f98adc74ab2058898ea3cc942a69112dbd6 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 18:34:46 +0900 Subject: [PATCH 057/383] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 24 +++++++++++++++++++ .../Timo/global/auth/service/AuthService.java | 11 +++++++++ 2 files changed, 35 insertions(+) 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 ac3f85c6..049400be 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 @@ -19,6 +19,7 @@ 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; @@ -101,4 +102,27 @@ public ResponseEntity> logout( CookieUtil.expireCookie("sessionId", cookieSecure).toString()) .body(BaseResponse.onSuccess(AuthSuccessCode.LOGOUT_SUCCESS, null)); } + + @Operation(summary = "회원 탈퇴", description = "회원 탈퇴 및 모든 데이터를 영구 삭제합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "회원 탈퇴 성공"), + @ApiResponse(responseCode = "401", description = "인증이 필요합니다"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 사용자"), + @ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + + @DeleteMapping("/withdraw") + public ResponseEntity> withdraw( + @AuthenticationPrincipal CustomUserDetails userDetails, + @CookieValue(name = "sessionId", required = false) String sessionId + ) { + authService.withdraw(userDetails.getUser().getId(), sessionId); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, + CookieUtil.expireCookie("refreshToken", cookieSecure).toString()) + .header(HttpHeaders.SET_COOKIE, + CookieUtil.expireCookie("sessionId", cookieSecure).toString()) + .body(BaseResponse.onSuccess(AuthSuccessCode.WITHDRAW_SUCCESS, null)); + } } \ No newline at end of file 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 10c7f190..fd6922e4 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 @@ -81,4 +81,15 @@ public ReissueResult reissue(String refreshToken, String sessionId) { public void logout(Long userId, String sessionId) { refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); } + + public void withdraw(Long userId, String sessionId) { + if (sessionId != null) { + refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); + } + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + userRepository.delete(user); + } } From 7bcd387e817ed70fbfc2580db04fe7e142551436 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 18:37:09 +0900 Subject: [PATCH 058/383] =?UTF-8?q?refactor:=20Auth=20API=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/global/auth/controller/AuthController.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 049400be..3c4e6a8c 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 @@ -27,7 +27,7 @@ @Tag(name = "Auth", description = "인증 관련 API") @RestController -@RequestMapping("/api/v1/auth") +@RequestMapping("/api/v1") @RequiredArgsConstructor public class AuthController { @@ -45,7 +45,7 @@ public class AuthController { @ApiResponse(responseCode = "404", description = "존재하지 않는 사용자"), @ApiResponse(responseCode = "500", description = "서버 내부 오류") }) - @PostMapping("/token") + @PostMapping("/auth/token") public ResponseEntity> token( @RequestBody Map body ) { @@ -63,7 +63,7 @@ public ResponseEntity> token( @ApiResponse(responseCode = "401", description = "유효하지 않거나 만료된 리프레시 토큰"), @ApiResponse(responseCode = "500", description = "서버 내부 오류") }) - @PostMapping("/reissue") + @PostMapping("/auth/reissue") public ResponseEntity>> reissue( @CookieValue(name = "refreshToken", required=false) String refreshToken, @CookieValue(name = "sessionId", required = false) String sessionId @@ -88,7 +88,7 @@ public ResponseEntity>> reissue( @ApiResponse(responseCode = "401", description = "인증이 필요합니다"), @ApiResponse(responseCode = "500", description = "서버 내부 오류") }) - @PostMapping("/logout") + @PostMapping("/auth/logout") public ResponseEntity> logout( @AuthenticationPrincipal CustomUserDetails userDetails, @CookieValue(name = "sessionId", required = false) String sessionId @@ -111,7 +111,7 @@ public ResponseEntity> logout( @ApiResponse(responseCode = "500", description = "서버 내부 오류") }) - @DeleteMapping("/withdraw") + @DeleteMapping("/users") public ResponseEntity> withdraw( @AuthenticationPrincipal CustomUserDetails userDetails, @CookieValue(name = "sessionId", required = false) String sessionId From f4e781ee64f2b31a5738929a4d5498ede65e755e Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 21:21:52 +0900 Subject: [PATCH 059/383] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=EB=90=9C=20accessToken=20=EB=B8=94=EB=9E=99=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B4=80=EB=A6=AC=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20BlacklistService=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/service/BlackListService.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java 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..32be323d --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java @@ -0,0 +1,28 @@ +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){ + redisTemplate.opsForValue().set( + KEY_PREFIX + accessToken, + "logout", + remainingExpiry, + TimeUnit.MILLISECONDS + ); + } + + public boolean isBlackListed(String accessToken){ + return Boolean.TRUE.equals(redisTemplate.hasKey(KEY_PREFIX + accessToken)); + } +} From a5d739618763c745f4efcfa9120de2c6b9654a59 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 21:26:36 +0900 Subject: [PATCH 060/383] =?UTF-8?q?feat:=20JwtTokenProvider=EC=97=90=20get?= =?UTF-8?q?RemainingExpiry=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/service/BlackListService.java | 2 +- .../com/Timo/Timo/global/jwt/provider/JwtTokenProvider.java | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) 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 index 32be323d..d13f91bb 100644 --- a/src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java +++ b/src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java @@ -18,7 +18,7 @@ public void addToBlackList(String accessToken, long remainingExpiry){ KEY_PREFIX + accessToken, "logout", remainingExpiry, - TimeUnit.MILLISECONDS + TimeUnit.SECONDS ); } 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; + } } From 75812e404d98594b2393fd5c753a754579f5711d Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 21:31:30 +0900 Subject: [PATCH 061/383] =?UTF-8?q?feat:=20JwtAuthenticationFilter?= =?UTF-8?q?=EC=97=90=20=EB=B8=94=EB=9E=99=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/global/jwt/filter/JwtAuthenticationFilter.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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..a158f52d 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,7 @@ 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.jwt.provider.JwtTokenProvider; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -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( @@ -33,7 +35,9 @@ protected void doFilterInternal( String token = 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()); From de40dcccc5756e256bae9b08d383d66b967cb993 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 21:43:24 +0900 Subject: [PATCH 062/383] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=8B=9C=20Access=20Token=20=EB=B8=94=EB=9E=99?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/controller/AuthController.java | 16 ++++++++++++++-- .../Timo/global/auth/service/AuthService.java | 7 ++++++- 2 files changed, 20 insertions(+), 3 deletions(-) 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 3c4e6a8c..db776068 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 @@ -12,12 +12,14 @@ 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 jakarta.servlet.http.HttpServletRequest; import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -91,9 +93,11 @@ public ResponseEntity>> reissue( @PostMapping("/auth/logout") public ResponseEntity> logout( @AuthenticationPrincipal CustomUserDetails userDetails, - @CookieValue(name = "sessionId", required = false) String sessionId + @CookieValue(name = "sessionId", required = false) String sessionId, + HttpServletRequest request ) { - authService.logout(userDetails.getUser().getId(), sessionId); + String accessToken = resolveToken(request); + authService.logout(accessToken, userDetails.getUser().getId(), sessionId); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, @@ -125,4 +129,12 @@ public ResponseEntity> withdraw( CookieUtil.expireCookie("sessionId", cookieSecure).toString()) .body(BaseResponse.onSuccess(AuthSuccessCode.WITHDRAW_SUCCESS, null)); } + + private String resolveToken(HttpServletRequest request) { + String bearer = request.getHeader("Authorization"); + if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) { + return bearer.substring(7); + } + return null; + } } \ No newline at end of file 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 fd6922e4..ad0451ec 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 @@ -20,6 +20,7 @@ public class AuthService { private final JwtTokenProvider jwtTokenProvider; private final UserRepository userRepository; private final RefreshTokenService refreshTokenService; + private final BlackListService blackListService; public AuthTokenResponse exchangeCodeForToken(String code) { @@ -78,7 +79,11 @@ public ReissueResult reissue(String refreshToken, String sessionId) { return new ReissueResult(newAccessToken, newRefreshToken, newSessionId); } - public void logout(Long userId, String sessionId) { + public void logout(String accessToken, Long userId, String sessionId) { + if(accessToken !=null){ + long remainingExpiry = jwtTokenProvider.getRemainingExpiry(accessToken); + blackListService.addToBlackList(accessToken, remainingExpiry); + } refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); } From 5ad3dbb847b277da1d2b3802c266f9ad9137b543 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 21:48:07 +0900 Subject: [PATCH 063/383] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EC=8B=9C=20Access=20Token=20=EB=B8=94=EB=9E=99?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/controller/AuthController.java | 6 ++++-- .../Timo/global/auth/service/AuthService.java | 18 +++++++++++++----- .../global/auth/service/BlackListService.java | 2 +- 3 files changed, 18 insertions(+), 8 deletions(-) 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 db776068..d416d6e6 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 @@ -118,9 +118,11 @@ public ResponseEntity> logout( @DeleteMapping("/users") public ResponseEntity> withdraw( @AuthenticationPrincipal CustomUserDetails userDetails, - @CookieValue(name = "sessionId", required = false) String sessionId + @CookieValue(name = "sessionId", required = false) String sessionId, + HttpServletRequest request ) { - authService.withdraw(userDetails.getUser().getId(), sessionId); + String accessToken = resolveToken(request); + authService.withdraw(accessToken, userDetails.getUser().getId(), sessionId); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, 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 ad0451ec..f85596cc 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 @@ -20,7 +20,7 @@ public class AuthService { private final JwtTokenProvider jwtTokenProvider; private final UserRepository userRepository; private final RefreshTokenService refreshTokenService; - private final BlackListService blackListService; + private final BlackListService blacklistService; public AuthTokenResponse exchangeCodeForToken(String code) { @@ -80,14 +80,22 @@ public ReissueResult reissue(String refreshToken, String sessionId) { } public void logout(String accessToken, Long userId, String sessionId) { - if(accessToken !=null){ + if (accessToken != null) { long remainingExpiry = jwtTokenProvider.getRemainingExpiry(accessToken); - blackListService.addToBlackList(accessToken, remainingExpiry); + blacklistService.addToBlacklist(accessToken, remainingExpiry); + } + + if (sessionId != null) { + refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); } - refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); } - public void withdraw(Long userId, String sessionId) { + public void withdraw(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); } 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 index d13f91bb..f20b1277 100644 --- a/src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java +++ b/src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java @@ -13,7 +13,7 @@ public class BlackListService { private static final String KEY_PREFIX = "blacklist:"; - public void addToBlackList(String accessToken, long remainingExpiry){ + public void addToBlacklist(String accessToken, long remainingExpiry){ redisTemplate.opsForValue().set( KEY_PREFIX + accessToken, "logout", From 8ba1b3ca7b97098b13855bdb4d0caf293a516d31 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 21:49:23 +0900 Subject: [PATCH 064/383] =?UTF-8?q?fix:=20JwtAuthenticationFilter=20?= =?UTF-8?q?=EB=B8=94=EB=9E=99=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/global/jwt/filter/JwtAuthenticationFilter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a158f52d..56e11a1e 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 @@ -37,7 +37,7 @@ protected void doFilterInternal( if(token!=null && jwtTokenProvider.validateAccessToken(token) - && blacklistService.isBlackListed(token)){ + && !blacklistService.isBlackListed(token)){ Long userId = jwtTokenProvider.getUserId(token); userRepository.findById(userId).ifPresent(user -> { CustomUserDetails userDetails = new CustomUserDetails(user, Map.of()); From 4dc033b46c8ba8c514396b9faa93ef7f97958776 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 21:55:48 +0900 Subject: [PATCH 065/383] =?UTF-8?q?refactor:=20resolveToken=EC=9D=84=20Tok?= =?UTF-8?q?enExtractor=20=EC=9C=A0=ED=8B=B8=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/controller/AuthController.java | 15 ++++----------- .../Timo/global/auth/utils/TokenExtractor.java | 15 +++++++++++++++ .../jwt/filter/JwtAuthenticationFilter.java | 12 ++---------- 3 files changed, 21 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/global/auth/utils/TokenExtractor.java 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 d416d6e6..d0e415fa 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 @@ -6,6 +6,7 @@ import com.Timo.Timo.global.auth.principal.CustomUserDetails; import com.Timo.Timo.global.auth.service.AuthService; import com.Timo.Timo.global.auth.utils.CookieUtil; +import com.Timo.Timo.global.auth.utils.TokenExtractor; import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; import com.Timo.Timo.global.response.BaseResponse; import io.swagger.v3.oas.annotations.Operation; @@ -96,7 +97,7 @@ public ResponseEntity> logout( @CookieValue(name = "sessionId", required = false) String sessionId, HttpServletRequest request ) { - String accessToken = resolveToken(request); + String accessToken = TokenExtractor.resolveToken(request); authService.logout(accessToken, userDetails.getUser().getId(), sessionId); return ResponseEntity.ok() @@ -121,7 +122,7 @@ public ResponseEntity> withdraw( @CookieValue(name = "sessionId", required = false) String sessionId, HttpServletRequest request ) { - String accessToken = resolveToken(request); + String accessToken = TokenExtractor.resolveToken(request); authService.withdraw(accessToken, userDetails.getUser().getId(), sessionId); return ResponseEntity.ok() @@ -131,12 +132,4 @@ public ResponseEntity> withdraw( CookieUtil.expireCookie("sessionId", cookieSecure).toString()) .body(BaseResponse.onSuccess(AuthSuccessCode.WITHDRAW_SUCCESS, null)); } - - private String resolveToken(HttpServletRequest request) { - String bearer = request.getHeader("Authorization"); - if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) { - return bearer.substring(7); - } - return null; - } -} \ No newline at end of file +} 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/jwt/filter/JwtAuthenticationFilter.java b/src/main/java/com/Timo/Timo/global/jwt/filter/JwtAuthenticationFilter.java index 56e11a1e..2fd9c70d 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 @@ -3,6 +3,7 @@ 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; @@ -33,7 +34,7 @@ protected void doFilterInternal( FilterChain filterChain ) throws ServletException, IOException { - String token = resolveToken(request); + String token = TokenExtractor.resolveToken(request); if(token!=null && jwtTokenProvider.validateAccessToken(token) @@ -52,13 +53,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; - } } From 29ad0cf0fcbdf61395259dfc835b35a4ec092f24 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 23:01:45 +0900 Subject: [PATCH 066/383] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20import=20=EB=B0=8F=20=EA=B3=B5=EB=B0=B1=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/controller/AuthController.java | 1 - .../Timo/Timo/global/auth/dto/response/AuthTokenResponse.java | 1 - .../Timo/Timo/global/auth/handler/AuthErrorResponseWriter.java | 1 - .../Timo/global/auth/handler/JwtAuthenticationEntryPoint.java | 2 +- .../com/Timo/Timo/global/auth/principal/CustomUserDetails.java | 1 - .../Timo/Timo/global/jwt/filter/JwtAuthenticationFilter.java | 1 - 6 files changed, 1 insertion(+), 6 deletions(-) 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 d0e415fa..6c6109d9 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 @@ -20,7 +20,6 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PostMapping; 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/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/principal/CustomUserDetails.java b/src/main/java/com/Timo/Timo/global/auth/principal/CustomUserDetails.java index b3434ed2..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 @@ -49,5 +49,4 @@ public String getPassword(){ public String getUsername(){ return user.getEmail(); } - } 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 2fd9c70d..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 @@ -16,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 From 58e38fcd73b09622b346a9047f4558349e74aa80 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 23:09:28 +0900 Subject: [PATCH 067/383] =?UTF-8?q?fix:=20=EC=BF=A0=ED=82=A4=20path?= =?UTF-8?q?=EB=A5=BC=20/api/v1/auth=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/global/auth/service/AuthService.java | 2 +- src/main/java/com/Timo/Timo/global/auth/utils/CookieUtil.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 f85596cc..26a77033 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 @@ -58,7 +58,7 @@ public AuthTokenResponse exchangeCodeForToken(String code) { } public ReissueResult reissue(String refreshToken, String sessionId) { - + if (refreshToken == null || sessionId == null || !jwtTokenProvider.validateRefreshToken(refreshToken)) { throw new CustomException(AuthErrorCode.INVALID_REFRESH_TOKEN); 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 index 5cc4a54a..8f31f74a 100644 --- a/src/main/java/com/Timo/Timo/global/auth/utils/CookieUtil.java +++ b/src/main/java/com/Timo/Timo/global/auth/utils/CookieUtil.java @@ -9,7 +9,7 @@ public static ResponseCookie createCookie(String name, String value, long maxAge return ResponseCookie.from(name, value) .httpOnly(true) .secure(secure) - .path("/auth") + .path("/api/v1/auth") .maxAge(Duration.ofSeconds(maxAgeSeconds)) .sameSite("Strict") .build(); @@ -19,7 +19,7 @@ public static ResponseCookie expireCookie(String name, boolean secure) { return ResponseCookie.from(name, "") .httpOnly(true) .secure(secure) - .path("/auth") + .path("/api/v1/auth") .maxAge(0) .sameSite("Strict") .build(); From af6d03de4bcf5aafa13054de241b3693ac7baa71 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 23:09:52 +0900 Subject: [PATCH 068/383] =?UTF-8?q?fix:=20=ED=83=88=ED=87=B4=20API=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/global/auth/controller/AuthController.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 6c6109d9..858bc85b 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 @@ -29,7 +29,7 @@ @Tag(name = "Auth", description = "인증 관련 API") @RestController -@RequestMapping("/api/v1") +@RequestMapping("/api/v1/auth") @RequiredArgsConstructor public class AuthController { @@ -47,7 +47,7 @@ public class AuthController { @ApiResponse(responseCode = "404", description = "존재하지 않는 사용자"), @ApiResponse(responseCode = "500", description = "서버 내부 오류") }) - @PostMapping("/auth/token") + @PostMapping("/token") public ResponseEntity> token( @RequestBody Map body ) { @@ -65,7 +65,7 @@ public ResponseEntity> token( @ApiResponse(responseCode = "401", description = "유효하지 않거나 만료된 리프레시 토큰"), @ApiResponse(responseCode = "500", description = "서버 내부 오류") }) - @PostMapping("/auth/reissue") + @PostMapping("/reissue") public ResponseEntity>> reissue( @CookieValue(name = "refreshToken", required=false) String refreshToken, @CookieValue(name = "sessionId", required = false) String sessionId @@ -90,7 +90,7 @@ public ResponseEntity>> reissue( @ApiResponse(responseCode = "401", description = "인증이 필요합니다"), @ApiResponse(responseCode = "500", description = "서버 내부 오류") }) - @PostMapping("/auth/logout") + @PostMapping("/logout") public ResponseEntity> logout( @AuthenticationPrincipal CustomUserDetails userDetails, @CookieValue(name = "sessionId", required = false) String sessionId, @@ -115,7 +115,7 @@ public ResponseEntity> logout( @ApiResponse(responseCode = "500", description = "서버 내부 오류") }) - @DeleteMapping("/users") + @DeleteMapping("/withdraw") public ResponseEntity> withdraw( @AuthenticationPrincipal CustomUserDetails userDetails, @CookieValue(name = "sessionId", required = false) String sessionId, From 7e69bac790739bf25cf1b967925154a7371a9e40 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 00:15:56 +0900 Subject: [PATCH 069/383] =?UTF-8?q?refactor:=20=ED=83=88=ED=87=B4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=97=90=EC=84=9C=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=A1=B4=EC=9E=AC=20=EA=B2=80=EC=A6=9D=20=EC=88=9C?= =?UTF-8?q?=EC=84=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/service/AuthService.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 26a77033..bd09f6a8 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 @@ -58,7 +58,7 @@ public AuthTokenResponse exchangeCodeForToken(String code) { } public ReissueResult reissue(String refreshToken, String sessionId) { - + if (refreshToken == null || sessionId == null || !jwtTokenProvider.validateRefreshToken(refreshToken)) { throw new CustomException(AuthErrorCode.INVALID_REFRESH_TOKEN); @@ -91,6 +91,10 @@ public void logout(String accessToken, Long userId, String sessionId) { } public void withdraw(String accessToken, Long userId, String sessionId) { + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + if (accessToken != null) { long remainingExpiry = jwtTokenProvider.getRemainingExpiry(accessToken); blacklistService.addToBlacklist(accessToken, remainingExpiry); @@ -100,9 +104,6 @@ public void withdraw(String accessToken, Long userId, String sessionId) { refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); } - User user = userRepository.findById(userId) - .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); - userRepository.delete(user); } } From 49633157b9d3a4dd4de09fc3413693434e9053ce Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 00:18:46 +0900 Subject: [PATCH 070/383] =?UTF-8?q?refactor:=20=ED=83=88=ED=87=B4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/global/auth/service/AuthService.java | 2 ++ 1 file changed, 2 insertions(+) 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 bd09f6a8..d3646954 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 @@ -11,6 +11,7 @@ import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -90,6 +91,7 @@ public void logout(String accessToken, Long userId, String sessionId) { } } + @Transactional public void withdraw(String accessToken, Long userId, String sessionId) { User user = userRepository.findById(userId) From cab3d014af51a3150e77497a56be40d45fb63361 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 00:20:14 +0900 Subject: [PATCH 071/383] =?UTF-8?q?fix:=20=EB=A7=8C=EB=A3=8C=EB=90=9C=20Ac?= =?UTF-8?q?cess=20Token=EC=9D=80=20=EB=B8=94=EB=9E=99=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=97=90=20=EC=A0=80=EC=9E=A5=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/service/BlackListService.java | 5 +++++ 1 file changed, 5 insertions(+) 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 index f20b1277..8e42a136 100644 --- a/src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java +++ b/src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java @@ -14,6 +14,11 @@ public class BlackListService { 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", From 22c5c5546f6ba1060aea94b65bef4d30f119a2fd Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 02:31:51 +0900 Subject: [PATCH 072/383] =?UTF-8?q?refactor:=20Controller=EC=97=90?= =?UTF-8?q?=EC=84=9C=20Swagger=20docs=20=EC=9D=B8=ED=84=B0=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EB=B6=84=EB=A6=AC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 38 +--- .../global/auth/docs/AuthControllerDocs.java | 190 ++++++++++++++++++ 2 files changed, 196 insertions(+), 32 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java 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 858bc85b..3a588fee 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,5 +1,6 @@ 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.response.AuthTokenResponse; import com.Timo.Timo.global.auth.exception.AuthSuccessCode; @@ -9,9 +10,6 @@ import com.Timo.Timo.global.auth.utils.TokenExtractor; import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; 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 jakarta.servlet.http.HttpServletRequest; import java.util.Map; @@ -31,7 +29,7 @@ @RestController @RequestMapping("/api/v1/auth") @RequiredArgsConstructor -public class AuthController { +public class AuthController implements AuthControllerDocs { private final AuthService authService; private final JwtTokenProvider jwtTokenProvider; @@ -39,14 +37,7 @@ public class AuthController { @Value("${app.auth.cookie-secure}") private boolean cookieSecure; - @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 @@ -59,12 +50,7 @@ public ResponseEntity> token( .body(BaseResponse.onSuccess(AuthSuccessCode.LOGIN_SUCCESS, authTokenResponse)); } - @Operation(summary = "AccessToken 재발급", description = "RefreshToken으로 AccessToken을 재발급합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "재발급 성공"), - @ApiResponse(responseCode = "401", description = "유효하지 않거나 만료된 리프레시 토큰"), - @ApiResponse(responseCode = "500", description = "서버 내부 오류") - }) + @Override @PostMapping("/reissue") public ResponseEntity>> reissue( @CookieValue(name = "refreshToken", required=false) String refreshToken, @@ -84,12 +70,7 @@ public ResponseEntity>> reissue( Map.of("accessToken", result.getAccessToken()))); } - @Operation(summary = "로그아웃", description = "현재 세션을 로그아웃합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "로그아웃 성공"), - @ApiResponse(responseCode = "401", description = "인증이 필요합니다"), - @ApiResponse(responseCode = "500", description = "서버 내부 오류") - }) + @Override @PostMapping("/logout") public ResponseEntity> logout( @AuthenticationPrincipal CustomUserDetails userDetails, @@ -107,14 +88,7 @@ public ResponseEntity> logout( .body(BaseResponse.onSuccess(AuthSuccessCode.LOGOUT_SUCCESS, null)); } - @Operation(summary = "회원 탈퇴", description = "회원 탈퇴 및 모든 데이터를 영구 삭제합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "회원 탈퇴 성공"), - @ApiResponse(responseCode = "401", description = "인증이 필요합니다"), - @ApiResponse(responseCode = "404", description = "존재하지 않는 사용자"), - @ApiResponse(responseCode = "500", description = "서버 내부 오류") - }) - + @Override @DeleteMapping("/withdraw") public ResponseEntity> withdraw( @AuthenticationPrincipal CustomUserDetails userDetails, 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..98232929 --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java @@ -0,0 +1,190 @@ +package com.Timo.Timo.global.auth.docs; + +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.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Map; +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으로 교환합니다. + 최초 로그인인 경우 isNewUser가 true로 반환됩니다. + RefreshToken은 Set-Cookie 헤더로 전달됩니다. + """ + ) + @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 Map body + ); + + @Operation( + summary = "AccessToken 재발급", + description = """ + 쿠키로 전달된 RefreshToken과 sessionId를 검증하여 AccessToken을 재발급합니다. + 재발급 성공 시 RefreshToken과 sessionId 쿠키가 갱신됩니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "재발급 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "401", + 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 쿠키를 만료시킵니다. + Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. + """, + security = @SecurityRequirement(name = "bearerAuth") + ) + @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 = """ + 회원 탈퇴를 진행하며, 사용자와 관련된 모든 데이터를 영구 삭제합니다. + Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. + 이 작업은 되돌릴 수 없습니다. + """, + security = @SecurityRequirement(name = "bearerAuth") + ) + @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 + ); +} \ No newline at end of file From 2b87503f04f88796b1863b833072106f83865f5f Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 02:47:06 +0900 Subject: [PATCH 073/383] =?UTF-8?q?fix:=20createdAt=20JSON=20=EC=A7=81?= =?UTF-8?q?=EB=A0=AC=ED=99=94=20=ED=8F=AC=EB=A7=B7=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/Timo/Timo/global/common/BaseTimeEntity.java | 3 +++ 1 file changed, 3 insertions(+) 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..bc4e6703 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 HH:mm:ss") private LocalDateTime createdAt; @LastModifiedDate @Column(name = "updated_at", nullable = false) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime updatedAt; } From 7d15dc7061c868f12fa76234c71334f066001dbb Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 03:18:59 +0900 Subject: [PATCH 074/383] =?UTF-8?q?fix:=20AuthControllerDocs=20token=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=20=ED=83=80=EC=9E=85=20=EB=B6=88=EC=9D=BC=EC=B9=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/global/auth/controller/AuthController.java | 6 +++--- .../Timo/Timo/global/auth/docs/AuthControllerDocs.java | 4 +++- .../Timo/global/auth/dto/request/AuthTokenRequest.java | 10 ++++++++++ 3 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/global/auth/dto/request/AuthTokenRequest.java 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 3a588fee..13ca7a57 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 @@ -2,6 +2,7 @@ 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.AuthTokenResponse; import com.Timo.Timo.global.auth.exception.AuthSuccessCode; import com.Timo.Timo.global.auth.principal.CustomUserDetails; @@ -40,10 +41,9 @@ public class AuthController implements AuthControllerDocs { @Override @PostMapping("/token") public ResponseEntity> token( - @RequestBody Map body + @RequestBody AuthTokenRequest request ) { - String code = body.get("code"); - AuthTokenResponse authTokenResponse = authService.exchangeCodeForToken(code); + AuthTokenResponse authTokenResponse = authService.exchangeCodeForToken(request.code()); return ResponseEntity.ok() .header("Cache-Control", "no-store") 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 index 98232929..44dfd276 100644 --- a/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java +++ b/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java @@ -1,5 +1,6 @@ package com.Timo.Timo.global.auth.docs; +import com.Timo.Timo.global.auth.dto.request.AuthTokenRequest; 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; @@ -26,6 +27,7 @@ public interface AuthControllerDocs { 최초 로그인인 경우 isNewUser가 true로 반환됩니다. RefreshToken은 Set-Cookie 헤더로 전달됩니다. """ + ) @ApiResponses({ @ApiResponse( @@ -67,7 +69,7 @@ public interface AuthControllerDocs { ) }) ResponseEntity> token( - @RequestBody Map body + @RequestBody AuthTokenRequest body ); @Operation( 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..5f180071 --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/dto/request/AuthTokenRequest.java @@ -0,0 +1,10 @@ +package com.Timo.Timo.global.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AuthTokenRequest( + @Schema(description = "로그인 성공 리다이렉트 시 쿼리스트링으로 전달받은 일회용 인가코드", + example = "550e8400-e29b-41d4-a716-446655440000") + String code +) { +} From a1286e0758ac9d6286f1942e1c01fb6cddc5ea32 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 03:26:21 +0900 Subject: [PATCH 075/383] =?UTF-8?q?fix:=20ErrorDto=20timestamp=20Swagger?= =?UTF-8?q?=20=EC=98=88=EC=8B=9C=20=ED=8F=AC=EB=A7=B7=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/Timo/Timo/global/exception/dto/ErrorDto.java | 2 ++ 1 file changed, 2 insertions(+) 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, From d9407de323a12b243bc78302913a899ad052d10c Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 03:41:19 +0900 Subject: [PATCH 076/383] =?UTF-8?q?refactor:=20Swagger=20Auth=20API=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/docs/AuthControllerDocs.java | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) 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 index 44dfd276..64c5b7a5 100644 --- a/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java +++ b/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java @@ -8,6 +8,7 @@ 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; @@ -23,11 +24,27 @@ public interface AuthControllerDocs { @Operation( summary = "AccessToken 발급", description = """ - 1회성 인증 코드(code)를 AccessToken으로 교환합니다. - 최초 로그인인 경우 isNewUser가 true로 반환됩니다. - RefreshToken은 Set-Cookie 헤더로 전달됩니다. + 1회성 인증 코드(code)를 AccessToken으로 교환합니다. + - isNewUser: 이번 요청으로 신규 회원가입이 이루어졌는지 여부입니다. true면 아직 온보딩을 진행한 적이 없는 사용자입니다. + - user.onboardingCompleted: 온보딩(초기 설정) 완료 여부입니다. false면 온보딩 화면으로, true면 홈 화면으로 이동시켜 주세요. + - isNewUser가 true인 경우 user.onboardingCompleted는 항상 false입니다. 기존 가입자가 온보딩을 중단한 경우에도 isNewUser는 false, onboardingCompleted는 false로 반환될 수 있습니다. + - RefreshToken과 sessionId는 Set-Cookie 헤더로 전달되며, 응답 바디에는 포함되지 않습니다. """ - + ) + @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( From ee7fb98938c4ddb3955474e84a2325a4d8bb13b9 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 03:59:19 +0900 Subject: [PATCH 077/383] =?UTF-8?q?refactor:=20AuthReissueResponse=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/controller/AuthController.java | 10 ++++++---- .../global/auth/docs/AuthControllerDocs.java | 18 ++++++++++-------- .../auth/dto/response/AuthReissueResponse.java | 13 +++++++++++++ 3 files changed, 29 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/global/auth/dto/response/AuthReissueResponse.java 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 13ca7a57..73774fce 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 @@ -3,6 +3,7 @@ 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.principal.CustomUserDetails; @@ -13,7 +14,6 @@ import com.Timo.Timo.global.response.BaseResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; -import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; @@ -52,11 +52,14 @@ public ResponseEntity> token( @Override @PostMapping("/reissue") - public ResponseEntity>> reissue( + public ResponseEntity> reissue( @CookieValue(name = "refreshToken", required=false) String refreshToken, @CookieValue(name = "sessionId", required = false) String sessionId ) { ReissueResult result = authService.reissue(refreshToken, sessionId); + AuthReissueResponse authReissueResponse = AuthReissueResponse.builder() + .accessToken(result.getAccessToken()) + .build(); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, @@ -66,8 +69,7 @@ public ResponseEntity>> reissue( CookieUtil.createCookie("sessionId", result.getSessionId(), jwtTokenProvider.getRefreshTokenExpiry(), cookieSecure).toString()) .header("Cache-Control", "no-store") - .body(BaseResponse.onSuccess(AuthSuccessCode.REISSUE_SUCCESS, - Map.of("accessToken", result.getAccessToken()))); + .body(BaseResponse.onSuccess(AuthSuccessCode.REISSUE_SUCCESS, authReissueResponse)); } @Override 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 index 64c5b7a5..9e4c31b6 100644 --- a/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java +++ b/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java @@ -1,6 +1,7 @@ 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; @@ -24,11 +25,11 @@ public interface AuthControllerDocs { @Operation( summary = "AccessToken 발급", description = """ - 1회성 인증 코드(code)를 AccessToken으로 교환합니다. - - isNewUser: 이번 요청으로 신규 회원가입이 이루어졌는지 여부입니다. true면 아직 온보딩을 진행한 적이 없는 사용자입니다. - - user.onboardingCompleted: 온보딩(초기 설정) 완료 여부입니다. false면 온보딩 화면으로, true면 홈 화면으로 이동시켜 주세요. - - isNewUser가 true인 경우 user.onboardingCompleted는 항상 false입니다. 기존 가입자가 온보딩을 중단한 경우에도 isNewUser는 false, onboardingCompleted는 false로 반환될 수 있습니다. - - RefreshToken과 sessionId는 Set-Cookie 헤더로 전달되며, 응답 바디에는 포함되지 않습니다. + 1회성 인증 코드(code)를 AccessToken으로 교환 + - isNewUser: 이번 요청으로 신규 회원가입이 이루어졌는지 여부 (true면 아직 온보딩을 진행한 적이 없는 사용자) + - user.onboardingCompleted: 온보딩 완료 여부 (false면 온보딩 화면으로, true면 홈 화면으로 이동) + - 기존 가입자가 온보딩을 중단한 경우에도 isNewUser는 false, onboardingCompleted는 false로 반환될 수 가능 + - RefreshToken과 sessionId는 Set-Cookie 헤더로 전달되며, 응답 바디에는 포함되지 않음 """ ) @io.swagger.v3.oas.annotations.parameters.RequestBody( @@ -92,9 +93,10 @@ ResponseEntity> token( @Operation( summary = "AccessToken 재발급", description = """ - 쿠키로 전달된 RefreshToken과 sessionId를 검증하여 AccessToken을 재발급합니다. - 재발급 성공 시 RefreshToken과 sessionId 쿠키가 갱신됩니다. + 쿠키로 전달된 RefreshToken과 sessionId를 검증하여 AccessToken 재발급 + 재발급 성공 시 RefreshToken과 sessionId 쿠키 갱신 """ + ) @ApiResponses({ @ApiResponse( @@ -119,7 +121,7 @@ ResponseEntity> token( ) ) }) - ResponseEntity>> reissue( + ResponseEntity> reissue( @CookieValue(name = "refreshToken", required = false) String refreshToken, @CookieValue(name = "sessionId", required = false) String sessionId ); 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; +} From 98c5de103ccbcacea82ccff3c6f7e7aa6b03b572 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 04:58:18 +0900 Subject: [PATCH 078/383] =?UTF-8?q?feat:=20code=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=EC=97=90=20=EC=9E=85=EB=A0=A5=20=EA=B2=80=EC=A6=9D=20=EC=96=B4?= =?UTF-8?q?=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/dto/request/AuthTokenRequest.java | 2 ++ 1 file changed, 2 insertions(+) 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 index 5f180071..1aafaa47 100644 --- 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 @@ -1,10 +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 ) { } From e924ca72b3b3f5151f499328f79bb0093308aece Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 14:27:44 +0900 Subject: [PATCH 079/383] =?UTF-8?q?refactor:=20=EC=BF=A0=ED=82=A4/?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=A1=B0=EB=A6=BD=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=9D=84=20AuthResponseFactory=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 40 ++--------- .../auth/factory/AuthResponseFactory.java | 68 +++++++++++++++++++ 2 files changed, 74 insertions(+), 34 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/global/auth/factory/AuthResponseFactory.java 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 73774fce..54cb81e1 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 @@ -5,18 +5,15 @@ 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.CookieUtil; import com.Timo.Timo.global.auth.utils.TokenExtractor; -import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; import com.Timo.Timo.global.response.BaseResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.CookieValue; @@ -33,7 +30,7 @@ public class AuthController implements AuthControllerDocs { private final AuthService authService; - private final JwtTokenProvider jwtTokenProvider; + private final AuthResponseFactory authResponseFactory; @Value("${app.auth.cookie-secure}") private boolean cookieSecure; @@ -44,10 +41,7 @@ public ResponseEntity> token( @RequestBody AuthTokenRequest request ) { AuthTokenResponse authTokenResponse = authService.exchangeCodeForToken(request.code()); - - return ResponseEntity.ok() - .header("Cache-Control", "no-store") - .body(BaseResponse.onSuccess(AuthSuccessCode.LOGIN_SUCCESS, authTokenResponse)); + return authResponseFactory.tokenResponse(authTokenResponse); } @Override @@ -57,19 +51,7 @@ public ResponseEntity> reissue( @CookieValue(name = "sessionId", required = false) String sessionId ) { ReissueResult result = authService.reissue(refreshToken, sessionId); - AuthReissueResponse authReissueResponse = AuthReissueResponse.builder() - .accessToken(result.getAccessToken()) - .build(); - - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, - CookieUtil.createCookie("refreshToken", result.getRefreshToken(), - jwtTokenProvider.getRefreshTokenExpiry(), cookieSecure).toString()) - .header(HttpHeaders.SET_COOKIE, - CookieUtil.createCookie("sessionId", result.getSessionId(), - jwtTokenProvider.getRefreshTokenExpiry(), cookieSecure).toString()) - .header("Cache-Control", "no-store") - .body(BaseResponse.onSuccess(AuthSuccessCode.REISSUE_SUCCESS, authReissueResponse)); + return authResponseFactory.reissueResponse(result); } @Override @@ -82,12 +64,7 @@ public ResponseEntity> logout( String accessToken = TokenExtractor.resolveToken(request); authService.logout(accessToken, userDetails.getUser().getId(), sessionId); - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, - CookieUtil.expireCookie("refreshToken", cookieSecure).toString()) - .header(HttpHeaders.SET_COOKIE, - CookieUtil.expireCookie("sessionId", cookieSecure).toString()) - .body(BaseResponse.onSuccess(AuthSuccessCode.LOGOUT_SUCCESS, null)); + return authResponseFactory.logoutResponse(); } @Override @@ -100,11 +77,6 @@ public ResponseEntity> withdraw( String accessToken = TokenExtractor.resolveToken(request); authService.withdraw(accessToken, userDetails.getUser().getId(), sessionId); - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, - CookieUtil.expireCookie("refreshToken", cookieSecure).toString()) - .header(HttpHeaders.SET_COOKIE, - CookieUtil.expireCookie("sessionId", cookieSecure).toString()) - .body(BaseResponse.onSuccess(AuthSuccessCode.WITHDRAW_SUCCESS, null)); + return authResponseFactory.withdrawResponse(); } } 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..03208c03 --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/factory/AuthResponseFactory.java @@ -0,0 +1,68 @@ +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()) + .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(); + } +} + From 6139c544937692972744eeea6006202420ae3f53 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 14:28:45 +0900 Subject: [PATCH 080/383] =?UTF-8?q?fix:=20token=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=97=90=20@Valid=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/controller/AuthController.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 54cb81e1..3a4304d0 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 @@ -12,6 +12,7 @@ import com.Timo.Timo.global.response.BaseResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; @@ -38,7 +39,7 @@ public class AuthController implements AuthControllerDocs { @Override @PostMapping("/token") public ResponseEntity> token( - @RequestBody AuthTokenRequest request + @Valid @RequestBody AuthTokenRequest request ) { AuthTokenResponse authTokenResponse = authService.exchangeCodeForToken(request.code()); return authResponseFactory.tokenResponse(authTokenResponse); From 906e7ac12bf56657e1f83e1f6e197d39b20593f7 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 14:42:19 +0900 Subject: [PATCH 081/383] =?UTF-8?q?fix:=20=ED=83=88=ED=87=B4=20=EC=8B=9C?= =?UTF-8?q?=20=EC=A0=84=EC=B2=B4=20=EC=84=B8=EC=85=98=20refresh=20token=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=B0=8F=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?=EC=8B=9C=20=EC=9C=A0=EC=A0=80=20=EC=A1=B4=EC=9E=AC=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/global/auth/docs/AuthControllerDocs.java | 9 +++++++++ .../com/Timo/Timo/global/auth/service/AuthService.java | 8 +++++--- .../Timo/global/auth/service/RefreshTokenService.java | 8 ++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) 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 index 9e4c31b6..b941270f 100644 --- a/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java +++ b/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java @@ -112,6 +112,14 @@ ResponseEntity> token( schema = @Schema(implementation = ErrorDto.class) ) ), + @ApiResponse( + responseCode = "404", + description = "RefreshToken에 해당하는 사용자를 찾을 수 없는 경우 (탈퇴 등으로 삭제된 사용자)", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), @ApiResponse( responseCode = "500", description = "서버 내부 오류", @@ -203,6 +211,7 @@ ResponseEntity> logout( ) ) }) + ResponseEntity> withdraw( @Parameter(hidden = true) CustomUserDetails userDetails, @CookieValue(name = "sessionId", required = false) String sessionId, 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 d3646954..5b24c793 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 @@ -67,6 +67,10 @@ public ReissueResult reissue(String refreshToken, String sessionId) { 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); } @@ -102,9 +106,7 @@ public void withdraw(String accessToken, Long userId, String sessionId) { blacklistService.addToBlacklist(accessToken, remainingExpiry); } - if (sessionId != null) { - refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); - } + refreshTokenService.deleteAllRefreshTokens(String.valueOf(userId)); userRepository.delete(user); } 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..0c994cb2 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 @@ -2,6 +2,7 @@ import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; import java.util.Objects; +import java.util.Set; import java.util.UUID; import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; @@ -36,6 +37,13 @@ public void deleteRefreshToken(String userId, String sessionId) { redisTemplate.delete(KEY_PREFIX + userId + ":" + sessionId); } + public void deleteAllRefreshTokens(String userId) { + Set keys = redisTemplate.keys(KEY_PREFIX + userId + ":*"); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + } + public boolean isRefreshTokenValid(String userId, String sessionId, String refreshToken) { return Objects.equals(refreshToken, getRefreshToken(userId, sessionId)); } From c35a1a749177a08fb180a694d3b594c387767c12 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 14:51:13 +0900 Subject: [PATCH 082/383] =?UTF-8?q?chore:=20AuthController=EC=9D=98=20?= =?UTF-8?q?=EB=AF=B8=EC=82=AC=EC=9A=A9=20cookieSecure=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/controller/AuthController.java | 3 --- 1 file changed, 3 deletions(-) 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 3a4304d0..cbc5ca49 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 @@ -33,9 +33,6 @@ public class AuthController implements AuthControllerDocs { private final AuthService authService; private final AuthResponseFactory authResponseFactory; - @Value("${app.auth.cookie-secure}") - private boolean cookieSecure; - @Override @PostMapping("/token") public ResponseEntity> token( From b18721915d3a8437b13e989c514f0a3e74b2c1d1 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 15:07:52 +0900 Subject: [PATCH 083/383] =?UTF-8?q?feat:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20enum=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/timer/enums/TimerStatus.java | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/timer/enums/TimerStatus.java 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 +} From 713cd71e44809cac0420c25153fb10f772a96727 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 15:42:11 +0900 Subject: [PATCH 084/383] =?UTF-8?q?feat:=20timer=5Frecord=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/timer/entity/TimerRecord.java | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java 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..f75188f3 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java @@ -0,0 +1,111 @@ +package com.Timo.Timo.domain.timer.entity; + +import com.Timo.Timo.domain.timer.enums.TimerStatus; +import com.Timo.Timo.domain.user.entity.User; +import jakarta.persistence.CascadeType; +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.OneToMany; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +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_minutes", nullable = false) + private Integer plannedMinutes; + + @Column(name = "extended_minutes", nullable = false) + private Integer extendedMinutes; + + @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; + + @OneToMany(mappedBy = "timerRecord", cascade = CascadeType.ALL, orphanRemoval = true) + private final List sessions = new ArrayList<>(); + + @Builder + private TimerRecord(User user, Todo todo, Integer plannedMinutes, LocalDateTime startedAt) { + this.user = user; + this.todo = todo; + this.plannedMinutes = plannedMinutes; + this.startedAt = startedAt; + this.extendedMinutes = 0; + this.status = TimerStatus.RUNNING; + } + + public void pause(){ + this.status = TimerStatus.PAUSED; + } + + public void resume() { + this.status = TimerStatus.RUNNING; + } + + public void addSession(TimerSession session) { + this.sessions.add(session); + } + + 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; + } +} From 43e83518670c197f335d03c7471f4517d20819a3 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 15:42:24 +0900 Subject: [PATCH 085/383] =?UTF-8?q?feat:=20timer=5Fsession=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/timer/entity/TimerSession.java | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/timer/entity/TimerSession.java 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..23634fdd --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/entity/TimerSession.java @@ -0,0 +1,52 @@ +package com.Timo.Timo.domain.timer.entity; + +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){ + this.pausedAt = pausedAt; + } + + public boolean isActive(){ + return this.pausedAt == null; + } +} From aa58c5a54320d4a92addbfeb1c8f272bd28c4635 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 15:44:20 +0900 Subject: [PATCH 086/383] =?UTF-8?q?fix:=20timer=5Frecord=20=EC=96=91?= =?UTF-8?q?=EB=B0=A9=ED=96=A5=20=EB=A7=A4=ED=95=91=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/timer/entity/TimerRecord.java | 11 ----------- 1 file changed, 11 deletions(-) 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 index f75188f3..7f0daddf 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java +++ b/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java @@ -2,7 +2,6 @@ import com.Timo.Timo.domain.timer.enums.TimerStatus; import com.Timo.Timo.domain.user.entity.User; -import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EntityListeners; @@ -14,11 +13,8 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -72,9 +68,6 @@ public class TimerRecord { @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; - @OneToMany(mappedBy = "timerRecord", cascade = CascadeType.ALL, orphanRemoval = true) - private final List sessions = new ArrayList<>(); - @Builder private TimerRecord(User user, Todo todo, Integer plannedMinutes, LocalDateTime startedAt) { this.user = user; @@ -93,10 +86,6 @@ public void resume() { this.status = TimerStatus.RUNNING; } - public void addSession(TimerSession session) { - this.sessions.add(session); - } - public boolean isRunning() { return this.status == TimerStatus.RUNNING; } From 1926b421df8834be13ff0f920a09307e7f1eeddb Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 15:47:31 +0900 Subject: [PATCH 087/383] =?UTF-8?q?feat:=20=EC=9E=84=EC=8B=9C=EC=9A=A9=20t?= =?UTF-8?q?odo=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=B6=94=EA=B0=80=20(?= =?UTF-8?q?=ED=83=80=EC=9D=B4=EB=A8=B8=EC=97=90=20=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=95=84=EB=93=9C=EB=A7=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java | 1 + 1 file changed, 1 insertion(+) 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 index 7f0daddf..160f391d 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java +++ b/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java @@ -1,6 +1,7 @@ package com.Timo.Timo.domain.timer.entity; import com.Timo.Timo.domain.timer.enums.TimerStatus; +import com.Timo.Timo.domain.todo.entity.Todo; import com.Timo.Timo.domain.user.entity.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; From 1c72586d422a971a2cf00bc852882a53cb466302 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 15:58:27 +0900 Subject: [PATCH 088/383] =?UTF-8?q?feat:=20timerRecord,=20timerSession=20?= =?UTF-8?q?=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/repository/TimerRecordRepository.java | 12 ++++++++++++ .../timer/repository/TimerSessionRepository.java | 10 ++++++++++ 2 files changed, 22 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java create mode 100644 src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java 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..26cdfbdd --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java @@ -0,0 +1,12 @@ +package com.Timo.Timo.domain.timer.repository; + +import com.Timo.Timo.domain.timer.entity.TimerRecord; +import com.Timo.Timo.domain.timer.enums.TimerStatus; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TimerRecordRepository extends JpaRepository { + + Optional findByUserIdAndStatusIn(Long userId, List statuses); +} 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..fd811590 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java @@ -0,0 +1,10 @@ +package com.Timo.Timo.domain.timer.repository; + +import com.Timo.Timo.domain.timer.entity.TimerSession; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TimerSessionRepository extends JpaRepository { + + Optional findByTimerRecordIdAndPausedAtIsNull(Long timerRecordId); +} From 456ef9e156f262ebdb786424904b4acd089986b9 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 16:19:20 +0900 Subject: [PATCH 089/383] =?UTF-8?q?feat:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20e?= =?UTF-8?q?rrorCode,=20successCode=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/exception/TimerErrorCode.java | 20 +++++++++++++++++++ .../timer/exception/TimerSuccessCode.java | 18 +++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/timer/exception/TimerErrorCode.java create mode 100644 src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java 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..439bf108 --- /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", "요청을 처리할 수 없는 타이머 상태입니다."); + + 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..f542cb8a --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java @@ -0,0 +1,18 @@ +package com.Timo.Timo.domain.timer.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum TimerSuccessCode { + + TIMER_STARTED(HttpStatus.CREATED, "TIMER_201", "타이머가 시작되었습니다."), + TIMER_PAUSED(HttpStatus.OK, "TIMER_200", "타이머가 일시정지되었습니다."), + TIMER_RESUMED(HttpStatus.OK, "TIMER_200", "타이머가 재개되었습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} From f9664d6530b485395758f9bb997ab2318d66f85c Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 16:19:51 +0900 Subject: [PATCH 090/383] =?UTF-8?q?feat:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=20response=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/TimerStartResponse.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerStartResponse.java 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..dcc9addc --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerStartResponse.java @@ -0,0 +1,22 @@ +package com.Timo.Timo.domain.timer.dto.response; + +import com.Timo.Timo.domain.timer.entity.TimerRecord; +import java.time.LocalDateTime; + +public record TimerStartResponse( + Long timerId, + Long todoId, + String status, + Integer plannedMinutes, + LocalDateTime startedAt +) { + public static TimerStartResponse from(TimerRecord timerRecord) { + return new TimerStartResponse( + timerRecord.getId(), + timerRecord.getTodo().getId(), + timerRecord.getStatus().name(), + timerRecord.getPlannedMinutes(), + timerRecord.getStartedAt() + ); + } +} From 399121af26ced3deaeb8a43c5c1ea0066eea0588 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 17:58:14 +0900 Subject: [PATCH 091/383] =?UTF-8?q?refactor:=20=ED=83=80=EC=9D=B4=EB=A8=B8?= =?UTF-8?q?=20SuccessCode=20enum=EC=97=90=20BaseSuccessCode=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/timer/exception/TimerSuccessCode.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 index f542cb8a..dd6eb2f8 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java @@ -1,16 +1,18 @@ 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 { +public enum TimerSuccessCode implements BaseSuccessCode { - TIMER_STARTED(HttpStatus.CREATED, "TIMER_201", "타이머가 시작되었습니다."), TIMER_PAUSED(HttpStatus.OK, "TIMER_200", "타이머가 일시정지되었습니다."), - TIMER_RESUMED(HttpStatus.OK, "TIMER_200", "타이머가 재개되었습니다."); + TIMER_RESUMED(HttpStatus.OK, "TIMER_200", "타이머가 재개되었습니다."), + + TIMER_STARTED(HttpStatus.CREATED, "TIMER_201", "타이머가 시작되었습니다."); private final HttpStatus httpStatus; private final String code; From 6f98a21dba06bd77360f2c3a48dc7b114ea9dab0 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 18:10:40 +0900 Subject: [PATCH 092/383] =?UTF-8?q?feat:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/controller/TimerController.java | 40 ++++++++++ .../timer/docs/TimerControllerDocs.java | 79 +++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java create mode 100644 src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java 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..94ce8e72 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java @@ -0,0 +1,40 @@ +package com.Timo.Timo.domain.timer.controller; + +import com.Timo.Timo.domain.timer.docs.TimerControllerDocs; +import com.Timo.Timo.domain.timer.dto.response.TimerStartResponse; +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 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.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +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 TimerControllerDocs { + + private final TimerService timerService; + + @Override + @PostMapping("/todos/{todoId}/timers/start") + public ResponseEntity> startTimer( + @PathVariable Long todoId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + + Long userId = userDetails.getUser().getId(); + TimerStartResponse response = timerService.startTimer(userId, todoId); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(BaseResponse.onSuccess(TimerSuccessCode.TIMER_STARTED, response)); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java new file mode 100644 index 00000000..207a7fac --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java @@ -0,0 +1,79 @@ +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 io.swagger.v3.oas.annotations.security.SecurityRequirement; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; + +public interface TimerControllerDocs { + + @Operation( + summary = "타이머 시작", + description = """ + 투두의 타이머를 시작합니다. + - 예상 소요 시간(plannedMinutes)은 시작 시점 투두의 estimatedMinutes 값을 저장 + - 한 번에 한 개의 타이머만 실행 가능하며, 이미 실행/일시정지 중인 타이머가 있으면 409 반환 + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "201", + 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 = "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 + ); +} \ No newline at end of file From aa12b263e0e1eaea78dc345ef94cd9a416b56948 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 18:14:38 +0900 Subject: [PATCH 093/383] =?UTF-8?q?feat:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/timer/service/TimerService.java | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java 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..9eb276d9 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -0,0 +1,69 @@ +package com.Timo.Timo.domain.timer.service; + +import com.Timo.Timo.domain.timer.dto.response.TimerStartResponse; +import com.Timo.Timo.domain.timer.entity.TimerRecord; +import com.Timo.Timo.domain.timer.entity.TimerSession; +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.exception.TodoErrorCode; +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 java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@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; + + @Transactional + public TimerStartResponse startTimer(Long userId, Long todoId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + Todo todo = todoRepository.findById(todoId) + .orElseThrow(() -> new CustomException(TodoErrorCode.TODO_NOT_FOUND)); + + if (todo.getEstimatedMinutes() == null) { + throw new CustomException(TodoErrorCode.TODO_ESTIMATED_MINUTES_REQUIRED); + } + + 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) + .plannedMinutes(todo.getEstimatedMinutes()) + .startedAt(now) + .build(); + timerRecordRepository.save(timerRecord); + + TimerSession session = TimerSession.builder() + .timerRecord(timerRecord) + .startedAt(now) + .build(); + timerSessionRepository.save(session); + + return TimerStartResponse.from(timerRecord); + } +} \ No newline at end of file From 350989318cd0df67c6ae725b766a694911f552b8 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 18:15:59 +0900 Subject: [PATCH 094/383] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20import=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/controller/AuthController.java | 1 - .../java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java | 1 - 2 files changed, 2 deletions(-) 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 cbc5ca49..dd85f7e0 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 @@ -14,7 +14,6 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.CookieValue; 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 index b941270f..893cb4c8 100644 --- a/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java +++ b/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java @@ -15,7 +15,6 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import jakarta.servlet.http.HttpServletRequest; -import java.util.Map; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.RequestBody; From 039cf9a0f1b4ee11b56e934e3992efc06f6fdad2 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 18:19:06 +0900 Subject: [PATCH 095/383] =?UTF-8?q?refactor=20TimerResponseFactory=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85=20=EB=B0=8F=20TimerController=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/controller/TimerController.java | 5 +++-- .../timer/factory/TimerResponseFactory.java | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java 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 index 94ce8e72..4e42386f 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java +++ b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java @@ -3,6 +3,7 @@ import com.Timo.Timo.domain.timer.docs.TimerControllerDocs; import com.Timo.Timo.domain.timer.dto.response.TimerStartResponse; import com.Timo.Timo.domain.timer.exception.TimerSuccessCode; +import com.Timo.Timo.domain.timer.factory.TimerResponseFactory; import com.Timo.Timo.domain.timer.service.TimerService; import com.Timo.Timo.global.auth.principal.CustomUserDetails; import com.Timo.Timo.global.response.BaseResponse; @@ -23,6 +24,7 @@ public class TimerController implements TimerControllerDocs { private final TimerService timerService; + private final TimerResponseFactory timerResponseFactory; @Override @PostMapping("/todos/{todoId}/timers/start") @@ -34,7 +36,6 @@ public ResponseEntity> startTimer( Long userId = userDetails.getUser().getId(); TimerStartResponse response = timerService.startTimer(userId, todoId); - return ResponseEntity.status(HttpStatus.CREATED) - .body(BaseResponse.onSuccess(TimerSuccessCode.TIMER_STARTED, response)); + return timerResponseFactory.startResponse(response); } } diff --git a/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java b/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java new file mode 100644 index 00000000..e061cd8f --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java @@ -0,0 +1,17 @@ +package com.Timo.Timo.domain.timer.factory; + +import com.Timo.Timo.domain.timer.dto.response.TimerStartResponse; +import com.Timo.Timo.domain.timer.exception.TimerSuccessCode; +import com.Timo.Timo.global.response.BaseResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; + +@Component +public class TimerResponseFactory { + + public ResponseEntity> startResponse(TimerStartResponse response) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(BaseResponse.onSuccess(TimerSuccessCode.TIMER_STARTED, response)); + } +} From 52386006937284dd7d6f57d92a7ae54701c387dd Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 19:48:55 +0900 Subject: [PATCH 096/383] =?UTF-8?q?fix:=20TimerSession=20pause=20=EC=9E=AC?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=EC=8B=9C=20=EC=83=81=ED=83=9C=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/timer/controller/TimerController.java | 2 -- .../Timo/Timo/domain/timer/docs/TimerControllerDocs.java | 1 - .../com/Timo/Timo/domain/timer/entity/TimerSession.java | 6 ++++++ 3 files changed, 6 insertions(+), 3 deletions(-) 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 index 4e42386f..0817ab82 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java +++ b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java @@ -2,14 +2,12 @@ import com.Timo.Timo.domain.timer.docs.TimerControllerDocs; import com.Timo.Timo.domain.timer.dto.response.TimerStartResponse; -import com.Timo.Timo.domain.timer.exception.TimerSuccessCode; import com.Timo.Timo.domain.timer.factory.TimerResponseFactory; 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 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.PathVariable; diff --git a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java index 207a7fac..71f3d15a 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java @@ -10,7 +10,6 @@ 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.SecurityRequirement; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; 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 index 23634fdd..e29ef9cd 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/entity/TimerSession.java +++ b/src/main/java/com/Timo/Timo/domain/timer/entity/TimerSession.java @@ -1,5 +1,7 @@ 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; @@ -43,6 +45,10 @@ private TimerSession(TimerRecord timerRecord, LocalDateTime startedAt){ } public void pause(LocalDateTime pausedAt){ + if (!isActive()) { + throw new CustomException(TimerErrorCode.TIMER_INVALID_STATUS_TRANSITION); + } + this.pausedAt = pausedAt; } From 0f1fd864e9ff3e5fa991184023b314e82dc7fff6 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 19:50:45 +0900 Subject: [PATCH 097/383] =?UTF-8?q?refactor:=20ErrorCode=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/timer/exception/TimerErrorCode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 439bf108..cb6ca8ab 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/exception/TimerErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/timer/exception/TimerErrorCode.java @@ -12,7 +12,7 @@ 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_INVALID_STATUS_TRANSITION(HttpStatus.CONFLICT, "TIMER_409", "이미 실행 중인 타이머가 있습니다."); private final HttpStatus httpStatus; private final String code; From 4c241c84b30ec078540ba9d8aad72294fc886437 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 19:57:42 +0900 Subject: [PATCH 098/383] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83/=ED=83=88=ED=87=B4=20=EC=9D=91=EB=8B=B5=EC=97=90=20Ca?= =?UTF-8?q?che-Control=20no-store=20=ED=97=A4=EB=8D=94=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/factory/AuthResponseFactory.java | 1 + 1 file changed, 1 insertion(+) 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 index 03208c03..c470653b 100644 --- a/src/main/java/com/Timo/Timo/global/auth/factory/AuthResponseFactory.java +++ b/src/main/java/com/Timo/Timo/global/auth/factory/AuthResponseFactory.java @@ -52,6 +52,7 @@ private ResponseEntity> expiredCookieResponse(AuthSuccessCode 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)); } From 3aa1195d7c3a383b2f60e6118f51f95a8e132600 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 19:59:40 +0900 Subject: [PATCH 099/383] =?UTF-8?q?fix:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=20API=EC=97=90=20Todo=20=EC=86=8C=EC=9C=A0?= =?UTF-8?q?=EA=B6=8C=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/domain/timer/service/TimerService.java | 4 ++++ 1 file changed, 4 insertions(+) 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 index 9eb276d9..f1951d8d 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -39,6 +39,10 @@ public TimerStartResponse startTimer(Long userId, Long todoId) { Todo todo = todoRepository.findById(todoId) .orElseThrow(() -> new CustomException(TodoErrorCode.TODO_NOT_FOUND)); + if (!todo.getUser().getId().equals(userId)) { + throw new CustomException(TodoErrorCode.TODO_ACCESS_DENIED); + } + if (todo.getEstimatedMinutes() == null) { throw new CustomException(TodoErrorCode.TODO_ESTIMATED_MINUTES_REQUIRED); } From ce43f6193fc77154da25bc0a6fc40deccbcccba2 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 20:03:41 +0900 Subject: [PATCH 100/383] =?UTF-8?q?docs:=20TimerControllerDocs=EC=97=90=20?= =?UTF-8?q?403=20=EC=9D=91=EB=8B=B5=20=EB=AC=B8=EC=84=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/timer/docs/TimerControllerDocs.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java index 71f3d15a..c72c3ac0 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java @@ -45,6 +45,14 @@ public interface TimerControllerDocs { schema = @Schema(implementation = ErrorDto.class) ) ), + @ApiResponse( + responseCode = "403", + description = "본인 소유의 투두가 아닌 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), @ApiResponse( responseCode = "404", description = "존재하지 않는 투두인 경우", From c2362335c3c6d3332599aa1284904d8f1d5fc50b Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 22:50:40 +0900 Subject: [PATCH 101/383] =?UTF-8?q?feat:=20timer=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/controller/TimerController.java | 39 --------- .../timer/docs/TimerControllerDocs.java | 86 ------------------- .../dto/response/TimerStartResponse.java | 22 ----- .../timer/exception/TimerErrorCode.java | 20 ----- .../timer/exception/TimerSuccessCode.java | 20 ----- .../timer/factory/TimerResponseFactory.java | 17 ---- .../repository/TimerRecordRepository.java | 12 --- .../repository/TimerSessionRepository.java | 10 --- .../domain/timer/service/TimerService.java | 73 ---------------- 9 files changed, 299 deletions(-) delete mode 100644 src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java delete mode 100644 src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java delete mode 100644 src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerStartResponse.java delete mode 100644 src/main/java/com/Timo/Timo/domain/timer/exception/TimerErrorCode.java delete mode 100644 src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java delete mode 100644 src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java delete mode 100644 src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java delete mode 100644 src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java delete mode 100644 src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java 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 deleted file mode 100644 index 0817ab82..00000000 --- a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.Timo.Timo.domain.timer.controller; - -import com.Timo.Timo.domain.timer.docs.TimerControllerDocs; -import com.Timo.Timo.domain.timer.dto.response.TimerStartResponse; -import com.Timo.Timo.domain.timer.factory.TimerResponseFactory; -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 lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -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 TimerControllerDocs { - - private final TimerService timerService; - private final TimerResponseFactory timerResponseFactory; - - @Override - @PostMapping("/todos/{todoId}/timers/start") - public ResponseEntity> startTimer( - @PathVariable Long todoId, - @AuthenticationPrincipal CustomUserDetails userDetails - ) { - - Long userId = userDetails.getUser().getId(); - TimerStartResponse response = timerService.startTimer(userId, todoId); - - return timerResponseFactory.startResponse(response); - } -} diff --git a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java deleted file mode 100644 index c72c3ac0..00000000 --- a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java +++ /dev/null @@ -1,86 +0,0 @@ -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 TimerControllerDocs { - - @Operation( - summary = "타이머 시작", - description = """ - 투두의 타이머를 시작합니다. - - 예상 소요 시간(plannedMinutes)은 시작 시점 투두의 estimatedMinutes 값을 저장 - - 한 번에 한 개의 타이머만 실행 가능하며, 이미 실행/일시정지 중인 타이머가 있으면 409 반환 - """ - ) - @ApiResponses({ - @ApiResponse( - responseCode = "201", - 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> startTimer( - @Parameter(description = "타이머를 시작할 투두 ID", example = "3") - @PathVariable Long todoId, - @Parameter(hidden = true) CustomUserDetails userDetails - ); -} \ 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 deleted file mode 100644 index dcc9addc..00000000 --- a/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerStartResponse.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.Timo.Timo.domain.timer.dto.response; - -import com.Timo.Timo.domain.timer.entity.TimerRecord; -import java.time.LocalDateTime; - -public record TimerStartResponse( - Long timerId, - Long todoId, - String status, - Integer plannedMinutes, - LocalDateTime startedAt -) { - public static TimerStartResponse from(TimerRecord timerRecord) { - return new TimerStartResponse( - timerRecord.getId(), - timerRecord.getTodo().getId(), - timerRecord.getStatus().name(), - timerRecord.getPlannedMinutes(), - timerRecord.getStartedAt() - ); - } -} 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 deleted file mode 100644 index cb6ca8ab..00000000 --- a/src/main/java/com/Timo/Timo/domain/timer/exception/TimerErrorCode.java +++ /dev/null @@ -1,20 +0,0 @@ -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", "이미 실행 중인 타이머가 있습니다."); - - 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 deleted file mode 100644 index dd6eb2f8..00000000 --- a/src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java +++ /dev/null @@ -1,20 +0,0 @@ -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_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/factory/TimerResponseFactory.java b/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java deleted file mode 100644 index e061cd8f..00000000 --- a/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.Timo.Timo.domain.timer.factory; - -import com.Timo.Timo.domain.timer.dto.response.TimerStartResponse; -import com.Timo.Timo.domain.timer.exception.TimerSuccessCode; -import com.Timo.Timo.global.response.BaseResponse; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; - -@Component -public class TimerResponseFactory { - - public ResponseEntity> startResponse(TimerStartResponse response) { - return ResponseEntity.status(HttpStatus.CREATED) - .body(BaseResponse.onSuccess(TimerSuccessCode.TIMER_STARTED, response)); - } -} 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 deleted file mode 100644 index 26cdfbdd..00000000 --- a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.Timo.Timo.domain.timer.repository; - -import com.Timo.Timo.domain.timer.entity.TimerRecord; -import com.Timo.Timo.domain.timer.enums.TimerStatus; -import java.util.List; -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface TimerRecordRepository extends JpaRepository { - - Optional findByUserIdAndStatusIn(Long userId, List statuses); -} 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 deleted file mode 100644 index fd811590..00000000 --- a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.Timo.Timo.domain.timer.repository; - -import com.Timo.Timo.domain.timer.entity.TimerSession; -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface TimerSessionRepository extends JpaRepository { - - Optional findByTimerRecordIdAndPausedAtIsNull(Long timerRecordId); -} 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 deleted file mode 100644 index f1951d8d..00000000 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.Timo.Timo.domain.timer.service; - -import com.Timo.Timo.domain.timer.dto.response.TimerStartResponse; -import com.Timo.Timo.domain.timer.entity.TimerRecord; -import com.Timo.Timo.domain.timer.entity.TimerSession; -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.exception.TodoErrorCode; -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 java.time.LocalDateTime; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@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; - - @Transactional - public TimerStartResponse startTimer(Long userId, Long todoId) { - User user = userRepository.findById(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(TodoErrorCode.TODO_ACCESS_DENIED); - } - - if (todo.getEstimatedMinutes() == null) { - throw new CustomException(TodoErrorCode.TODO_ESTIMATED_MINUTES_REQUIRED); - } - - 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) - .plannedMinutes(todo.getEstimatedMinutes()) - .startedAt(now) - .build(); - timerRecordRepository.save(timerRecord); - - TimerSession session = TimerSession.builder() - .timerRecord(timerRecord) - .startedAt(now) - .build(); - timerSessionRepository.save(session); - - return TimerStartResponse.from(timerRecord); - } -} \ No newline at end of file From c1ed7695e91025b0982c37f022bf9c6a08010196 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 23:08:40 +0900 Subject: [PATCH 102/383] =?UTF-8?q?feat:=20timerRecord,=20timerSession=20?= =?UTF-8?q?=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/repository/TimerRecordRepository.java | 12 ++++++++++++ .../timer/repository/TimerSessionRepository.java | 10 ++++++++++ 2 files changed, 22 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java create mode 100644 src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java 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..b7ac0642 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java @@ -0,0 +1,12 @@ +package com.Timo.Timo.domain.timer.repository; + +import com.Timo.Timo.domain.timer.entity.TimerRecord; +import com.Timo.Timo.domain.timer.enums.TimerStatus; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TimerRecordRepository extends JpaRepository { + + Optional findByUserIdAndStatusIn(Long userId, List statuses); +} 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..fd811590 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java @@ -0,0 +1,10 @@ +package com.Timo.Timo.domain.timer.repository; + +import com.Timo.Timo.domain.timer.entity.TimerSession; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TimerSessionRepository extends JpaRepository { + + Optional findByTimerRecordIdAndPausedAtIsNull(Long timerRecordId); +} From 9121a343bef325f8feb5a284f7ffe50ff203e4d8 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 23:13:23 +0900 Subject: [PATCH 103/383] =?UTF-8?q?feat:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20e?= =?UTF-8?q?rrorCode,=20successCode=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/exception/TimerErrorCode.java | 20 +++++++++++++++++++ .../timer/exception/TimerSuccessCode.java | 18 +++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/timer/exception/TimerErrorCode.java create mode 100644 src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java 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..439bf108 --- /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", "요청을 처리할 수 없는 타이머 상태입니다."); + + 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..f542cb8a --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java @@ -0,0 +1,18 @@ +package com.Timo.Timo.domain.timer.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum TimerSuccessCode { + + TIMER_STARTED(HttpStatus.CREATED, "TIMER_201", "타이머가 시작되었습니다."), + TIMER_PAUSED(HttpStatus.OK, "TIMER_200", "타이머가 일시정지되었습니다."), + TIMER_RESUMED(HttpStatus.OK, "TIMER_200", "타이머가 재개되었습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} From 1bb674065a1d3edbf954bb29750622085a50c1ef Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 23:14:03 +0900 Subject: [PATCH 104/383] =?UTF-8?q?feat:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=20response=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/TimerStartResponse.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerStartResponse.java 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..dcc9addc --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerStartResponse.java @@ -0,0 +1,22 @@ +package com.Timo.Timo.domain.timer.dto.response; + +import com.Timo.Timo.domain.timer.entity.TimerRecord; +import java.time.LocalDateTime; + +public record TimerStartResponse( + Long timerId, + Long todoId, + String status, + Integer plannedMinutes, + LocalDateTime startedAt +) { + public static TimerStartResponse from(TimerRecord timerRecord) { + return new TimerStartResponse( + timerRecord.getId(), + timerRecord.getTodo().getId(), + timerRecord.getStatus().name(), + timerRecord.getPlannedMinutes(), + timerRecord.getStartedAt() + ); + } +} From 98fb50f9c8bdda52d99cf0bea2735d12506174b5 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 23:14:33 +0900 Subject: [PATCH 105/383] =?UTF-8?q?refactor:=20=ED=83=80=EC=9D=B4=EB=A8=B8?= =?UTF-8?q?=20SuccessCode=20enum=EC=97=90=20BaseSuccessCode=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index f542cb8a..27cb4a73 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java @@ -1,12 +1,13 @@ 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 { +public enum TimerSuccessCode implements BaseSuccessCode { TIMER_STARTED(HttpStatus.CREATED, "TIMER_201", "타이머가 시작되었습니다."), TIMER_PAUSED(HttpStatus.OK, "TIMER_200", "타이머가 일시정지되었습니다."), From 414f832cb10a9fd19e0e1639b8919cd25495cc5c Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 23:24:41 +0900 Subject: [PATCH 106/383] =?UTF-8?q?refactor:=20TimerSuccessCode=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=BD=94=EB=93=9C=EB=B3=84=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/timer/exception/TimerSuccessCode.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index 27cb4a73..dd6eb2f8 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java @@ -9,9 +9,10 @@ @RequiredArgsConstructor public enum TimerSuccessCode implements BaseSuccessCode { - TIMER_STARTED(HttpStatus.CREATED, "TIMER_201", "타이머가 시작되었습니다."), TIMER_PAUSED(HttpStatus.OK, "TIMER_200", "타이머가 일시정지되었습니다."), - TIMER_RESUMED(HttpStatus.OK, "TIMER_200", "타이머가 재개되었습니다."); + TIMER_RESUMED(HttpStatus.OK, "TIMER_200", "타이머가 재개되었습니다."), + + TIMER_STARTED(HttpStatus.CREATED, "TIMER_201", "타이머가 시작되었습니다."); private final HttpStatus httpStatus; private final String code; From 797434982a9675693a8d87e35b1c612fb034cea3 Mon Sep 17 00:00:00 2001 From: jy000n Date: Wed, 8 Jul 2026 00:12:01 +0900 Subject: [PATCH 107/383] =?UTF-8?q?feat:=20User=20=EC=98=A8=EB=B3=B4?= =?UTF-8?q?=EB=94=A9=20=EC=99=84=EB=A3=8C=20=EC=B2=98=EB=A6=AC=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=EC=97=90=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/domain/user/entity/User.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 3d63452e..1153d940 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 @@ -76,7 +76,16 @@ public void update(String name, String profileImageUrl) { this.profileImageUrl = profileImageUrl; } - public void completeOnboarding() { + 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; } From b3efee34abe21b0a88e8451308b3358342fc10aa Mon Sep 17 00:00:00 2001 From: jy000n Date: Wed, 8 Jul 2026 00:21:25 +0900 Subject: [PATCH 108/383] =?UTF-8?q?feat:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20API=20Request=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/OnboardingRequest.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/onboarding/dto/request/OnboardingRequest.java diff --git a/src/main/java/com/Timo/Timo/domain/onboarding/dto/request/OnboardingRequest.java b/src/main/java/com/Timo/Timo/domain/onboarding/dto/request/OnboardingRequest.java new file mode 100644 index 00000000..c88ff9b0 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/onboarding/dto/request/OnboardingRequest.java @@ -0,0 +1,26 @@ +package com.Timo.Timo.domain.onboarding.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 +){ +} From 340a97885230d4039d6de7629e4d50d51f270d9a Mon Sep 17 00:00:00 2001 From: jy000n Date: Wed, 8 Jul 2026 00:24:19 +0900 Subject: [PATCH 109/383] =?UTF-8?q?feat:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20API=20Response=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onboarding/dto/response/OnboardingResponse.java | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/onboarding/dto/response/OnboardingResponse.java diff --git a/src/main/java/com/Timo/Timo/domain/onboarding/dto/response/OnboardingResponse.java b/src/main/java/com/Timo/Timo/domain/onboarding/dto/response/OnboardingResponse.java new file mode 100644 index 00000000..1c3ccdcd --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/onboarding/dto/response/OnboardingResponse.java @@ -0,0 +1,10 @@ +package com.Timo.Timo.domain.onboarding.dto.response; + +import lombok.Builder; + +@Builder +public record OnboardingResponse( + boolean onboardingCompleted +) { + +} From 40d2b26770f5e55157ef5586dd2eb677ff0f0445 Mon Sep 17 00:00:00 2001 From: jy000n Date: Wed, 8 Jul 2026 00:28:15 +0900 Subject: [PATCH 110/383] =?UTF-8?q?chore:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=ED=8C=8C=EC=9D=BC=20=EC=9C=84=EC=B9=98=20?= =?UTF-8?q?User=20=ED=8F=B4=EB=8D=94=20=EB=82=B4=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/user/controller/OnboardingController.java | 5 +++++ .../{onboarding => user}/dto/request/OnboardingRequest.java | 2 +- .../dto/response/OnboardingResponse.java | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/domain/user/controller/OnboardingController.java rename src/main/java/com/Timo/Timo/domain/{onboarding => user}/dto/request/OnboardingRequest.java (94%) rename src/main/java/com/Timo/Timo/domain/{onboarding => user}/dto/response/OnboardingResponse.java (66%) 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..d465d7d1 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/controller/OnboardingController.java @@ -0,0 +1,5 @@ +package com.Timo.Timo.domain.user.controller; + +public class OnboardingController { + +} diff --git a/src/main/java/com/Timo/Timo/domain/onboarding/dto/request/OnboardingRequest.java b/src/main/java/com/Timo/Timo/domain/user/dto/request/OnboardingRequest.java similarity index 94% rename from src/main/java/com/Timo/Timo/domain/onboarding/dto/request/OnboardingRequest.java rename to src/main/java/com/Timo/Timo/domain/user/dto/request/OnboardingRequest.java index c88ff9b0..fe2f4d97 100644 --- a/src/main/java/com/Timo/Timo/domain/onboarding/dto/request/OnboardingRequest.java +++ b/src/main/java/com/Timo/Timo/domain/user/dto/request/OnboardingRequest.java @@ -1,4 +1,4 @@ -package com.Timo.Timo.domain.onboarding.dto.request; +package com.Timo.Timo.domain.user.dto.request; import com.Timo.Timo.domain.user.enums.Language; import jakarta.validation.constraints.Max; diff --git a/src/main/java/com/Timo/Timo/domain/onboarding/dto/response/OnboardingResponse.java b/src/main/java/com/Timo/Timo/domain/user/dto/response/OnboardingResponse.java similarity index 66% rename from src/main/java/com/Timo/Timo/domain/onboarding/dto/response/OnboardingResponse.java rename to src/main/java/com/Timo/Timo/domain/user/dto/response/OnboardingResponse.java index 1c3ccdcd..bf119bab 100644 --- a/src/main/java/com/Timo/Timo/domain/onboarding/dto/response/OnboardingResponse.java +++ b/src/main/java/com/Timo/Timo/domain/user/dto/response/OnboardingResponse.java @@ -1,4 +1,4 @@ -package com.Timo.Timo.domain.onboarding.dto.response; +package com.Timo.Timo.domain.user.dto.response; import lombok.Builder; From afed08515c6b6f57f50b84317660e0b43e2e22bc Mon Sep 17 00:00:00 2001 From: jy000n Date: Wed, 8 Jul 2026 00:36:35 +0900 Subject: [PATCH 111/383] =?UTF-8?q?feat:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EA=B2=80=EC=A6=9D=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/domain/user/exception/UserErrorCode.java | 2 ++ 1 file changed, 2 insertions(+) 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..f7e408ad 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", "취침 시간은 기상 시간보다 이후여야 합니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404", "존재하지 않는 사용자입니다."); private final HttpStatus httpStatus; From 7cad7ed943f7f23db2c37627753c78f07573f59f Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:45:49 +0900 Subject: [PATCH 112/383] =?UTF-8?q?refactor:=20todo=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=8B=9C=20=EB=82=A0=EC=A7=9C=20=EC=A0=80=EC=9E=A5=EC=9D=B4=20?= =?UTF-8?q?=EC=95=84=EB=8B=8C=20=EB=B0=98=EB=B3=B5=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/todo/entity/Subtask.java | 9 +-- .../Timo/Timo/domain/todo/entity/Todo.java | 78 ++++++++++--------- .../Timo/domain/todo/entity/TodoInstance.java | 71 +++++++++++++++++ 3 files changed, 116 insertions(+), 42 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/domain/todo/entity/TodoInstance.java 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 index 4f390634..7f59d06e 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/entity/Subtask.java +++ b/src/main/java/com/Timo/Timo/domain/todo/entity/Subtask.java @@ -12,7 +12,6 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.AccessLevel; -import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -34,13 +33,13 @@ public class Subtask extends BaseTimeEntity { @Column(name = "content", nullable = false, length = 20) private String content; - @Column(name = "completed", nullable = false) - private boolean completed; + @Column(name = "sort_order", nullable = false) + private Integer sortOrder; - public static Subtask of(String content) { + public static Subtask of(String content, int sortOrder) { Subtask subtask = new Subtask(); subtask.content = content; - subtask.completed = false; + subtask.sortOrder = sortOrder; return subtask; } 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 index 901a48ca..f7b065fc 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java +++ b/src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java @@ -57,18 +57,12 @@ public class Todo extends BaseTimeEntity { @OneToMany(mappedBy = "todo", cascade = CascadeType.ALL, orphanRemoval = true) private List subtasks = new ArrayList<>(); - @Column(name = "todo_date", nullable = false) - private LocalDate date; + // ─── 반복 규칙 ───────────────────────────────────── + @Column(name = "start_date", nullable = false) + private LocalDate startDate; - @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; + @Column(name = "end_date", nullable = false) + private LocalDate endDate; @Enumerated(EnumType.STRING) @Column(name = "repeat_type", nullable = false, length = 20) @@ -83,40 +77,48 @@ public class Todo extends BaseTimeEntity { @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; - @Column(name = "sort_order", nullable = false) - private Integer sortOrder; - @Builder(access = AccessLevel.PRIVATE) private Todo( User user, TodoIcon icon, String title, - LocalDate date, - Integer durationSeconds, - TodoPriority priority, - Long tagId, + LocalDate startDate, + LocalDate endDate, RepeatType repeatType, List repeatWeekdays, Integer repeatDayOfMonth, - String memo, - Integer sortOrder + Integer durationSeconds, + TodoPriority priority, + Long tagId, + String memo ) { this.user = user; this.icon = icon; this.title = title; - this.date = date; - this.durationSeconds = durationSeconds; - this.priority = priority; - this.tagId = tagId; + this.startDate = startDate; + this.endDate = endDate; this.repeatType = repeatType; this.repeatWeekdays = repeatWeekdays != null ? new ArrayList<>(repeatWeekdays) : new ArrayList<>(); this.repeatDayOfMonth = repeatDayOfMonth; + this.durationSeconds = durationSeconds; + this.priority = priority; + this.tagId = tagId; this.memo = memo; - this.sortOrder = sortOrder; } public static Todo create( @@ -124,33 +126,35 @@ public static Todo create( TodoIcon icon, String title, List subtaskContents, - LocalDate date, - int durationSeconds, - TodoPriority priority, - Long tagId, + LocalDate startDate, + LocalDate endDate, RepeatType repeatType, List repeatWeekdays, Integer repeatDayOfMonth, - String memo, - int sortOrder + int durationSeconds, + TodoPriority priority, + Long tagId, + String memo ) { Todo todo = Todo.builder() .user(user) .icon(icon) .title(title) - .date(date) - .durationSeconds(durationSeconds) - .priority(priority) - .tagId(tagId) + .startDate(startDate) + .endDate(endDate) .repeatType(repeatType) .repeatWeekdays(repeatWeekdays) .repeatDayOfMonth(repeatDayOfMonth) + .durationSeconds(durationSeconds) + .priority(priority) + .tagId(tagId) .memo(memo) - .sortOrder(sortOrder) .build(); if (subtaskContents != null) { - subtaskContents.forEach(content -> todo.addSubtask(Subtask.of(content))); + for (int i = 0; i < subtaskContents.size(); i++) { + todo.addSubtask(Subtask.of(subtaskContents.get(i), i + 1)); + } } return todo; } 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..1bec4612 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/entity/TodoInstance.java @@ -0,0 +1,71 @@ +package com.Timo.Timo.domain.todo.entity; + +import java.time.LocalDate; + +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 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; + + 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; + return instance; + } + + public void markCompleted() { + this.completed = true; + } + + public void markIncomplete() { + this.completed = false; + } + + public void updateSortOrder(int sortOrder) { + this.sortOrder = sortOrder; + } +} From 7dba91257d3d2eb47e0b9329318964f9dc5eb2fe Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:46:22 +0900 Subject: [PATCH 113/383] =?UTF-8?q?feat:=20todo=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20dto=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../todo/dto/request/TodoCreateRequest.java | 64 +++++++++++++++++++ .../todo/dto/response/TodoCreateResponse.java | 12 ++++ 2 files changed, 76 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoCreateRequest.java create mode 100644 src/main/java/com/Timo/Timo/domain/todo/dto/response/TodoCreateResponse.java 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..de375f4a --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoCreateRequest.java @@ -0,0 +1,64 @@ +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 + @Pattern(regexp = "^\\d+:[0-5]\\d$", message = "duration은 mm:ss 형식이어야 합니다.") + 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/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()); + } +} From 709c54a8616d4674c1c662de551070489b0247f1 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:46:46 +0900 Subject: [PATCH 114/383] =?UTF-8?q?feat:=20todo=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EC=97=90=20=ED=95=84=EC=9A=94=ED=95=9C=20tag=20=EC=84=B8?= =?UTF-8?q?=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/tag/entity/Tag.java | 23 +++++++++++++++++++ .../domain/tag/exception/TagErrorCode.java | 19 +++++++++++++++ .../domain/tag/repository/TagRepository.java | 8 +++++++ 3 files changed, 50 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/tag/entity/Tag.java create mode 100644 src/main/java/com/Timo/Timo/domain/tag/exception/TagErrorCode.java create mode 100644 src/main/java/com/Timo/Timo/domain/tag/repository/TagRepository.java 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..a553b11a --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/tag/entity/Tag.java @@ -0,0 +1,23 @@ +package com.Timo.Timo.domain.tag.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "tags") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Tag { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; +} 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..700134cb --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/tag/exception/TagErrorCode.java @@ -0,0 +1,19 @@ +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 { + + TAG_NOT_FOUND(HttpStatus.NOT_FOUND, "TAG_404", "존재하지 않는 태그입니다."); + + private final HttpStatus httpStatus; + private final String code; + 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..a3ffde1b --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/tag/repository/TagRepository.java @@ -0,0 +1,8 @@ +package com.Timo.Timo.domain.tag.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.Timo.Timo.domain.tag.entity.Tag; + +public interface TagRepository extends JpaRepository { +} From 627882c530b20823e0bd4ea2515e72251bd5a6f5 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:47:22 +0900 Subject: [PATCH 115/383] =?UTF-8?q?feat:=20todo=20=EB=A0=88=ED=8F=AC?= =?UTF-8?q?=EC=A7=80=ED=86=A0=EB=A6=AC=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/TodoInstanceRepository.java | 27 +++++++++++++++++++ .../todo/repository/TodoRepository.java | 20 +++++++++----- 2 files changed, 41 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/domain/todo/repository/TodoInstanceRepository.java 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..1a7f2fa5 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoInstanceRepository.java @@ -0,0 +1,27 @@ +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.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); + + @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 + ); +} 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 index c78c0a85..12c1e6d5 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java @@ -1,6 +1,7 @@ package com.Timo.Timo.domain.todo.repository; import java.time.LocalDate; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -10,12 +11,19 @@ public interface TodoRepository extends JpaRepository { - long countByUser_IdAndDate(Long userId, LocalDate date); - @Query(""" - select coalesce(max(t.sortOrder), 0) - from Todo t - where t.user.id = :userId and t.date = :date + select t from Todo t + where t.user.id = :userId + and t.startDate <= :to + and t.endDate >= :from """) - int findMaxSortOrderByUserIdAndDate(@Param("userId") Long userId, @Param("date") LocalDate date); + List findRulesInRange( + @Param("userId") Long userId, + @Param("from") LocalDate from, + @Param("to") LocalDate to + ); + + long countByUser_IdAndStartDateLessThanEqualAndEndDateGreaterThanEqual( + Long userId, LocalDate to, LocalDate from + ); } From eb9859b0928be1e8c6ce94c9c758c49bcda752a4 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:47:52 +0900 Subject: [PATCH 116/383] =?UTF-8?q?feat:=20todo=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20valid=20=EB=A1=9C=EC=A7=81=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../validation/SubtaskContentValidator.java | 23 +++++++++++++++++++ .../todo/validation/TodoTitleValidator.java | 23 +++++++++++++++++++ .../todo/validation/ValidSubtaskContent.java | 21 +++++++++++++++++ .../todo/validation/ValidTodoTitle.java | 21 +++++++++++++++++ 4 files changed, 88 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/todo/validation/SubtaskContentValidator.java create mode 100644 src/main/java/com/Timo/Timo/domain/todo/validation/TodoTitleValidator.java create mode 100644 src/main/java/com/Timo/Timo/domain/todo/validation/ValidSubtaskContent.java create mode 100644 src/main/java/com/Timo/Timo/domain/todo/validation/ValidTodoTitle.java 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 {}; +} From 27ff88e4ca8d355e71a5a483eced52e60ae247e5 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:48:34 +0900 Subject: [PATCH 117/383] =?UTF-8?q?feat:=20=EC=86=8C=EC=9A=94=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EA=B4=80=EB=A0=A8=20valid=20=EC=97=AC=EB=B6=80=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=20=EB=B0=8F=20=EC=B4=88=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/todo/vo/Duration.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/todo/vo/Duration.java 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..727de30c --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/vo/Duration.java @@ -0,0 +1,27 @@ +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); + } + + int minutes = Integer.parseInt(matcher.group(1)); + int seconds = Integer.parseInt(matcher.group(2)); + return new Duration(minutes * 60 + seconds); + } +} From 1c0728fb4c9767f9460c43ecf3ad791957d4398a Mon Sep 17 00:00:00 2001 From: jy000n Date: Wed, 8 Jul 2026 00:49:21 +0900 Subject: [PATCH 118/383] =?UTF-8?q?feat:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EC=B2=98=EB=A6=AC=20Service=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/user/OnboardingService.java | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/user/OnboardingService.java diff --git a/src/main/java/com/Timo/Timo/domain/user/OnboardingService.java b/src/main/java/com/Timo/Timo/domain/user/OnboardingService.java new file mode 100644 index 00000000..1b57b3e4 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/OnboardingService.java @@ -0,0 +1,44 @@ +package com.Timo.Timo.domain.user; + +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(); + } +} From 17372e3d8824052c21224bc565c0425197c692e5 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:49:30 +0900 Subject: [PATCH 119/383] =?UTF-8?q?feat:=20todo=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EC=8B=9C=20=EC=A0=9C=EC=95=BD=EC=A1=B0=EA=B1=B4=EC=9D=84=20?= =?UTF-8?q?=EB=A7=8C=EC=A1=B1=ED=95=98=EB=8A=94=EC=A7=80=20=EA=B2=80?= =?UTF-8?q?=EC=82=AC=20=EB=A1=9C=EC=A7=81=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../todo/service/TodoCapacityChecker.java | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/todo/service/TodoCapacityChecker.java 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..b2769413 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoCapacityChecker.java @@ -0,0 +1,44 @@ +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) { + 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 -> todoDateCalculator.occursOn(rule, date)) + .count(); + + if (existingCount >= MAX_TODO_COUNT_PER_DATE) { + throw new CustomException(TodoErrorCode.MAX_COUNT_EXCEEDED); + } + } + } +} From 5ed11fae80b0f92993ac0f3d133706c1bd0cd553 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:49:47 +0900 Subject: [PATCH 120/383] =?UTF-8?q?feat:=20todo=20=EB=B0=98=EB=B3=B5?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=20=EA=B3=84=EC=82=B0=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=EA=B8=B0=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../todo/service/TodoDateCalculator.java | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/todo/service/TodoDateCalculator.java 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..a4e9fabf --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoDateCalculator.java @@ -0,0 +1,134 @@ +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) { + LocalDate start = request.date(); + LocalDate end = start.plus(REPEAT_PERIOD); + + Set dates = switch (request.repeatType()) { + case NONE -> Set.of(start); + case DAILY -> daily(start, end); + case WEEKLY -> weekly(start, end, request.repeatWeekdays()); + case MONTHLY -> monthly(start, end, request.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)); + } +} From 7c2806d24802a193c04db48b74a33025ac6a768b Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:49:56 +0900 Subject: [PATCH 121/383] =?UTF-8?q?feat:=20todo=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/todo/service/TodoService.java | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java 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..35e74bc9 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java @@ -0,0 +1,73 @@ +package com.Timo.Timo.domain.todo.service; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.Timo.Timo.domain.tag.exception.TagErrorCode; +import com.Timo.Timo.domain.tag.repository.TagRepository; +import com.Timo.Timo.domain.todo.dto.request.TodoCreateRequest; +import com.Timo.Timo.domain.todo.dto.response.TodoCreateResponse; +import com.Timo.Timo.domain.todo.entity.Todo; +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 UserRepository userRepository; + private final TagRepository tagRepository; + private final TodoDateCalculator todoDateCalculator; + private final TodoCapacityChecker todoCapacityChecker; + + @Transactional + public TodoCreateResponse createTodo(Long userId, TodoCreateRequest request) { + User user = userRepository.findById(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 = request.date().plus(TodoDateCalculator.REPEAT_PERIOD); + + 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); + } + + private void validateTagExists(Long tagId) { + if (tagId != null && !tagRepository.existsById(tagId)) { + throw new CustomException(TagErrorCode.TAG_NOT_FOUND); + } + } +} From 268e6823a8aba72ca199bd598100a897a2fa2eca Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:50:16 +0900 Subject: [PATCH 122/383] =?UTF-8?q?feat:=20todo=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=20=EB=B0=8F=20swagger=20docs=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../todo/controller/TodoController.java | 42 +++++++ .../domain/todo/docs/TodoControllerDocs.java | 118 ++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/todo/controller/TodoController.java create mode 100644 src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java 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..874620d8 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/controller/TodoController.java @@ -0,0 +1,42 @@ +package com.Timo.Timo.domain.todo.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.todo.docs.TodoControllerDocs; +import com.Timo.Timo.domain.todo.dto.request.TodoCreateRequest; +import com.Timo.Timo.domain.todo.dto.response.TodoCreateResponse; +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)); + } +} 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..4099b2b8 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java @@ -0,0 +1,118 @@ +package com.Timo.Timo.domain.todo.docs; + +import org.springframework.http.ResponseEntity; + +import com.Timo.Timo.domain.todo.dto.request.TodoCreateRequest; +import com.Timo.Timo.domain.todo.dto.response.TodoCreateResponse; +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; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; + +public interface TodoControllerDocs { + + @Operation( + summary = "TODO 생성", + description = """ + 사용자가 새로운 TODO를 생성합니다. + + 아이콘, 제목, 하위 태스크, 날짜, 예상 소요 시간, 우선순위, 태그, 반복 설정, 메모를 함께 저장합니다. + 예상 소요 시간은 duration 필드에 분:초 형식으로 전달합니다. 예: 00:15 + 반복 일정은 시작일 기준 최대 1년까지 생성됩니다. + + Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. + """, + security = @SecurityRequirement(name = "bearerAuth") + ) + @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 + ); +} From c85f619deade5705dc0b304ad9f69012a1394483 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:55:27 +0900 Subject: [PATCH 123/383] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java | 2 -- 1 file changed, 2 deletions(-) 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 index f7b065fc..4c0e5644 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java +++ b/src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java @@ -57,7 +57,6 @@ public class Todo extends BaseTimeEntity { @OneToMany(mappedBy = "todo", cascade = CascadeType.ALL, orphanRemoval = true) private List subtasks = new ArrayList<>(); - // ─── 반복 규칙 ───────────────────────────────────── @Column(name = "start_date", nullable = false) private LocalDate startDate; @@ -77,7 +76,6 @@ public class Todo extends BaseTimeEntity { @Column(name = "repeat_day_of_month") private Integer repeatDayOfMonth; - // ─── 규칙에 딸린 공통 속성 ──────────────────────── @Column(name = "duration_seconds", nullable = false) private Integer durationSeconds; From 6323cde2473d01de5e810d32de8a78b9ae4aa339 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:55:52 +0900 Subject: [PATCH 124/383] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=B0=8F=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C?= =?UTF-8?q?=EC=95=BD=EC=A1=B0=EA=B1=B4=20=EB=A1=9C=EC=A7=81=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/todo/dto/request/TodoCreateRequest.java | 1 - 1 file changed, 1 deletion(-) 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 index de375f4a..75329d52 100644 --- 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 @@ -31,7 +31,6 @@ public record TodoCreateRequest( LocalDate date, @NotBlank - @Pattern(regexp = "^\\d+:[0-5]\\d$", message = "duration은 mm:ss 형식이어야 합니다.") String duration, TodoPriority priority, From 25f9a276f25c8cb9cc2c3e01c7ac5404bdaac049 Mon Sep 17 00:00:00 2001 From: jy000n Date: Wed, 8 Jul 2026 00:59:09 +0900 Subject: [PATCH 125/383] =?UTF-8?q?feat:=20UserSuccessCode=EC=97=90=20?= =?UTF-8?q?=EC=98=A8=EB=B3=B4=EB=94=A9=20=EC=99=84=EB=A3=8C=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/user/exception/UserSuccessCode.java | 3 ++- .../Timo/Timo/domain/user/{ => service}/OnboardingService.java | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) rename src/main/java/com/Timo/Timo/domain/user/{ => service}/OnboardingService.java (96%) 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 index 2f38574d..13d58e34 100644 --- a/src/main/java/com/Timo/Timo/domain/user/exception/UserSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/user/exception/UserSuccessCode.java @@ -11,7 +11,8 @@ @RequiredArgsConstructor public enum UserSuccessCode implements BaseSuccessCode { - PROFILE_RETRIEVED(HttpStatus.OK, "프로필을 조회했습니다."); + PROFILE_RETRIEVED(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/OnboardingService.java b/src/main/java/com/Timo/Timo/domain/user/service/OnboardingService.java similarity index 96% rename from src/main/java/com/Timo/Timo/domain/user/OnboardingService.java rename to src/main/java/com/Timo/Timo/domain/user/service/OnboardingService.java index 1b57b3e4..2ed7f29a 100644 --- a/src/main/java/com/Timo/Timo/domain/user/OnboardingService.java +++ b/src/main/java/com/Timo/Timo/domain/user/service/OnboardingService.java @@ -1,4 +1,4 @@ -package com.Timo.Timo.domain.user; +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; From 128837032f91561a2edea59c03752a90d49f474e Mon Sep 17 00:00:00 2001 From: jy000n Date: Wed, 8 Jul 2026 01:04:43 +0900 Subject: [PATCH 126/383] =?UTF-8?q?feat:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/OnboardingController.java | 38 ++++++++++++++++++- .../user/docs/OnboardingControllerDocs.java | 17 +++++++++ .../user/dto/request/OnboardingRequest.java | 1 + 3 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/Timo/Timo/domain/user/docs/OnboardingControllerDocs.java 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 index d465d7d1..82a59fd6 100644 --- a/src/main/java/com/Timo/Timo/domain/user/controller/OnboardingController.java +++ b/src/main/java/com/Timo/Timo/domain/user/controller/OnboardingController.java @@ -1,5 +1,41 @@ package com.Timo.Timo.domain.user.controller; -public class OnboardingController { +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.exception.UserSuccessCode; +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 ResponseEntity.ok( + BaseResponse.onSuccess(UserSuccessCode.ONBOARDING_COMPLETED, 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..5fb72056 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/docs/OnboardingControllerDocs.java @@ -0,0 +1,17 @@ +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.response.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import org.springframework.http.ResponseEntity; + +public interface OnboardingControllerDocs { + + @Operation(summary = "온보딩 완료", description = "언어, 예측 정확도, 기상/취침 시간을 저장하고 온보딩을 완료 처리합니다.") + ResponseEntity> completeOnboarding( + CustomUserDetails userDetails, + OnboardingRequest 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 index fe2f4d97..7b11a4dc 100644 --- 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 @@ -7,6 +7,7 @@ import jakarta.validation.constraints.Pattern; public record OnboardingRequest( + @NotNull(message = "language는 필수입니다.") Language language, From 475e62c5d1cf29e89806a6f76b2d46ec327909ae Mon Sep 17 00:00:00 2001 From: jy000n Date: Wed, 8 Jul 2026 01:14:04 +0900 Subject: [PATCH 127/383] =?UTF-8?q?feat:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EC=9D=91=EB=8B=B5=20=EC=A1=B0=EB=A6=BD?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20OnboardingResponseFactory=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/OnboardingController.java | 5 ++--- .../user/factory/OnboardingResponseFactory.java | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/domain/user/factory/OnboardingResponseFactory.java 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 index 82a59fd6..cc9df4e7 100644 --- a/src/main/java/com/Timo/Timo/domain/user/controller/OnboardingController.java +++ b/src/main/java/com/Timo/Timo/domain/user/controller/OnboardingController.java @@ -4,6 +4,7 @@ import com.Timo.Timo.domain.user.dto.request.OnboardingRequest; import com.Timo.Timo.domain.user.dto.response.OnboardingResponse; import com.Timo.Timo.domain.user.exception.UserSuccessCode; +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; @@ -34,8 +35,6 @@ public ResponseEntity> completeOnboarding( Long userId = userDetails.getUserId(); OnboardingResponse response = onboardingService.completeOnboarding(userId, request); - return ResponseEntity.ok( - BaseResponse.onSuccess(UserSuccessCode.ONBOARDING_COMPLETED, response) - ); + return OnboardingResponseFactory.completed(response); } } 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) + ); + } +} From 1613e08e063eeda4d23f55a54d11339c0fc04359 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 02:27:25 +0900 Subject: [PATCH 128/383] =?UTF-8?q?fix(todo):=20=EC=BB=AC=EB=A0=89?= =?UTF-8?q?=EC=85=98=20getter=20=EB=B0=A9=EC=96=B4=EC=A0=81=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/domain/todo/entity/Todo.java | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 index 4c0e5644..a8efb38c 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java +++ b/src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java @@ -2,6 +2,7 @@ import java.time.LocalDate; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import com.Timo.Timo.domain.todo.enums.RepeatType; @@ -54,6 +55,7 @@ public class Todo extends BaseTimeEntity { @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<>(); @@ -67,6 +69,7 @@ public class Todo extends BaseTimeEntity { @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) @@ -157,6 +160,14 @@ public static Todo create( return todo; } + public List getSubtasks() { + return Collections.unmodifiableList(subtasks); + } + + public List getRepeatWeekdays() { + return Collections.unmodifiableList(repeatWeekdays); + } + private void addSubtask(Subtask subtask) { this.subtasks.add(subtask); subtask.assignTodo(this); From 8a718fbe21626feceb5e1d13d005ff7f142def82 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 02:28:17 +0900 Subject: [PATCH 129/383] =?UTF-8?q?fix(todo):=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 4d880f40..eae57c03 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java @@ -13,7 +13,7 @@ public enum TodoErrorCode implements BaseErrorCode { INVALID_REQUEST(HttpStatus.BAD_REQUEST, "TODO_400", "필수 필드가 누락되었거나 형식이 올바르지 않습니다."), INVALID_TITLE(HttpStatus.BAD_REQUEST, "TODO_400", "투두명은 한국어 20자/영어 30자를 초과할 수 없습니다."), - MAX_COUNT_EXCEEDED(HttpStatus.CONFLICT, "COMMON_409", "해당 날짜의 투두가 최대 개수(20개)를 초과했습니다."); + MAX_COUNT_EXCEEDED(HttpStatus.CONFLICT, "TODO_409", "해당 날짜의 투두가 최대 개수(20개)를 초과했습니다."); private final HttpStatus httpStatus; private final String code; From 440e30f1bb6347dc5492f3db639f20df7ce328a3 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 02:37:24 +0900 Subject: [PATCH 130/383] =?UTF-8?q?fix(todo):=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=EC=97=90=20=EC=9A=B0=EC=84=A0=EC=88=9C?= =?UTF-8?q?=EC=9C=84=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/todo/exception/TodoExceptionHandler.java | 3 +++ 1 file changed, 3 insertions(+) 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 index 1981141e..6e0e659d 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoExceptionHandler.java +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoExceptionHandler.java @@ -2,6 +2,8 @@ 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; @@ -14,6 +16,7 @@ import jakarta.servlet.http.HttpServletRequest; @RestControllerAdvice(assignableTypes = TodoController.class) +@Order(Ordered.HIGHEST_PRECEDENCE) public class TodoExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) From 3e91ff20991929c17dabf769cd8df06f6883b972 Mon Sep 17 00:00:00 2001 From: jy000n Date: Wed, 8 Jul 2026 02:54:14 +0900 Subject: [PATCH 131/383] =?UTF-8?q?fix:=20Swagger=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EB=B0=8F=20=ED=86=A0=ED=81=B0=20=EC=9E=AC=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20API=20=EC=9D=B8=EC=A6=9D=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/global/auth/docs/AuthControllerDocs.java | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) 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 index b941270f..dcb4fad6 100644 --- a/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java +++ b/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java @@ -13,9 +13,8 @@ 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.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityRequirements; import jakarta.servlet.http.HttpServletRequest; -import java.util.Map; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.RequestBody; @@ -32,7 +31,8 @@ public interface AuthControllerDocs { - RefreshToken과 sessionId는 Set-Cookie 헤더로 전달되며, 응답 바디에는 포함되지 않음 """ ) - @io.swagger.v3.oas.annotations.parameters.RequestBody( + @SecurityRequirements + @io.swagger.v3.oas.annotations.parameters.RequestBody( content = @Content( mediaType = "application/json", schema = @Schema(implementation = AuthTokenRequest.class), @@ -98,6 +98,7 @@ ResponseEntity> token( """ ) + @SecurityRequirements @ApiResponses({ @ApiResponse( responseCode = "200", @@ -139,8 +140,7 @@ ResponseEntity> reissue( description = """ 현재 세션을 로그아웃하고 RefreshToken 및 sessionId 쿠키를 만료시킵니다. Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. - """, - security = @SecurityRequirement(name = "bearerAuth") + """ ) @ApiResponses({ @ApiResponse( @@ -177,8 +177,7 @@ ResponseEntity> logout( 회원 탈퇴를 진행하며, 사용자와 관련된 모든 데이터를 영구 삭제합니다. Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. 이 작업은 되돌릴 수 없습니다. - """, - security = @SecurityRequirement(name = "bearerAuth") + """ ) @ApiResponses({ @ApiResponse( From efc6fd630ccacd655a02a42d71609cfd9924eee3 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 03:16:35 +0900 Subject: [PATCH 132/383] =?UTF-8?q?fix(todo):=20Todo=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EC=8B=9C=20=EB=8F=99=EC=8B=9C=20=EC=9A=A9=EB=9F=89=20?= =?UTF-8?q?=EC=B4=88=EA=B3=BC=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/todo/service/TodoService.java | 2 +- .../Timo/Timo/domain/user/repository/UserRepository.java | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) 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 index 35e74bc9..867d92fb 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java @@ -33,7 +33,7 @@ public class TodoService { @Transactional public TodoCreateResponse createTodo(Long userId, TodoCreateRequest request) { - User user = userRepository.findById(userId) + User user = userRepository.findByIdForUpdate(userId) .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); validateTagExists(request.tagId()); 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..22c72f87 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,17 @@ import com.Timo.Timo.domain.user.entity.User; import com.Timo.Timo.domain.user.enums.Provider; +import jakarta.persistence.LockModeType; 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.repository.query.Param; public interface UserRepository extends JpaRepository { Optional findByProviderAndProviderId(Provider provider, String providerId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select u from User u where u.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); } From 7b7d784793de78bb5de6f3fbb24c49ae2bee7484 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 03:39:50 +0900 Subject: [PATCH 133/383] =?UTF-8?q?fix(todo):=20=EB=B0=98=EB=B3=B5?= =?UTF-8?q?=EC=9D=B4=20=EC=97=86=EB=8A=94=20todo=EB=8A=94=20=ED=95=98?= =?UTF-8?q?=EB=A3=A8=20=EA=B7=9C=EC=B9=99=EC=9C=BC=EB=A1=9C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/todo/service/TodoService.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 index 867d92fb..e61b1023 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java @@ -11,6 +11,7 @@ import com.Timo.Timo.domain.todo.dto.request.TodoCreateRequest; import com.Timo.Timo.domain.todo.dto.response.TodoCreateResponse; import com.Timo.Timo.domain.todo.entity.Todo; +import com.Timo.Timo.domain.todo.enums.RepeatType; import com.Timo.Timo.domain.todo.repository.TodoRepository; import com.Timo.Timo.domain.todo.vo.Duration; import com.Timo.Timo.domain.user.entity.User; @@ -43,7 +44,7 @@ public TodoCreateResponse createTodo(Long userId, TodoCreateRequest request) { todoCapacityChecker.assertCapacity(userId, todoDates); LocalDate startDate = request.date(); - LocalDate endDate = request.date().plus(TodoDateCalculator.REPEAT_PERIOD); + LocalDate endDate = resolveEndDate(startDate, request.repeatType()); Todo todo = Todo.create( user, @@ -70,4 +71,11 @@ private void validateTagExists(Long 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); + } } From 9ca6fa9c98decbf026082a8cec2fd8d2e9b96cab Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 03:48:33 +0900 Subject: [PATCH 134/383] =?UTF-8?q?fix(todo):=20=EC=86=8C=EC=9A=94=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=EC=9D=B4=20=ED=81=B0=20=EA=B0=92=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=93=A4=EC=96=B4=EC=99=94=EC=9D=84=20=EB=95=8C=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/domain/todo/vo/Duration.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 index 727de30c..bb3e6e03 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/vo/Duration.java +++ b/src/main/java/com/Timo/Timo/domain/todo/vo/Duration.java @@ -20,8 +20,12 @@ public static Duration parse(String value) { throw new CustomException(TodoErrorCode.INVALID_REQUEST); } - int minutes = Integer.parseInt(matcher.group(1)); - int seconds = Integer.parseInt(matcher.group(2)); - return new Duration(minutes * 60 + seconds); + 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); + } } } From d23d87d7d8163de4a4c3466127bbd5bb70408822 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 03:57:05 +0900 Subject: [PATCH 135/383] =?UTF-8?q?fix(todo):=20=EB=9D=BD=20=EA=B2=BD?= =?UTF-8?q?=ED=95=A9=20=EC=8B=9C=20=EB=8C=80=EA=B8=B0=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=EC=98=88=EC=99=B8=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/repository/UserRepository.java | 6 ++++++ .../global/exception/GlobalExceptionHandler.java | 15 +++++++++++++++ .../Timo/global/exception/code/ErrorCode.java | 1 + 3 files changed, 22 insertions(+) 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 22c72f87..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 @@ -3,16 +3,22 @@ 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/global/exception/GlobalExceptionHandler.java b/src/main/java/com/Timo/Timo/global/exception/GlobalExceptionHandler.java index c81aee51..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,6 +2,7 @@ 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; @@ -13,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; @@ -52,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; From 5bdc95e53c365f85c47bbc4b0f4bdc1da21d0d53 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Tue, 7 Jul 2026 02:30:47 +0900 Subject: [PATCH 136/383] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=96=B8=EC=96=B4=20=EB=B3=80=EA=B2=BD=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/dto/request/UpdateLanguageRequest.java | 13 +++++++++++++ .../user/dto/response/UpdateLanguageResponse.java | 11 +++++++++++ .../java/com/Timo/Timo/domain/user/entity/User.java | 4 ++++ .../Timo/domain/user/exception/UserSuccessCode.java | 3 ++- .../Timo/Timo/domain/user/service/UserService.java | 12 ++++++++++++ 5 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/Timo/Timo/domain/user/dto/request/UpdateLanguageRequest.java create mode 100644 src/main/java/com/Timo/Timo/domain/user/dto/response/UpdateLanguageResponse.java 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/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/entity/User.java b/src/main/java/com/Timo/Timo/domain/user/entity/User.java index 3d63452e..763e0be4 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 @@ -80,6 +80,10 @@ public void completeOnboarding() { this.onboardingCompleted = true; } + public void updateLanguage(Language language) { + this.language = language; + } + @Builder private User( Provider provider, 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 index 2f38574d..61c25b7d 100644 --- a/src/main/java/com/Timo/Timo/domain/user/exception/UserSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/user/exception/UserSuccessCode.java @@ -11,7 +11,8 @@ @RequiredArgsConstructor public enum UserSuccessCode implements BaseSuccessCode { - PROFILE_RETRIEVED(HttpStatus.OK, "프로필을 조회했습니다."); + PROFILE_RETRIEVED(HttpStatus.OK, "프로필을 조회했습니다."), + LANGUAGE_UPDATED(HttpStatus.OK, "언어설정이 수정되었습니다."); private final HttpStatus httpStatus; private final String message; 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 index 54cd6c8e..22b575e6 100644 --- a/src/main/java/com/Timo/Timo/domain/user/service/UserService.java +++ b/src/main/java/com/Timo/Timo/domain/user/service/UserService.java @@ -3,7 +3,9 @@ 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.response.UserProfileResponse; +import com.Timo.Timo.domain.user.dto.response.UpdateLanguageResponse; import com.Timo.Timo.domain.user.entity.User; import com.Timo.Timo.domain.user.exception.UserErrorCode; import com.Timo.Timo.domain.user.repository.UserRepository; @@ -24,4 +26,14 @@ public UserProfileResponse getMyProfile(Long userId) { 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()); + } } From d7b5453e9b5ff84fa00b36a359af1d008477ec31 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Tue, 7 Jul 2026 14:23:42 +0900 Subject: [PATCH 137/383] =?UTF-8?q?fix:=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserController.java | 7 +- .../domain/user/docs/UserLanguageDocs.java | 82 +++++++++++++++++++ ...ntrollerDocs.java => UserProfileDocs.java} | 3 +- 3 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/domain/user/docs/UserLanguageDocs.java rename src/main/java/com/Timo/Timo/domain/user/docs/{UserControllerDocs.java => UserProfileDocs.java} (98%) 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 index 1d6103a0..9796a191 100644 --- a/src/main/java/com/Timo/Timo/domain/user/controller/UserController.java +++ b/src/main/java/com/Timo/Timo/domain/user/controller/UserController.java @@ -1,15 +1,18 @@ package com.Timo.Timo.domain.user.controller; +import com.Timo.Timo.domain.user.docs.UserLanguageDocs; +import com.Timo.Timo.domain.user.docs.UserProfileDocs; 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.user.docs.UserControllerDocs; 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.exception.CustomException; +import com.Timo.Timo.global.exception.code.ErrorCode; import com.Timo.Timo.global.auth.principal.CustomUserDetails; import com.Timo.Timo.global.response.BaseResponse; @@ -20,7 +23,7 @@ @RequestMapping("/api/v1/users") @RequiredArgsConstructor @Tag(name = "User", description = "사용자 API") -public class UserController implements UserControllerDocs { +public class UserController implements UserProfileDocs, UserLanguageDocs { private final UserService userService; 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..b19ce021 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/docs/UserLanguageDocs.java @@ -0,0 +1,82 @@ +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.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.SecurityRequirement; + +public interface UserLanguageDocs { + + @Operation( + summary = "서비스 언어 수정", + description = """ + 현재 로그인한 사용자의 서비스 언어를 변경합니다. + + 변경 가능한 값은 `KO`, `EN`입니다. + 이름, 이메일, 프로필 이미지는 구글 계정 기반 정보이므로 해당 API에서 변경할 수 없습니다. + """, + security = @SecurityRequirement(name = "bearerAuth") + ) + @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/UserControllerDocs.java b/src/main/java/com/Timo/Timo/domain/user/docs/UserProfileDocs.java similarity index 98% rename from src/main/java/com/Timo/Timo/domain/user/docs/UserControllerDocs.java rename to src/main/java/com/Timo/Timo/domain/user/docs/UserProfileDocs.java index a73da22c..f0da8d82 100644 --- a/src/main/java/com/Timo/Timo/domain/user/docs/UserControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/user/docs/UserProfileDocs.java @@ -15,7 +15,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.security.SecurityRequirement; -public interface UserControllerDocs { +public interface UserProfileDocs { @Operation( summary = "내 프로필 조회", @@ -55,7 +55,6 @@ public interface UserControllerDocs { content = @Content( mediaType = "application/json", schema = @Schema(implementation = ErrorDto.class) - ) ) }) From 1f69e389fc946f8a2c37145b796a03c0e22044cb Mon Sep 17 00:00:00 2001 From: aneykrap Date: Tue, 7 Jul 2026 02:33:32 +0900 Subject: [PATCH 138/383] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EA=B3=B5=EB=B0=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/Timo/Timo/domain/user/enums/Language.java | 1 - 1 file changed, 1 deletion(-) 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 index 995ce5c7..31483389 100644 --- a/src/main/java/com/Timo/Timo/domain/user/enums/Language.java +++ b/src/main/java/com/Timo/Timo/domain/user/enums/Language.java @@ -1,7 +1,6 @@ package com.Timo.Timo.domain.user.enums; public enum Language { - KO, EN } From 387248d7a1b9c4d449328fad5cf6b27be2192e36 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Tue, 7 Jul 2026 15:38:36 +0900 Subject: [PATCH 139/383] =?UTF-8?q?fix:=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserController.java | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) 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 index 9796a191..770e169d 100644 --- a/src/main/java/com/Timo/Timo/domain/user/controller/UserController.java +++ b/src/main/java/com/Timo/Timo/domain/user/controller/UserController.java @@ -5,18 +5,21 @@ 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.response.UpdateLanguageResponse; 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.exception.CustomException; -import com.Timo.Timo.global.exception.code.ErrorCode; 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 @@ -39,4 +42,18 @@ public ResponseEntity> getMyProfile( 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) + ); + } } From 3cb414920e0a81f52eb57d7343c0eecb39c59456 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Tue, 7 Jul 2026 16:01:38 +0900 Subject: [PATCH 140/383] =?UTF-8?q?fix:=20Swagger=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=EC=97=90=20=EB=B3=B4=EC=95=88=20=EC=9A=94=EA=B5=AC=20=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/user/docs/UserLanguageDocs.java | 5 +---- .../java/com/Timo/Timo/domain/user/docs/UserProfileDocs.java | 4 +--- .../com/Timo/Timo/global/auth/controller/AuthController.java | 4 +++- src/main/java/com/Timo/Timo/global/config/SwaggerConfig.java | 2 ++ 4 files changed, 7 insertions(+), 8 deletions(-) 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 index b19ce021..4455f852 100644 --- a/src/main/java/com/Timo/Timo/domain/user/docs/UserLanguageDocs.java +++ b/src/main/java/com/Timo/Timo/domain/user/docs/UserLanguageDocs.java @@ -11,11 +11,9 @@ 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.SecurityRequirement; public interface UserLanguageDocs { @@ -26,8 +24,7 @@ public interface UserLanguageDocs { 변경 가능한 값은 `KO`, `EN`입니다. 이름, 이메일, 프로필 이미지는 구글 계정 기반 정보이므로 해당 API에서 변경할 수 없습니다. - """, - security = @SecurityRequirement(name = "bearerAuth") + """ ) @ApiResponses({ @ApiResponse( 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 index f0da8d82..4c114e65 100644 --- a/src/main/java/com/Timo/Timo/domain/user/docs/UserProfileDocs.java +++ b/src/main/java/com/Timo/Timo/domain/user/docs/UserProfileDocs.java @@ -13,7 +13,6 @@ 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.SecurityRequirement; public interface UserProfileDocs { @@ -24,8 +23,7 @@ public interface UserProfileDocs { Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. Access Token의 사용자 ID와 일치하는 사용자의 정보가 반환됩니다. - """, - security = @SecurityRequirement(name = "bearerAuth") + """ ) @ApiResponses({ @ApiResponse( 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..09c0af30 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 @@ -7,6 +7,7 @@ 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.security.SecurityRequirements; import io.swagger.v3.oas.annotations.tags.Tag; import java.util.Map; import lombok.RequiredArgsConstructor; @@ -25,6 +26,7 @@ public class AuthController { private final AuthService authService; @Operation(summary = "AccessToken 발급", description = "1회성 code로 AccessToken을 발급합니다.") + @SecurityRequirements @ApiResponses({ @ApiResponse(responseCode = "200", description = "로그인 성공"), @ApiResponse(responseCode = "400", description = "code 누락"), @@ -43,4 +45,4 @@ public ResponseEntity> token( .header("Cache-Control", "no-store") .body(BaseResponse.onSuccess(AuthSuccessCode.LOGIN_SUCCESS, authTokenResponse)); } -} \ No newline at end of file +} 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 a45550e9..28ac02d8 100644 --- a/src/main/java/com/Timo/Timo/global/config/SwaggerConfig.java +++ b/src/main/java/com/Timo/Timo/global/config/SwaggerConfig.java @@ -3,6 +3,7 @@ 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; @@ -24,6 +25,7 @@ public OpenAPI customOpenAPI() { .scheme("bearer") .bearerFormat("JWT") )) + .addSecurityItem(new SecurityRequirement().addList(BEARER_AUTH)) .info(new Info() .title("Timo API") .description("Timo 서버 API 명세서") From 06e58f7244d48c28a1dc9308da5d43693ddd5d20 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 17:03:10 +0900 Subject: [PATCH 141/383] =?UTF-8?q?feat:=20ReissueResult=20DTO=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/dto/ReissueResult.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/global/auth/dto/ReissueResult.java 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; +} From cddb4dd8c2fd9c59c1507f2af96f1e2ad9a3ead0 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 17:34:16 +0900 Subject: [PATCH 142/383] =?UTF-8?q?feat:=20AuthService=EC=97=90=20?= =?UTF-8?q?=EC=9E=AC=EB=B0=9C=EA=B8=89=20=EB=B9=84=EC=A6=88=EB=8B=88?= =?UTF-8?q?=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/global/auth/service/AuthService.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) 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..00a9008e 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; @@ -18,8 +19,10 @@ public class AuthService { private final AuthCodeService authCodeService; private final JwtTokenProvider jwtTokenProvider; private final UserRepository userRepository; + private final RefreshTokenService refreshTokenService; public AuthTokenResponse exchangeCodeForToken(String code) { + if (code == null) { throw new CustomException(ErrorCode.BAD_REQUEST); } @@ -52,4 +55,26 @@ 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 (!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); + } } From 8bfe4a42fbbeabdbb0d545a52c6354ae2bdbc771 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 17:53:52 +0900 Subject: [PATCH 143/383] =?UTF-8?q?refactor:=20Auth=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/exception/AuthErrorCode.java | 6 +++--- .../Timo/Timo/global/auth/exception/AuthSuccessCode.java | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) 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; From fa3a27bf26f7839a4f4a979df4ed94c526324961 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 17:59:16 +0900 Subject: [PATCH 144/383] =?UTF-8?q?feat:=20=EC=BF=A0=ED=82=A4=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1/=EB=A7=8C=EB=A3=8C=20=EA=B3=B5=ED=86=B5=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=20CookieUtil=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/global/auth/utils/CookieUtil.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/global/auth/utils/CookieUtil.java 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..5cc4a54a --- /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("/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("/auth") + .maxAge(0) + .sameSite("Strict") + .build(); + } +} From 83db2918e75c9bc23c8f2f27b1e66075d7abac46 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 18:00:35 +0900 Subject: [PATCH 145/383] =?UTF-8?q?refactor:=20OAuthSuccessHandler=20?= =?UTF-8?q?=EC=BF=A0=ED=82=A4=20=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=9D=84=20CookieUtil=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/handler/OAuthSuccessHandler.java | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) 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 29d81d55..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; @@ -45,23 +44,12 @@ public void onAuthenticationSuccess( 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), From cc7a2f8acef797327496449eb3c921bf860e91b5 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 18:18:52 +0900 Subject: [PATCH 146/383] =?UTF-8?q?feat:=20AccessToken=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20API=20=EA=B5=AC=ED=98=84=20(refreshToken?= =?UTF-8?q?=20rotation=20=EC=A0=81=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 39 +++++++++++++++++++ .../Timo/global/auth/service/AuthService.java | 1 + 2 files changed, 40 insertions(+) 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 09c0af30..c619d315 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,8 +1,12 @@ package com.Timo.Timo.global.auth.controller; +import com.Timo.Timo.global.auth.dto.ReissueResult; import com.Timo.Timo.global.auth.dto.response.AuthTokenResponse; import com.Timo.Timo.global.auth.exception.AuthSuccessCode; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; import com.Timo.Timo.global.auth.service.AuthService; +import com.Timo.Timo.global.auth.utils.CookieUtil; +import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; import com.Timo.Timo.global.response.BaseResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -11,7 +15,11 @@ import io.swagger.v3.oas.annotations.tags.Tag; import java.util.Map; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; 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.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -24,6 +32,10 @@ public class AuthController { private final AuthService authService; + private final JwtTokenProvider jwtTokenProvider; + + @Value("${app.auth.cookie-secure}") + private boolean cookieSecure; @Operation(summary = "AccessToken 발급", description = "1회성 code로 AccessToken을 발급합니다.") @SecurityRequirements @@ -45,4 +57,31 @@ public ResponseEntity> token( .header("Cache-Control", "no-store") .body(BaseResponse.onSuccess(AuthSuccessCode.LOGIN_SUCCESS, authTokenResponse)); } + + @Operation(summary = "AccessToken 재발급", description = "RefreshToken으로 AccessToken을 재발급합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "재발급 성공"), + @ApiResponse(responseCode = "401", description = "유효하지 않거나 만료된 리프레시 토큰"), + @ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + @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 ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, + CookieUtil.createCookie("refreshToken", result.getRefreshToken(), + jwtTokenProvider.getRefreshTokenExpiry(), cookieSecure).toString()) + .header(HttpHeaders.SET_COOKIE, + CookieUtil.createCookie("sessionId", result.getSessionId(), + jwtTokenProvider.getRefreshTokenExpiry(), cookieSecure).toString()) + .header("Cache-Control", "no-store") + .body(BaseResponse.onSuccess(AuthSuccessCode.REISSUE_SUCCESS, + Map.of("accessToken", result.getAccessToken()))); + } + + } 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 00a9008e..0398dd6d 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 @@ -77,4 +77,5 @@ public ReissueResult reissue(String refreshToken, String sessionId) { return new ReissueResult(newAccessToken, newRefreshToken, newSessionId); } + } From b576c104e44b209b1cd073ed585608b4f1997f05 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 18:33:59 +0900 Subject: [PATCH 147/383] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 19 +++++++++++++++++++ .../Timo/global/auth/service/AuthService.java | 3 +++ 2 files changed, 22 insertions(+) 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 c619d315..49d8d032 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 @@ -83,5 +83,24 @@ public ResponseEntity>> reissue( Map.of("accessToken", result.getAccessToken()))); } + @Operation(summary = "로그아웃", description = "현재 세션을 로그아웃합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "로그아웃 성공"), + @ApiResponse(responseCode = "401", description = "인증이 필요합니다"), + @ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + @PostMapping("/logout") + public ResponseEntity> logout( + @AuthenticationPrincipal CustomUserDetails userDetails, + @CookieValue(name = "sessionId", required = false) String sessionId + ) { + authService.logout(userDetails.getUser().getId(), sessionId); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, + CookieUtil.expireCookie("refreshToken", cookieSecure).toString()) + .header(HttpHeaders.SET_COOKIE, + CookieUtil.expireCookie("sessionId", cookieSecure).toString()) + .body(BaseResponse.onSuccess(AuthSuccessCode.LOGOUT_SUCCESS, null)); + } } 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 0398dd6d..10c7f190 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 @@ -78,4 +78,7 @@ public ReissueResult reissue(String refreshToken, String sessionId) { return new ReissueResult(newAccessToken, newRefreshToken, newSessionId); } + public void logout(Long userId, String sessionId) { + refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); + } } From 81fce48e3f31b8e705c1f51d11a9f8320f242797 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 18:34:46 +0900 Subject: [PATCH 148/383] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 24 +++++++++++++++++++ .../Timo/global/auth/service/AuthService.java | 11 +++++++++ 2 files changed, 35 insertions(+) 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 49d8d032..594aa957 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 @@ -20,6 +20,7 @@ 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; @@ -103,4 +104,27 @@ public ResponseEntity> logout( CookieUtil.expireCookie("sessionId", cookieSecure).toString()) .body(BaseResponse.onSuccess(AuthSuccessCode.LOGOUT_SUCCESS, null)); } + + @Operation(summary = "회원 탈퇴", description = "회원 탈퇴 및 모든 데이터를 영구 삭제합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "회원 탈퇴 성공"), + @ApiResponse(responseCode = "401", description = "인증이 필요합니다"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 사용자"), + @ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + + @DeleteMapping("/withdraw") + public ResponseEntity> withdraw( + @AuthenticationPrincipal CustomUserDetails userDetails, + @CookieValue(name = "sessionId", required = false) String sessionId + ) { + authService.withdraw(userDetails.getUser().getId(), sessionId); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, + CookieUtil.expireCookie("refreshToken", cookieSecure).toString()) + .header(HttpHeaders.SET_COOKIE, + CookieUtil.expireCookie("sessionId", cookieSecure).toString()) + .body(BaseResponse.onSuccess(AuthSuccessCode.WITHDRAW_SUCCESS, null)); + } } 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 10c7f190..fd6922e4 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 @@ -81,4 +81,15 @@ public ReissueResult reissue(String refreshToken, String sessionId) { public void logout(Long userId, String sessionId) { refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); } + + public void withdraw(Long userId, String sessionId) { + if (sessionId != null) { + refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); + } + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + userRepository.delete(user); + } } From b96407faf0eff72b1de2db604e0826d712491d30 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 18:37:09 +0900 Subject: [PATCH 149/383] =?UTF-8?q?refactor:=20Auth=20API=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/global/auth/controller/AuthController.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 594aa957..c1ec9e10 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 @@ -28,7 +28,7 @@ @Tag(name = "Auth", description = "인증 관련 API") @RestController -@RequestMapping("/api/v1/auth") +@RequestMapping("/api/v1") @RequiredArgsConstructor public class AuthController { @@ -47,7 +47,7 @@ public class AuthController { @ApiResponse(responseCode = "404", description = "존재하지 않는 사용자"), @ApiResponse(responseCode = "500", description = "서버 내부 오류") }) - @PostMapping("/token") + @PostMapping("/auth/token") public ResponseEntity> token( @RequestBody Map body ) { @@ -65,7 +65,7 @@ public ResponseEntity> token( @ApiResponse(responseCode = "401", description = "유효하지 않거나 만료된 리프레시 토큰"), @ApiResponse(responseCode = "500", description = "서버 내부 오류") }) - @PostMapping("/reissue") + @PostMapping("/auth/reissue") public ResponseEntity>> reissue( @CookieValue(name = "refreshToken", required=false) String refreshToken, @CookieValue(name = "sessionId", required = false) String sessionId @@ -90,7 +90,7 @@ public ResponseEntity>> reissue( @ApiResponse(responseCode = "401", description = "인증이 필요합니다"), @ApiResponse(responseCode = "500", description = "서버 내부 오류") }) - @PostMapping("/logout") + @PostMapping("/auth/logout") public ResponseEntity> logout( @AuthenticationPrincipal CustomUserDetails userDetails, @CookieValue(name = "sessionId", required = false) String sessionId @@ -113,7 +113,7 @@ public ResponseEntity> logout( @ApiResponse(responseCode = "500", description = "서버 내부 오류") }) - @DeleteMapping("/withdraw") + @DeleteMapping("/users") public ResponseEntity> withdraw( @AuthenticationPrincipal CustomUserDetails userDetails, @CookieValue(name = "sessionId", required = false) String sessionId From f34eeb421c7a23b2d3666118e1912de593839678 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 21:21:52 +0900 Subject: [PATCH 150/383] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=EB=90=9C=20accessToken=20=EB=B8=94=EB=9E=99=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B4=80=EB=A6=AC=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20BlacklistService=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/service/BlackListService.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java 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..32be323d --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java @@ -0,0 +1,28 @@ +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){ + redisTemplate.opsForValue().set( + KEY_PREFIX + accessToken, + "logout", + remainingExpiry, + TimeUnit.MILLISECONDS + ); + } + + public boolean isBlackListed(String accessToken){ + return Boolean.TRUE.equals(redisTemplate.hasKey(KEY_PREFIX + accessToken)); + } +} From 989d4ab119299294027a09b7ee88a70c551c1554 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 21:26:36 +0900 Subject: [PATCH 151/383] =?UTF-8?q?feat:=20JwtTokenProvider=EC=97=90=20get?= =?UTF-8?q?RemainingExpiry=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/service/BlackListService.java | 2 +- .../com/Timo/Timo/global/jwt/provider/JwtTokenProvider.java | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) 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 index 32be323d..d13f91bb 100644 --- a/src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java +++ b/src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java @@ -18,7 +18,7 @@ public void addToBlackList(String accessToken, long remainingExpiry){ KEY_PREFIX + accessToken, "logout", remainingExpiry, - TimeUnit.MILLISECONDS + TimeUnit.SECONDS ); } 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; + } } From d2f11a61e8e96ffc724064a697d7146923a48217 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 21:31:30 +0900 Subject: [PATCH 152/383] =?UTF-8?q?feat:=20JwtAuthenticationFilter?= =?UTF-8?q?=EC=97=90=20=EB=B8=94=EB=9E=99=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/global/jwt/filter/JwtAuthenticationFilter.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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..a158f52d 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,7 @@ 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.jwt.provider.JwtTokenProvider; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -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( @@ -33,7 +35,9 @@ protected void doFilterInternal( String token = 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()); From 9424cbf2247890901c7ebc9aab0ffdbe32adcd06 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 21:43:24 +0900 Subject: [PATCH 153/383] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=8B=9C=20Access=20Token=20=EB=B8=94=EB=9E=99?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/controller/AuthController.java | 16 ++++++++++++++-- .../Timo/global/auth/service/AuthService.java | 7 ++++++- 2 files changed, 20 insertions(+), 3 deletions(-) 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 c1ec9e10..d3dca738 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 @@ -13,12 +13,14 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.security.SecurityRequirements; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -93,9 +95,11 @@ public ResponseEntity>> reissue( @PostMapping("/auth/logout") public ResponseEntity> logout( @AuthenticationPrincipal CustomUserDetails userDetails, - @CookieValue(name = "sessionId", required = false) String sessionId + @CookieValue(name = "sessionId", required = false) String sessionId, + HttpServletRequest request ) { - authService.logout(userDetails.getUser().getId(), sessionId); + String accessToken = resolveToken(request); + authService.logout(accessToken, userDetails.getUser().getId(), sessionId); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, @@ -127,4 +131,12 @@ public ResponseEntity> withdraw( CookieUtil.expireCookie("sessionId", cookieSecure).toString()) .body(BaseResponse.onSuccess(AuthSuccessCode.WITHDRAW_SUCCESS, null)); } + + 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/auth/service/AuthService.java b/src/main/java/com/Timo/Timo/global/auth/service/AuthService.java index fd6922e4..ad0451ec 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 @@ -20,6 +20,7 @@ public class AuthService { private final JwtTokenProvider jwtTokenProvider; private final UserRepository userRepository; private final RefreshTokenService refreshTokenService; + private final BlackListService blackListService; public AuthTokenResponse exchangeCodeForToken(String code) { @@ -78,7 +79,11 @@ public ReissueResult reissue(String refreshToken, String sessionId) { return new ReissueResult(newAccessToken, newRefreshToken, newSessionId); } - public void logout(Long userId, String sessionId) { + public void logout(String accessToken, Long userId, String sessionId) { + if(accessToken !=null){ + long remainingExpiry = jwtTokenProvider.getRemainingExpiry(accessToken); + blackListService.addToBlackList(accessToken, remainingExpiry); + } refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); } From 26ac5edb55ef3cc844362db1eed8327c744597ea Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 21:48:07 +0900 Subject: [PATCH 154/383] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EC=8B=9C=20Access=20Token=20=EB=B8=94=EB=9E=99?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/controller/AuthController.java | 6 ++++-- .../Timo/global/auth/service/AuthService.java | 18 +++++++++++++----- .../global/auth/service/BlackListService.java | 2 +- 3 files changed, 18 insertions(+), 8 deletions(-) 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 d3dca738..830b4ff5 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 @@ -120,9 +120,11 @@ public ResponseEntity> logout( @DeleteMapping("/users") public ResponseEntity> withdraw( @AuthenticationPrincipal CustomUserDetails userDetails, - @CookieValue(name = "sessionId", required = false) String sessionId + @CookieValue(name = "sessionId", required = false) String sessionId, + HttpServletRequest request ) { - authService.withdraw(userDetails.getUser().getId(), sessionId); + String accessToken = resolveToken(request); + authService.withdraw(accessToken, userDetails.getUser().getId(), sessionId); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, 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 ad0451ec..f85596cc 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 @@ -20,7 +20,7 @@ public class AuthService { private final JwtTokenProvider jwtTokenProvider; private final UserRepository userRepository; private final RefreshTokenService refreshTokenService; - private final BlackListService blackListService; + private final BlackListService blacklistService; public AuthTokenResponse exchangeCodeForToken(String code) { @@ -80,14 +80,22 @@ public ReissueResult reissue(String refreshToken, String sessionId) { } public void logout(String accessToken, Long userId, String sessionId) { - if(accessToken !=null){ + if (accessToken != null) { long remainingExpiry = jwtTokenProvider.getRemainingExpiry(accessToken); - blackListService.addToBlackList(accessToken, remainingExpiry); + blacklistService.addToBlacklist(accessToken, remainingExpiry); + } + + if (sessionId != null) { + refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); } - refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); } - public void withdraw(Long userId, String sessionId) { + public void withdraw(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); } 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 index d13f91bb..f20b1277 100644 --- a/src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java +++ b/src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java @@ -13,7 +13,7 @@ public class BlackListService { private static final String KEY_PREFIX = "blacklist:"; - public void addToBlackList(String accessToken, long remainingExpiry){ + public void addToBlacklist(String accessToken, long remainingExpiry){ redisTemplate.opsForValue().set( KEY_PREFIX + accessToken, "logout", From 444f68f9744d8b2714df33eef0edbacdd964dcaf Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 21:49:23 +0900 Subject: [PATCH 155/383] =?UTF-8?q?fix:=20JwtAuthenticationFilter=20?= =?UTF-8?q?=EB=B8=94=EB=9E=99=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/global/jwt/filter/JwtAuthenticationFilter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a158f52d..56e11a1e 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 @@ -37,7 +37,7 @@ protected void doFilterInternal( if(token!=null && jwtTokenProvider.validateAccessToken(token) - && blacklistService.isBlackListed(token)){ + && !blacklistService.isBlackListed(token)){ Long userId = jwtTokenProvider.getUserId(token); userRepository.findById(userId).ifPresent(user -> { CustomUserDetails userDetails = new CustomUserDetails(user, Map.of()); From a24a68e5c186ccd8af841654cca5f7fc38bfb237 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 21:55:48 +0900 Subject: [PATCH 156/383] =?UTF-8?q?refactor:=20resolveToken=EC=9D=84=20Tok?= =?UTF-8?q?enExtractor=20=EC=9C=A0=ED=8B=B8=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/controller/AuthController.java | 14 +++----------- .../Timo/global/auth/utils/TokenExtractor.java | 15 +++++++++++++++ .../jwt/filter/JwtAuthenticationFilter.java | 12 ++---------- 3 files changed, 20 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/global/auth/utils/TokenExtractor.java 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 830b4ff5..bc3f7aa7 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 @@ -6,6 +6,7 @@ import com.Timo.Timo.global.auth.principal.CustomUserDetails; import com.Timo.Timo.global.auth.service.AuthService; import com.Timo.Timo.global.auth.utils.CookieUtil; +import com.Timo.Timo.global.auth.utils.TokenExtractor; import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; import com.Timo.Timo.global.response.BaseResponse; import io.swagger.v3.oas.annotations.Operation; @@ -20,7 +21,6 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -98,7 +98,7 @@ public ResponseEntity> logout( @CookieValue(name = "sessionId", required = false) String sessionId, HttpServletRequest request ) { - String accessToken = resolveToken(request); + String accessToken = TokenExtractor.resolveToken(request); authService.logout(accessToken, userDetails.getUser().getId(), sessionId); return ResponseEntity.ok() @@ -123,7 +123,7 @@ public ResponseEntity> withdraw( @CookieValue(name = "sessionId", required = false) String sessionId, HttpServletRequest request ) { - String accessToken = resolveToken(request); + String accessToken = TokenExtractor.resolveToken(request); authService.withdraw(accessToken, userDetails.getUser().getId(), sessionId); return ResponseEntity.ok() @@ -133,12 +133,4 @@ public ResponseEntity> withdraw( CookieUtil.expireCookie("sessionId", cookieSecure).toString()) .body(BaseResponse.onSuccess(AuthSuccessCode.WITHDRAW_SUCCESS, null)); } - - 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/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/jwt/filter/JwtAuthenticationFilter.java b/src/main/java/com/Timo/Timo/global/jwt/filter/JwtAuthenticationFilter.java index 56e11a1e..2fd9c70d 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 @@ -3,6 +3,7 @@ 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; @@ -33,7 +34,7 @@ protected void doFilterInternal( FilterChain filterChain ) throws ServletException, IOException { - String token = resolveToken(request); + String token = TokenExtractor.resolveToken(request); if(token!=null && jwtTokenProvider.validateAccessToken(token) @@ -52,13 +53,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; - } } From 23efe1c84b1776652bc51b7c2ebd823ef0388129 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 23:01:45 +0900 Subject: [PATCH 157/383] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20import=20=EB=B0=8F=20=EA=B3=B5=EB=B0=B1=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/global/auth/dto/response/AuthTokenResponse.java | 1 - .../Timo/Timo/global/auth/handler/AuthErrorResponseWriter.java | 1 - .../Timo/global/auth/handler/JwtAuthenticationEntryPoint.java | 2 +- .../com/Timo/Timo/global/auth/principal/CustomUserDetails.java | 1 - .../Timo/Timo/global/jwt/filter/JwtAuthenticationFilter.java | 1 - 5 files changed, 1 insertion(+), 5 deletions(-) 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/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/principal/CustomUserDetails.java b/src/main/java/com/Timo/Timo/global/auth/principal/CustomUserDetails.java index b3434ed2..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 @@ -49,5 +49,4 @@ public String getPassword(){ public String getUsername(){ return user.getEmail(); } - } 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 2fd9c70d..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 @@ -16,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 From c5d7457a8d26c051ff45df6d4f0139ff169e219f Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 23:09:28 +0900 Subject: [PATCH 158/383] =?UTF-8?q?fix:=20=EC=BF=A0=ED=82=A4=20path?= =?UTF-8?q?=EB=A5=BC=20/api/v1/auth=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/global/auth/service/AuthService.java | 2 +- src/main/java/com/Timo/Timo/global/auth/utils/CookieUtil.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 f85596cc..26a77033 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 @@ -58,7 +58,7 @@ public AuthTokenResponse exchangeCodeForToken(String code) { } public ReissueResult reissue(String refreshToken, String sessionId) { - + if (refreshToken == null || sessionId == null || !jwtTokenProvider.validateRefreshToken(refreshToken)) { throw new CustomException(AuthErrorCode.INVALID_REFRESH_TOKEN); 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 index 5cc4a54a..8f31f74a 100644 --- a/src/main/java/com/Timo/Timo/global/auth/utils/CookieUtil.java +++ b/src/main/java/com/Timo/Timo/global/auth/utils/CookieUtil.java @@ -9,7 +9,7 @@ public static ResponseCookie createCookie(String name, String value, long maxAge return ResponseCookie.from(name, value) .httpOnly(true) .secure(secure) - .path("/auth") + .path("/api/v1/auth") .maxAge(Duration.ofSeconds(maxAgeSeconds)) .sameSite("Strict") .build(); @@ -19,7 +19,7 @@ public static ResponseCookie expireCookie(String name, boolean secure) { return ResponseCookie.from(name, "") .httpOnly(true) .secure(secure) - .path("/auth") + .path("/api/v1/auth") .maxAge(0) .sameSite("Strict") .build(); From b084b5835891d0b3ff24ab352d3b1990d9d06f1e Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 6 Jul 2026 23:09:52 +0900 Subject: [PATCH 159/383] =?UTF-8?q?fix:=20=ED=83=88=ED=87=B4=20API=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/global/auth/controller/AuthController.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 bc3f7aa7..7a675624 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 @@ -30,7 +30,7 @@ @Tag(name = "Auth", description = "인증 관련 API") @RestController -@RequestMapping("/api/v1") +@RequestMapping("/api/v1/auth") @RequiredArgsConstructor public class AuthController { @@ -49,7 +49,7 @@ public class AuthController { @ApiResponse(responseCode = "404", description = "존재하지 않는 사용자"), @ApiResponse(responseCode = "500", description = "서버 내부 오류") }) - @PostMapping("/auth/token") + @PostMapping("/token") public ResponseEntity> token( @RequestBody Map body ) { @@ -67,7 +67,7 @@ public ResponseEntity> token( @ApiResponse(responseCode = "401", description = "유효하지 않거나 만료된 리프레시 토큰"), @ApiResponse(responseCode = "500", description = "서버 내부 오류") }) - @PostMapping("/auth/reissue") + @PostMapping("/reissue") public ResponseEntity>> reissue( @CookieValue(name = "refreshToken", required=false) String refreshToken, @CookieValue(name = "sessionId", required = false) String sessionId @@ -92,7 +92,7 @@ public ResponseEntity>> reissue( @ApiResponse(responseCode = "401", description = "인증이 필요합니다"), @ApiResponse(responseCode = "500", description = "서버 내부 오류") }) - @PostMapping("/auth/logout") + @PostMapping("/logout") public ResponseEntity> logout( @AuthenticationPrincipal CustomUserDetails userDetails, @CookieValue(name = "sessionId", required = false) String sessionId, @@ -117,7 +117,7 @@ public ResponseEntity> logout( @ApiResponse(responseCode = "500", description = "서버 내부 오류") }) - @DeleteMapping("/users") + @DeleteMapping("/withdraw") public ResponseEntity> withdraw( @AuthenticationPrincipal CustomUserDetails userDetails, @CookieValue(name = "sessionId", required = false) String sessionId, From d8d07914af39aca073f5f46dd166df6509d79e8a Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 00:15:56 +0900 Subject: [PATCH 160/383] =?UTF-8?q?refactor:=20=ED=83=88=ED=87=B4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=97=90=EC=84=9C=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=A1=B4=EC=9E=AC=20=EA=B2=80=EC=A6=9D=20=EC=88=9C?= =?UTF-8?q?=EC=84=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/service/AuthService.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 26a77033..bd09f6a8 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 @@ -58,7 +58,7 @@ public AuthTokenResponse exchangeCodeForToken(String code) { } public ReissueResult reissue(String refreshToken, String sessionId) { - + if (refreshToken == null || sessionId == null || !jwtTokenProvider.validateRefreshToken(refreshToken)) { throw new CustomException(AuthErrorCode.INVALID_REFRESH_TOKEN); @@ -91,6 +91,10 @@ public void logout(String accessToken, Long userId, String sessionId) { } public void withdraw(String accessToken, Long userId, String sessionId) { + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + if (accessToken != null) { long remainingExpiry = jwtTokenProvider.getRemainingExpiry(accessToken); blacklistService.addToBlacklist(accessToken, remainingExpiry); @@ -100,9 +104,6 @@ public void withdraw(String accessToken, Long userId, String sessionId) { refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); } - User user = userRepository.findById(userId) - .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); - userRepository.delete(user); } } From 1c8ea8bf12327bcb43c13afc4e5bc084991a6429 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 00:18:46 +0900 Subject: [PATCH 161/383] =?UTF-8?q?refactor:=20=ED=83=88=ED=87=B4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/global/auth/service/AuthService.java | 2 ++ 1 file changed, 2 insertions(+) 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 bd09f6a8..d3646954 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 @@ -11,6 +11,7 @@ import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -90,6 +91,7 @@ public void logout(String accessToken, Long userId, String sessionId) { } } + @Transactional public void withdraw(String accessToken, Long userId, String sessionId) { User user = userRepository.findById(userId) From 81b2eae7ccd1719cb511c886b786af6598c97953 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 00:20:14 +0900 Subject: [PATCH 162/383] =?UTF-8?q?fix:=20=EB=A7=8C=EB=A3=8C=EB=90=9C=20Ac?= =?UTF-8?q?cess=20Token=EC=9D=80=20=EB=B8=94=EB=9E=99=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=97=90=20=EC=A0=80=EC=9E=A5=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/service/BlackListService.java | 5 +++++ 1 file changed, 5 insertions(+) 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 index f20b1277..8e42a136 100644 --- a/src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java +++ b/src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java @@ -14,6 +14,11 @@ public class BlackListService { 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", From 493234d3cec6c5a17c021b70bceffb8112dde66b Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 02:31:51 +0900 Subject: [PATCH 163/383] =?UTF-8?q?refactor:=20Controller=EC=97=90?= =?UTF-8?q?=EC=84=9C=20Swagger=20docs=20=EC=9D=B8=ED=84=B0=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EB=B6=84=EB=A6=AC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 40 +--- .../global/auth/docs/AuthControllerDocs.java | 190 ++++++++++++++++++ 2 files changed, 196 insertions(+), 34 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java 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 7a675624..3a588fee 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,5 +1,6 @@ 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.response.AuthTokenResponse; import com.Timo.Timo.global.auth.exception.AuthSuccessCode; @@ -9,10 +10,6 @@ import com.Timo.Timo.global.auth.utils.TokenExtractor; import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; 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.security.SecurityRequirements; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import java.util.Map; @@ -32,7 +29,7 @@ @RestController @RequestMapping("/api/v1/auth") @RequiredArgsConstructor -public class AuthController { +public class AuthController implements AuthControllerDocs { private final AuthService authService; private final JwtTokenProvider jwtTokenProvider; @@ -40,15 +37,7 @@ public class AuthController { @Value("${app.auth.cookie-secure}") private boolean cookieSecure; - @Operation(summary = "AccessToken 발급", description = "1회성 code로 AccessToken을 발급합니다.") - @SecurityRequirements - @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 @@ -61,12 +50,7 @@ public ResponseEntity> token( .body(BaseResponse.onSuccess(AuthSuccessCode.LOGIN_SUCCESS, authTokenResponse)); } - @Operation(summary = "AccessToken 재발급", description = "RefreshToken으로 AccessToken을 재발급합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "재발급 성공"), - @ApiResponse(responseCode = "401", description = "유효하지 않거나 만료된 리프레시 토큰"), - @ApiResponse(responseCode = "500", description = "서버 내부 오류") - }) + @Override @PostMapping("/reissue") public ResponseEntity>> reissue( @CookieValue(name = "refreshToken", required=false) String refreshToken, @@ -86,12 +70,7 @@ public ResponseEntity>> reissue( Map.of("accessToken", result.getAccessToken()))); } - @Operation(summary = "로그아웃", description = "현재 세션을 로그아웃합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "로그아웃 성공"), - @ApiResponse(responseCode = "401", description = "인증이 필요합니다"), - @ApiResponse(responseCode = "500", description = "서버 내부 오류") - }) + @Override @PostMapping("/logout") public ResponseEntity> logout( @AuthenticationPrincipal CustomUserDetails userDetails, @@ -109,14 +88,7 @@ public ResponseEntity> logout( .body(BaseResponse.onSuccess(AuthSuccessCode.LOGOUT_SUCCESS, null)); } - @Operation(summary = "회원 탈퇴", description = "회원 탈퇴 및 모든 데이터를 영구 삭제합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "회원 탈퇴 성공"), - @ApiResponse(responseCode = "401", description = "인증이 필요합니다"), - @ApiResponse(responseCode = "404", description = "존재하지 않는 사용자"), - @ApiResponse(responseCode = "500", description = "서버 내부 오류") - }) - + @Override @DeleteMapping("/withdraw") public ResponseEntity> withdraw( @AuthenticationPrincipal CustomUserDetails userDetails, 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..98232929 --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java @@ -0,0 +1,190 @@ +package com.Timo.Timo.global.auth.docs; + +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.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Map; +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으로 교환합니다. + 최초 로그인인 경우 isNewUser가 true로 반환됩니다. + RefreshToken은 Set-Cookie 헤더로 전달됩니다. + """ + ) + @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 Map body + ); + + @Operation( + summary = "AccessToken 재발급", + description = """ + 쿠키로 전달된 RefreshToken과 sessionId를 검증하여 AccessToken을 재발급합니다. + 재발급 성공 시 RefreshToken과 sessionId 쿠키가 갱신됩니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "재발급 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "401", + 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 쿠키를 만료시킵니다. + Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. + """, + security = @SecurityRequirement(name = "bearerAuth") + ) + @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 = """ + 회원 탈퇴를 진행하며, 사용자와 관련된 모든 데이터를 영구 삭제합니다. + Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. + 이 작업은 되돌릴 수 없습니다. + """, + security = @SecurityRequirement(name = "bearerAuth") + ) + @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 + ); +} \ No newline at end of file From d326e90d74eb84590c5a37d47ab8995ff10572d3 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 02:47:06 +0900 Subject: [PATCH 164/383] =?UTF-8?q?fix:=20createdAt=20JSON=20=EC=A7=81?= =?UTF-8?q?=EB=A0=AC=ED=99=94=20=ED=8F=AC=EB=A7=B7=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/Timo/Timo/global/common/BaseTimeEntity.java | 3 +++ 1 file changed, 3 insertions(+) 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..bc4e6703 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 HH:mm:ss") private LocalDateTime createdAt; @LastModifiedDate @Column(name = "updated_at", nullable = false) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime updatedAt; } From 29ebb29ba33fd96e72f2eead97606093cfbafb81 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 03:18:59 +0900 Subject: [PATCH 165/383] =?UTF-8?q?fix:=20AuthControllerDocs=20token=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=20=ED=83=80=EC=9E=85=20=EB=B6=88=EC=9D=BC=EC=B9=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/global/auth/controller/AuthController.java | 6 +++--- .../Timo/Timo/global/auth/docs/AuthControllerDocs.java | 4 +++- .../Timo/global/auth/dto/request/AuthTokenRequest.java | 10 ++++++++++ 3 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/global/auth/dto/request/AuthTokenRequest.java 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 3a588fee..13ca7a57 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 @@ -2,6 +2,7 @@ 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.AuthTokenResponse; import com.Timo.Timo.global.auth.exception.AuthSuccessCode; import com.Timo.Timo.global.auth.principal.CustomUserDetails; @@ -40,10 +41,9 @@ public class AuthController implements AuthControllerDocs { @Override @PostMapping("/token") public ResponseEntity> token( - @RequestBody Map body + @RequestBody AuthTokenRequest request ) { - String code = body.get("code"); - AuthTokenResponse authTokenResponse = authService.exchangeCodeForToken(code); + AuthTokenResponse authTokenResponse = authService.exchangeCodeForToken(request.code()); return ResponseEntity.ok() .header("Cache-Control", "no-store") 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 index 98232929..44dfd276 100644 --- a/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java +++ b/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java @@ -1,5 +1,6 @@ package com.Timo.Timo.global.auth.docs; +import com.Timo.Timo.global.auth.dto.request.AuthTokenRequest; 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; @@ -26,6 +27,7 @@ public interface AuthControllerDocs { 최초 로그인인 경우 isNewUser가 true로 반환됩니다. RefreshToken은 Set-Cookie 헤더로 전달됩니다. """ + ) @ApiResponses({ @ApiResponse( @@ -67,7 +69,7 @@ public interface AuthControllerDocs { ) }) ResponseEntity> token( - @RequestBody Map body + @RequestBody AuthTokenRequest body ); @Operation( 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..5f180071 --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/dto/request/AuthTokenRequest.java @@ -0,0 +1,10 @@ +package com.Timo.Timo.global.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AuthTokenRequest( + @Schema(description = "로그인 성공 리다이렉트 시 쿼리스트링으로 전달받은 일회용 인가코드", + example = "550e8400-e29b-41d4-a716-446655440000") + String code +) { +} From 6bf703391af55a6988da8424c9711a55a490ea63 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 03:26:21 +0900 Subject: [PATCH 166/383] =?UTF-8?q?fix:=20ErrorDto=20timestamp=20Swagger?= =?UTF-8?q?=20=EC=98=88=EC=8B=9C=20=ED=8F=AC=EB=A7=B7=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/Timo/Timo/global/exception/dto/ErrorDto.java | 2 ++ 1 file changed, 2 insertions(+) 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, From 1ed8111f0d5d1155d5639ab7089b05cd2aa1a741 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 03:41:19 +0900 Subject: [PATCH 167/383] =?UTF-8?q?refactor:=20Swagger=20Auth=20API=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/docs/AuthControllerDocs.java | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) 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 index 44dfd276..64c5b7a5 100644 --- a/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java +++ b/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java @@ -8,6 +8,7 @@ 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; @@ -23,11 +24,27 @@ public interface AuthControllerDocs { @Operation( summary = "AccessToken 발급", description = """ - 1회성 인증 코드(code)를 AccessToken으로 교환합니다. - 최초 로그인인 경우 isNewUser가 true로 반환됩니다. - RefreshToken은 Set-Cookie 헤더로 전달됩니다. + 1회성 인증 코드(code)를 AccessToken으로 교환합니다. + - isNewUser: 이번 요청으로 신규 회원가입이 이루어졌는지 여부입니다. true면 아직 온보딩을 진행한 적이 없는 사용자입니다. + - user.onboardingCompleted: 온보딩(초기 설정) 완료 여부입니다. false면 온보딩 화면으로, true면 홈 화면으로 이동시켜 주세요. + - isNewUser가 true인 경우 user.onboardingCompleted는 항상 false입니다. 기존 가입자가 온보딩을 중단한 경우에도 isNewUser는 false, onboardingCompleted는 false로 반환될 수 있습니다. + - RefreshToken과 sessionId는 Set-Cookie 헤더로 전달되며, 응답 바디에는 포함되지 않습니다. """ - + ) + @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( From 90f5a279453bcbe1f128ff22de7984c119307100 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 03:59:19 +0900 Subject: [PATCH 168/383] =?UTF-8?q?refactor:=20AuthReissueResponse=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/controller/AuthController.java | 10 ++++++---- .../global/auth/docs/AuthControllerDocs.java | 18 ++++++++++-------- .../auth/dto/response/AuthReissueResponse.java | 13 +++++++++++++ 3 files changed, 29 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/global/auth/dto/response/AuthReissueResponse.java 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 13ca7a57..73774fce 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 @@ -3,6 +3,7 @@ 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.principal.CustomUserDetails; @@ -13,7 +14,6 @@ import com.Timo.Timo.global.response.BaseResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; -import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; @@ -52,11 +52,14 @@ public ResponseEntity> token( @Override @PostMapping("/reissue") - public ResponseEntity>> reissue( + public ResponseEntity> reissue( @CookieValue(name = "refreshToken", required=false) String refreshToken, @CookieValue(name = "sessionId", required = false) String sessionId ) { ReissueResult result = authService.reissue(refreshToken, sessionId); + AuthReissueResponse authReissueResponse = AuthReissueResponse.builder() + .accessToken(result.getAccessToken()) + .build(); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, @@ -66,8 +69,7 @@ public ResponseEntity>> reissue( CookieUtil.createCookie("sessionId", result.getSessionId(), jwtTokenProvider.getRefreshTokenExpiry(), cookieSecure).toString()) .header("Cache-Control", "no-store") - .body(BaseResponse.onSuccess(AuthSuccessCode.REISSUE_SUCCESS, - Map.of("accessToken", result.getAccessToken()))); + .body(BaseResponse.onSuccess(AuthSuccessCode.REISSUE_SUCCESS, authReissueResponse)); } @Override 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 index 64c5b7a5..9e4c31b6 100644 --- a/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java +++ b/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java @@ -1,6 +1,7 @@ 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; @@ -24,11 +25,11 @@ public interface AuthControllerDocs { @Operation( summary = "AccessToken 발급", description = """ - 1회성 인증 코드(code)를 AccessToken으로 교환합니다. - - isNewUser: 이번 요청으로 신규 회원가입이 이루어졌는지 여부입니다. true면 아직 온보딩을 진행한 적이 없는 사용자입니다. - - user.onboardingCompleted: 온보딩(초기 설정) 완료 여부입니다. false면 온보딩 화면으로, true면 홈 화면으로 이동시켜 주세요. - - isNewUser가 true인 경우 user.onboardingCompleted는 항상 false입니다. 기존 가입자가 온보딩을 중단한 경우에도 isNewUser는 false, onboardingCompleted는 false로 반환될 수 있습니다. - - RefreshToken과 sessionId는 Set-Cookie 헤더로 전달되며, 응답 바디에는 포함되지 않습니다. + 1회성 인증 코드(code)를 AccessToken으로 교환 + - isNewUser: 이번 요청으로 신규 회원가입이 이루어졌는지 여부 (true면 아직 온보딩을 진행한 적이 없는 사용자) + - user.onboardingCompleted: 온보딩 완료 여부 (false면 온보딩 화면으로, true면 홈 화면으로 이동) + - 기존 가입자가 온보딩을 중단한 경우에도 isNewUser는 false, onboardingCompleted는 false로 반환될 수 가능 + - RefreshToken과 sessionId는 Set-Cookie 헤더로 전달되며, 응답 바디에는 포함되지 않음 """ ) @io.swagger.v3.oas.annotations.parameters.RequestBody( @@ -92,9 +93,10 @@ ResponseEntity> token( @Operation( summary = "AccessToken 재발급", description = """ - 쿠키로 전달된 RefreshToken과 sessionId를 검증하여 AccessToken을 재발급합니다. - 재발급 성공 시 RefreshToken과 sessionId 쿠키가 갱신됩니다. + 쿠키로 전달된 RefreshToken과 sessionId를 검증하여 AccessToken 재발급 + 재발급 성공 시 RefreshToken과 sessionId 쿠키 갱신 """ + ) @ApiResponses({ @ApiResponse( @@ -119,7 +121,7 @@ ResponseEntity> token( ) ) }) - ResponseEntity>> reissue( + ResponseEntity> reissue( @CookieValue(name = "refreshToken", required = false) String refreshToken, @CookieValue(name = "sessionId", required = false) String sessionId ); 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; +} From 73db1574c99f0b5bdbe1d188c35c69d79ee4da72 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 04:58:18 +0900 Subject: [PATCH 169/383] =?UTF-8?q?feat:=20code=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=EC=97=90=20=EC=9E=85=EB=A0=A5=20=EA=B2=80=EC=A6=9D=20=EC=96=B4?= =?UTF-8?q?=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/dto/request/AuthTokenRequest.java | 2 ++ 1 file changed, 2 insertions(+) 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 index 5f180071..1aafaa47 100644 --- 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 @@ -1,10 +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 ) { } From c6c3022aa867f2a861c439fe8ef485d723a80a40 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 14:27:44 +0900 Subject: [PATCH 170/383] =?UTF-8?q?refactor:=20=EC=BF=A0=ED=82=A4/?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=A1=B0=EB=A6=BD=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=9D=84=20AuthResponseFactory=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 40 ++--------- .../auth/factory/AuthResponseFactory.java | 68 +++++++++++++++++++ 2 files changed, 74 insertions(+), 34 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/global/auth/factory/AuthResponseFactory.java 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 73774fce..54cb81e1 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 @@ -5,18 +5,15 @@ 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.CookieUtil; import com.Timo.Timo.global.auth.utils.TokenExtractor; -import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; import com.Timo.Timo.global.response.BaseResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.CookieValue; @@ -33,7 +30,7 @@ public class AuthController implements AuthControllerDocs { private final AuthService authService; - private final JwtTokenProvider jwtTokenProvider; + private final AuthResponseFactory authResponseFactory; @Value("${app.auth.cookie-secure}") private boolean cookieSecure; @@ -44,10 +41,7 @@ public ResponseEntity> token( @RequestBody AuthTokenRequest request ) { AuthTokenResponse authTokenResponse = authService.exchangeCodeForToken(request.code()); - - return ResponseEntity.ok() - .header("Cache-Control", "no-store") - .body(BaseResponse.onSuccess(AuthSuccessCode.LOGIN_SUCCESS, authTokenResponse)); + return authResponseFactory.tokenResponse(authTokenResponse); } @Override @@ -57,19 +51,7 @@ public ResponseEntity> reissue( @CookieValue(name = "sessionId", required = false) String sessionId ) { ReissueResult result = authService.reissue(refreshToken, sessionId); - AuthReissueResponse authReissueResponse = AuthReissueResponse.builder() - .accessToken(result.getAccessToken()) - .build(); - - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, - CookieUtil.createCookie("refreshToken", result.getRefreshToken(), - jwtTokenProvider.getRefreshTokenExpiry(), cookieSecure).toString()) - .header(HttpHeaders.SET_COOKIE, - CookieUtil.createCookie("sessionId", result.getSessionId(), - jwtTokenProvider.getRefreshTokenExpiry(), cookieSecure).toString()) - .header("Cache-Control", "no-store") - .body(BaseResponse.onSuccess(AuthSuccessCode.REISSUE_SUCCESS, authReissueResponse)); + return authResponseFactory.reissueResponse(result); } @Override @@ -82,12 +64,7 @@ public ResponseEntity> logout( String accessToken = TokenExtractor.resolveToken(request); authService.logout(accessToken, userDetails.getUser().getId(), sessionId); - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, - CookieUtil.expireCookie("refreshToken", cookieSecure).toString()) - .header(HttpHeaders.SET_COOKIE, - CookieUtil.expireCookie("sessionId", cookieSecure).toString()) - .body(BaseResponse.onSuccess(AuthSuccessCode.LOGOUT_SUCCESS, null)); + return authResponseFactory.logoutResponse(); } @Override @@ -100,11 +77,6 @@ public ResponseEntity> withdraw( String accessToken = TokenExtractor.resolveToken(request); authService.withdraw(accessToken, userDetails.getUser().getId(), sessionId); - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, - CookieUtil.expireCookie("refreshToken", cookieSecure).toString()) - .header(HttpHeaders.SET_COOKIE, - CookieUtil.expireCookie("sessionId", cookieSecure).toString()) - .body(BaseResponse.onSuccess(AuthSuccessCode.WITHDRAW_SUCCESS, null)); + return authResponseFactory.withdrawResponse(); } } 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..03208c03 --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/factory/AuthResponseFactory.java @@ -0,0 +1,68 @@ +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()) + .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(); + } +} + From b6d314fb5cfe33a89b3cd00d4f7c232046fcf1a9 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 14:28:45 +0900 Subject: [PATCH 171/383] =?UTF-8?q?fix:=20token=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=97=90=20@Valid=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/controller/AuthController.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 54cb81e1..3a4304d0 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 @@ -12,6 +12,7 @@ import com.Timo.Timo.global.response.BaseResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; @@ -38,7 +39,7 @@ public class AuthController implements AuthControllerDocs { @Override @PostMapping("/token") public ResponseEntity> token( - @RequestBody AuthTokenRequest request + @Valid @RequestBody AuthTokenRequest request ) { AuthTokenResponse authTokenResponse = authService.exchangeCodeForToken(request.code()); return authResponseFactory.tokenResponse(authTokenResponse); From 12be0e2fb9285f37af9414b37a7a7c26a065b010 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 14:42:19 +0900 Subject: [PATCH 172/383] =?UTF-8?q?fix:=20=ED=83=88=ED=87=B4=20=EC=8B=9C?= =?UTF-8?q?=20=EC=A0=84=EC=B2=B4=20=EC=84=B8=EC=85=98=20refresh=20token=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=B0=8F=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?=EC=8B=9C=20=EC=9C=A0=EC=A0=80=20=EC=A1=B4=EC=9E=AC=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/global/auth/docs/AuthControllerDocs.java | 9 +++++++++ .../com/Timo/Timo/global/auth/service/AuthService.java | 8 +++++--- .../Timo/global/auth/service/RefreshTokenService.java | 8 ++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) 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 index 9e4c31b6..b941270f 100644 --- a/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java +++ b/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java @@ -112,6 +112,14 @@ ResponseEntity> token( schema = @Schema(implementation = ErrorDto.class) ) ), + @ApiResponse( + responseCode = "404", + description = "RefreshToken에 해당하는 사용자를 찾을 수 없는 경우 (탈퇴 등으로 삭제된 사용자)", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), @ApiResponse( responseCode = "500", description = "서버 내부 오류", @@ -203,6 +211,7 @@ ResponseEntity> logout( ) ) }) + ResponseEntity> withdraw( @Parameter(hidden = true) CustomUserDetails userDetails, @CookieValue(name = "sessionId", required = false) String sessionId, 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 d3646954..5b24c793 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 @@ -67,6 +67,10 @@ public ReissueResult reissue(String refreshToken, String sessionId) { 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); } @@ -102,9 +106,7 @@ public void withdraw(String accessToken, Long userId, String sessionId) { blacklistService.addToBlacklist(accessToken, remainingExpiry); } - if (sessionId != null) { - refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); - } + refreshTokenService.deleteAllRefreshTokens(String.valueOf(userId)); userRepository.delete(user); } 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..0c994cb2 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 @@ -2,6 +2,7 @@ import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; import java.util.Objects; +import java.util.Set; import java.util.UUID; import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; @@ -36,6 +37,13 @@ public void deleteRefreshToken(String userId, String sessionId) { redisTemplate.delete(KEY_PREFIX + userId + ":" + sessionId); } + public void deleteAllRefreshTokens(String userId) { + Set keys = redisTemplate.keys(KEY_PREFIX + userId + ":*"); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + } + public boolean isRefreshTokenValid(String userId, String sessionId, String refreshToken) { return Objects.equals(refreshToken, getRefreshToken(userId, sessionId)); } From 6ebaea8494a0905e59cca30dccbfd15b145fe0f9 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 7 Jul 2026 14:51:13 +0900 Subject: [PATCH 173/383] =?UTF-8?q?chore:=20AuthController=EC=9D=98=20?= =?UTF-8?q?=EB=AF=B8=EC=82=AC=EC=9A=A9=20cookieSecure=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/controller/AuthController.java | 3 --- 1 file changed, 3 deletions(-) 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 3a4304d0..cbc5ca49 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 @@ -33,9 +33,6 @@ public class AuthController implements AuthControllerDocs { private final AuthService authService; private final AuthResponseFactory authResponseFactory; - @Value("${app.auth.cookie-secure}") - private boolean cookieSecure; - @Override @PostMapping("/token") public ResponseEntity> token( From 559fa530924817b5f3454b3958faec6ec31b9b22 Mon Sep 17 00:00:00 2001 From: jy000n Date: Wed, 8 Jul 2026 04:47:13 +0900 Subject: [PATCH 174/383] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20import,=20=EB=B9=88=EC=B9=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/user/controller/OnboardingController.java | 1 - .../Timo/domain/user/dto/response/OnboardingResponse.java | 4 +--- .../com/Timo/Timo/domain/user/exception/UserErrorCode.java | 1 - .../com/Timo/Timo/global/auth/controller/AuthController.java | 1 - 4 files changed, 1 insertion(+), 6 deletions(-) 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 index cc9df4e7..3e182760 100644 --- a/src/main/java/com/Timo/Timo/domain/user/controller/OnboardingController.java +++ b/src/main/java/com/Timo/Timo/domain/user/controller/OnboardingController.java @@ -3,7 +3,6 @@ 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.exception.UserSuccessCode; import com.Timo.Timo.domain.user.factory.OnboardingResponseFactory; import com.Timo.Timo.domain.user.service.OnboardingService; import com.Timo.Timo.global.auth.principal.CustomUserDetails; 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 index bf119bab..de52f7e4 100644 --- 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 @@ -5,6 +5,4 @@ @Builder public record OnboardingResponse( boolean onboardingCompleted -) { - -} +) {} 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 f7e408ad..e1ba79c5 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 @@ -10,7 +10,6 @@ public enum UserErrorCode implements BaseErrorCode { USER_400_INVALID_TIME(HttpStatus.BAD_REQUEST, "USER_400", "취침 시간은 기상 시간보다 이후여야 합니다."), - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404", "존재하지 않는 사용자입니다."); private final HttpStatus httpStatus; 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 f1536cab..0fff9596 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 @@ -18,7 +18,6 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.CookieValue; From a22794f3eb15d33c3ca1a6a58ef112b8e116d75a Mon Sep 17 00:00:00 2001 From: aneykrap Date: Tue, 7 Jul 2026 21:26:36 +0900 Subject: [PATCH 175/383] =?UTF-8?q?feat:=20=ED=88=AC=EB=91=90=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=20=EC=A1=B0=ED=9A=8C=EC=9A=A9=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=A0=91=EA=B7=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java # src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java --- .../todo/repository/TodoDailyCompletionStats.java | 12 ++++++++++++ .../Timo/domain/todo/repository/TodoRepository.java | 2 ++ 2 files changed, 14 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/todo/repository/TodoDailyCompletionStats.java 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..492c2301 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoDailyCompletionStats.java @@ -0,0 +1,12 @@ +package com.Timo.Timo.domain.todo.repository; + +import java.time.LocalDate; + +public interface TodoDailyCompletionStats { + + LocalDate getDate(); + + Long getTotalCount(); + + Long getCompletedCount(); +} 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 index 12c1e6d5..93830714 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java @@ -11,6 +11,8 @@ public interface TodoRepository extends JpaRepository { + long countByUser_IdAndDate(Long userId, LocalDate date); + @Query(""" select t from Todo t where t.user.id = :userId From e2178cc3226efd9fc0777ecbd72b5c7854310a4e Mon Sep 17 00:00:00 2001 From: aneykrap Date: Tue, 7 Jul 2026 21:27:12 +0900 Subject: [PATCH 176/383] =?UTF-8?q?feat:=20=ED=86=B5=EA=B3=84=20=EC=BA=98?= =?UTF-8?q?=EB=A6=B0=EB=8D=94=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/StatisticsController.java | 69 ++++++++++++++++++ .../docs/StatisticsCalendarDocs.java | 71 +++++++++++++++++++ .../response/StatisticsCalendarResponse.java | 28 ++++++++ .../exception/StatisticsErrorCode.java | 21 ++++++ .../exception/StatisticsSuccessCode.java | 18 +++++ .../statistics/service/StatisticsService.java | 66 +++++++++++++++++ 6 files changed, 273 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/statistics/controller/StatisticsController.java create mode 100644 src/main/java/com/Timo/Timo/domain/statistics/docs/StatisticsCalendarDocs.java create mode 100644 src/main/java/com/Timo/Timo/domain/statistics/dto/response/StatisticsCalendarResponse.java create mode 100644 src/main/java/com/Timo/Timo/domain/statistics/exception/StatisticsErrorCode.java create mode 100644 src/main/java/com/Timo/Timo/domain/statistics/exception/StatisticsSuccessCode.java create mode 100644 src/main/java/com/Timo/Timo/domain/statistics/service/StatisticsService.java 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..be23ded6 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/statistics/controller/StatisticsController.java @@ -0,0 +1,69 @@ +package com.Timo.Timo.domain.statistics.controller; + +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +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.dto.response.StatisticsCalendarResponse; +import com.Timo.Timo.domain.statistics.exception.StatisticsErrorCode; +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.exception.CustomException; +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 { + + private static final DateTimeFormatter YEAR_MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM"); + private static final String YEAR_MONTH_PATTERN = "^\\d{4}-\\d{2}$"; + + private final StatisticsService statisticsService; + + @Override + @GetMapping("/calendar") + public ResponseEntity> getCalendar( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestParam(required = false) String yearMonth + ) { + YearMonth parsedYearMonth = parseYearMonth(yearMonth); + StatisticsCalendarResponse response = statisticsService.getCalendar( + userDetails.getUserId(), + parsedYearMonth + ); + + return ResponseEntity.ok( + BaseResponse.onSuccess(StatisticsSuccessCode.CALENDAR_RETRIEVED, response) + ); + } + + private 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); + } + } +} 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..13808e1e --- /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 + ); +} 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..8bbe008f --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/statistics/dto/response/StatisticsCalendarResponse.java @@ -0,0 +1,28 @@ +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 + ) { + } +} 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..4cf7956c --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/statistics/exception/StatisticsErrorCode.java @@ -0,0 +1,21 @@ +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, "COMMON_400", "yearMonth는 필수입니다."), + INVALID_YEAR_MONTH_FORMAT(HttpStatus.BAD_REQUEST, "COMMON_400", "yearMonth는 yyyy-MM 형식이어야 합니다."), + INVALID_YEAR_MONTH(HttpStatus.BAD_REQUEST, "COMMON_400", "유효하지 않은 연월입니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} 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..a6df17b2 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/statistics/exception/StatisticsSuccessCode.java @@ -0,0 +1,18 @@ +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, "통계 캘린더를 조회했습니다."); + + private final HttpStatus httpStatus; + private final String message; +} 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..2f0e17e2 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/statistics/service/StatisticsService.java @@ -0,0 +1,66 @@ +package com.Timo.Timo.domain.statistics.service; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +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.todo.repository.TodoDailyCompletionStats; +import com.Timo.Timo.domain.todo.repository.TodoRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class StatisticsService { + + private static final DateTimeFormatter YEAR_MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM"); + + private final TodoRepository todoRepository; + + public StatisticsCalendarResponse getCalendar(Long userId, YearMonth yearMonth) { + LocalDate today = LocalDate.now(ZoneOffset.UTC); + 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 + ); + } + + 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()); + } +} From 77ce9ecf8f8d5b507708dbef2a65232e13aecfb8 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 07:09:29 +0900 Subject: [PATCH 177/383] =?UTF-8?q?feat:=20daily=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/todo/repository/TodoRepository.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 index 93830714..78c76908 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java @@ -28,4 +28,22 @@ List findRulesInRange( 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 + ); } From 9f57f03a92ccb40bc558ed93ff48fda2cb2440c2 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 07:52:51 +0900 Subject: [PATCH 178/383] fix: remove invalid todo date count query --- .../com/Timo/Timo/domain/todo/repository/TodoRepository.java | 2 -- 1 file changed, 2 deletions(-) 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 index 78c76908..82fde0bf 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java @@ -11,8 +11,6 @@ public interface TodoRepository extends JpaRepository { - long countByUser_IdAndDate(Long userId, LocalDate date); - @Query(""" select t from Todo t where t.user.id = :userId From 22536be5fb7d04622c3a67be86f734ed4b102416 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 03:38:30 +0900 Subject: [PATCH 179/383] =?UTF-8?q?feat:=20=ED=88=AC=EB=91=90=20=EC=86=8C?= =?UTF-8?q?=EC=9A=94=EC=8B=9C=EA=B0=84=20=EC=98=88=EC=83=81=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/controller/AiTodoController.java | 54 +++++++++++++ .../Timo/Timo/domain/ai/docs/AiTodoDocs.java | 75 +++++++++++++++++++ .../domain/ai/exception/AiSuccessCode.java | 18 +++++ 3 files changed, 147 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java create mode 100644 src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java create mode 100644 src/main/java/com/Timo/Timo/domain/ai/exception/AiSuccessCode.java 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..95e61771 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java @@ -0,0 +1,54 @@ +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 io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestController +@RequestMapping("/api/v1/ai/todos") +@RequiredArgsConstructor +@Tag(name = "AI Todo", description = "투두 AI API") +public class AiTodoController implements AiTodoDocs { + + private final AiTodoService aiTodoService; + + @Override + @PostMapping("/recommend-duration") + public ResponseEntity> recommendDuration( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody RecommendDurationRequest request + ) { + log.info( + "AI duration recommendation API called. userId={}, title={}, tagId={}", + userDetails.getUserId(), + request.title(), + 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..3e259b59 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java @@ -0,0 +1,75 @@ +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( + summary = "AI 예상 소요 시간 추천", + description = """ + 투두명과 태그를 기준으로 예상 소요 시간을 추천합니다. + + 서버는 사용자 과거 투두 기록을 아래 우선순위로 조회해 Gemini에 전달합니다. + 1. 비슷한 투두명 기록 + 2. 같은 태그 기록 + 3. 최근 기록 + 4. 데이터가 부족하면 현재 투두 기준 + + Gemini API 키가 없거나 호출/응답 검증에 실패하면 서버 fallback 로직으로 추천 시간을 반환합니다. + 추천 시간은 5분 단위이며, 서비스 정책 범위 안으로 보정됩니다. + """ + ) + @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 = "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 + ); +} 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; +} From ca7020b780381a0f8c026255a6d920074560c975 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 03:39:03 +0900 Subject: [PATCH 180/383] =?UTF-8?q?feat:=20=EC=B6=94=EC=B2=9C=20=EC=86=8C?= =?UTF-8?q?=EC=9A=94=20=EC=8B=9C=EA=B0=84=20=EC=9A=94=EC=B2=AD=20=EB=B0=8F?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/RecommendDurationRequest.java | 16 ++++++++++++++++ .../response/GeminiDurationRecommendation.java | 10 ++++++++++ .../dto/response/RecommendDurationResponse.java | 17 +++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/ai/dto/request/RecommendDurationRequest.java create mode 100644 src/main/java/com/Timo/Timo/domain/ai/dto/response/GeminiDurationRecommendation.java create mode 100644 src/main/java/com/Timo/Timo/domain/ai/dto/response/RecommendDurationResponse.java 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..56ed02fc --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/dto/request/RecommendDurationRequest.java @@ -0,0 +1,16 @@ +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 +) { +} 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..78d9ca0a --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/dto/response/GeminiDurationRecommendation.java @@ -0,0 +1,10 @@ +package com.Timo.Timo.domain.ai.dto.response; + +import com.Timo.Timo.domain.ai.enums.PatternBasis; + +public record GeminiDurationRecommendation( + Integer recommendedMinutes, + PatternBasis patternBasis, + String feedback +) { +} 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..253e07f6 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/dto/response/RecommendDurationResponse.java @@ -0,0 +1,17 @@ +package com.Timo.Timo.domain.ai.dto.response; + +import com.Timo.Timo.domain.ai.enums.PatternBasis; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record RecommendDurationResponse( + @Schema(description = "추천 예상 소요 시간(분). 5분 단위", example = "45") + Integer recommendedMinutes, + + @Schema(description = "추천 판단에 가장 크게 사용한 근거", example = "SAME_TAG") + PatternBasis patternBasis, + + @Schema(description = "AI 또는 fallback 추천 설명", example = "비슷한 투두 기록을 기준으로 45분 정도를 추천해요.") + String feedback +) { +} From f7197572190023763d0a4f145be26ce0a677c133 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 03:39:22 +0900 Subject: [PATCH 181/383] =?UTF-8?q?feat:=20=EC=B6=94=EA=B0=80=EB=90=9C=20?= =?UTF-8?q?=ED=88=AC=EB=91=90=20=EC=86=8C=EC=9A=94=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20=ED=8C=A8?= =?UTF-8?q?=ED=84=B4=20=EA=B8=B0=EC=A4=80=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/ai/enums/PatternBasis.java | 8 ++ .../ai/prompt/TodoDurationPromptBuilder.java | 104 ++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/ai/enums/PatternBasis.java create mode 100644 src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java diff --git a/src/main/java/com/Timo/Timo/domain/ai/enums/PatternBasis.java b/src/main/java/com/Timo/Timo/domain/ai/enums/PatternBasis.java new file mode 100644 index 00000000..d9a29358 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/enums/PatternBasis.java @@ -0,0 +1,8 @@ +package com.Timo.Timo.domain.ai.enums; + +public enum PatternBasis { + SIMILAR_TITLE, + SAME_TAG, + RECENT_HISTORY, + CURRENT_ONLY +} 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..097f4b85 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java @@ -0,0 +1,104 @@ +package com.Timo.Timo.domain.ai.prompt; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.Timo.Timo.domain.ai.dto.request.RecommendDurationRequest; +import com.Timo.Timo.domain.ai.repository.TodoDurationHistory; + +@Component +public class TodoDurationPromptBuilder { + + public String build( + RecommendDurationRequest request, + List sameTagSimilarTitleHistories, + List sameTagHistories, + List recentHistories, + int minMinutes, + int maxMinutes + ) { + return """ + 너는 Timo 투두 앱의 예상 소요 시간 추천 AI야. + 현재 투두명/태그와 사용자의 타이머 기반 실제 소요시간 기록을 보고 예상 소요 시간을 추천해. + + 판단 방식: + - 같은 태그 안에서 투두명이 비슷한 기록을 가장 중요하게 참고해. + - 같은 태그의 전체 기록을 함께 참고해서 평균/경향을 보정해. + - 위 기록이 부족하면 사용자의 최근 타이머 기록 경향을 참고해. + - 모든 기록이 비어 있으면 현재 투두명과 태그만 기준으로 판단해. + + 규칙: + - 응답은 반드시 JSON 객체 하나만 반환해. + - recommendedMinutes는 5분 단위 정수여야 해. + - recommendedMinutes는 %d분 이상 %d분 이하만 가능해. + - feedback은 한국어 1문장으로 부드럽게 작성해. + - 실제 기록에 없는 패턴은 만들지 마. + - patternBasis는 가장 크게 참고한 근거를 기준으로 SIMILAR_TITLE, SAME_TAG, RECENT_HISTORY, CURRENT_ONLY 중 하나만 사용해. + + 응답 JSON 형식: + { + "recommendedMinutes": 45, + "patternBasis": "SAME_TAG", + "feedback": "같은 태그의 실제 소요시간 기록을 기준으로 45분 정도를 추천해요." + } + + 현재 투두: + { + "title": "%s", + "tagId": %s + } + + 같은 태그 내 비슷한 투두명 기록: + %s + + 같은 태그 기록: + %s + + 최근 기록: + %s + """.formatted( + minMinutes, + maxMinutes, + escape(request.title()), + request.tagId() == null ? "null" : request.tagId().toString(), + formatHistories(sameTagSimilarTitleHistories), + formatHistories(sameTagHistories), + formatHistories(recentHistories) + ); + } + + private String formatHistories(List histories) { + if (histories == null || histories.isEmpty()) { + return "[]"; + } + + return histories.stream() + .map(history -> """ + {"title":"%s","tagId":%s,"date":"%s","actualMinutes":%d} + """.formatted( + escape(history.title()), + history.tagId() == null ? "null" : history.tagId().toString(), + 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 escape(String value) { + if (value == null) { + return ""; + } + return value + .replace("\\", "\\\\") + .replace("\"", "\\\""); + } +} From 0ecde8deca3478074f6605132f725c4d05e0085d Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 03:39:54 +0900 Subject: [PATCH 182/383] =?UTF-8?q?feat:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/repository/AiTodoQueryRepository.java | 148 ++++++++++++++++++ .../ai/repository/TodoDurationHistory.java | 11 ++ 2 files changed, 159 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java create mode 100644 src/main/java/com/Timo/Timo/domain/ai/repository/TodoDurationHistory.java 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..60a75daf --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java @@ -0,0 +1,148 @@ +package com.Timo.Timo.domain.ai.repository; + +import java.sql.Date; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.util.List; + +import org.springframework.stereotype.Repository; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class AiTodoQueryRepository { + + private final EntityManager entityManager; + + public List findActualDurationHistoriesBySimilarTitleAndTag( + Long userId, + String title, + Long tagId, + LocalDate today, + int limit + ) { + Query query = entityManager.createNativeQuery(""" + select + t.title, + t.tag_id, + tr.actual_seconds, + date(coalesce(tr.ended_at, tr.started_at)) as recorded_date + from todos t + join timer_records tr on tr.todo_id = t.id + where t.user_id = :userId + and tr.user_id = :userId + and tr.actual_seconds is not null + and date(coalesce(tr.ended_at, tr.started_at)) <= :today + and t.tag_id = :tagId + and ( + lower(t.title) like lower(concat('%', :title, '%')) + or lower(:title) like lower(concat('%', t.title, '%')) + ) + order by coalesce(tr.ended_at, tr.started_at) desc, tr.id desc + """) + .setParameter("userId", userId) + .setParameter("title", title) + .setParameter("tagId", tagId) + .setParameter("today", today) + .setMaxResults(limit); + + return toHistories(query.getResultList()); + } + + public List findActualDurationHistoriesByTagId( + Long userId, + Long tagId, + LocalDate today, + int limit + ) { + Query query = entityManager.createNativeQuery(""" + select + t.title, + t.tag_id, + tr.actual_seconds, + date(coalesce(tr.ended_at, tr.started_at)) as recorded_date + from todos t + join timer_records tr on tr.todo_id = t.id + where t.user_id = :userId + and tr.user_id = :userId + and tr.actual_seconds is not null + and date(coalesce(tr.ended_at, tr.started_at)) <= :today + and t.tag_id = :tagId + order by coalesce(tr.ended_at, tr.started_at) desc, tr.id desc + """) + .setParameter("userId", userId) + .setParameter("tagId", tagId) + .setParameter("today", today) + .setMaxResults(limit); + + return toHistories(query.getResultList()); + } + + public List findRecentActualDurationHistories( + Long userId, + LocalDate today, + int limit + ) { + Query query = entityManager.createNativeQuery(""" + select + t.title, + t.tag_id, + tr.actual_seconds, + date(coalesce(tr.ended_at, tr.started_at)) as recorded_date + from todos t + join timer_records tr on tr.todo_id = t.id + where t.user_id = :userId + and tr.user_id = :userId + and tr.actual_seconds is not null + and date(coalesce(tr.ended_at, tr.started_at)) <= :today + order by coalesce(tr.ended_at, tr.started_at) desc, tr.id desc + """) + .setParameter("userId", userId) + .setParameter("today", today) + .setMaxResults(limit); + + return toHistories(query.getResultList()); + } + + @SuppressWarnings("unchecked") + private List toHistories(List rows) { + return ((List)rows).stream() + .map(row -> new TodoDurationHistory( + (String)row[0], + toLong(row[1]), + toInteger(row[2]), + toLocalDate(row[3]) + )) + .toList(); + } + + private Long toLong(Object value) { + if (value == null) { + return null; + } + return ((Number)value).longValue(); + } + + private Integer toInteger(Object value) { + if (value == null) { + return null; + } + return ((Number)value).intValue(); + } + + private LocalDate toLocalDate(Object value) { + if (value instanceof LocalDate localDate) { + return localDate; + } + if (value instanceof Date date) { + return date.toLocalDate(); + } + if (value instanceof Timestamp timestamp) { + return timestamp.toLocalDateTime().toLocalDate(); + } + return LocalDate.parse(value.toString()); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/ai/repository/TodoDurationHistory.java b/src/main/java/com/Timo/Timo/domain/ai/repository/TodoDurationHistory.java new file mode 100644 index 00000000..11eca5a2 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/repository/TodoDurationHistory.java @@ -0,0 +1,11 @@ +package com.Timo.Timo.domain.ai.repository; + +import java.time.LocalDate; + +public record TodoDurationHistory( + String title, + Long tagId, + Integer actualSeconds, + LocalDate date +) { +} From 6abb055fc5e4ed52f705e6a68d7fd8b62e64ee25 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 03:40:16 +0900 Subject: [PATCH 183/383] =?UTF-8?q?feat:=20gemini=20=ED=98=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/ai/service/AiTodoService.java | 137 ++++++++++++++++++ .../Timo/domain/ai/service/GeminiService.java | 77 ++++++++++ 2 files changed, 214 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java create mode 100644 src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java 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..907b358f --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java @@ -0,0 +1,137 @@ +package com.Timo.Timo.domain.ai.service; + +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +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.RecommendDurationResponse; +import com.Timo.Timo.domain.ai.enums.PatternBasis; +import com.Timo.Timo.domain.ai.prompt.TodoDurationPromptBuilder; +import com.Timo.Timo.domain.ai.repository.AiTodoQueryRepository; +import com.Timo.Timo.domain.ai.repository.TodoDurationHistory; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AiTodoService { + + private static final int HISTORY_LIMIT = 10; + + private final AiTodoQueryRepository aiTodoQueryRepository; + private final TodoDurationPromptBuilder promptBuilder; + private final GeminiService geminiService; + private final ObjectMapper objectMapper; + + @Value("${ai.duration.min-minutes:5}") + private int minMinutes; + + @Value("${ai.duration.max-minutes:240}") + private int maxMinutes; + + @Value("${ai.duration.default-minutes:30}") + private int defaultMinutes; + + public RecommendDurationResponse recommendDuration(Long userId, RecommendDurationRequest request) { + LocalDate today = LocalDate.now(ZoneOffset.UTC); + + List sameTagSimilarTitleHistories = request.tagId() == null + ? List.of() + : aiTodoQueryRepository.findActualDurationHistoriesBySimilarTitleAndTag( + userId, + request.title(), + request.tagId(), + today, + HISTORY_LIMIT + ); + List sameTagHistories = request.tagId() == null + ? List.of() + : aiTodoQueryRepository.findActualDurationHistoriesByTagId( + userId, + request.tagId(), + today, + HISTORY_LIMIT + ); + List recentHistories = aiTodoQueryRepository.findRecentActualDurationHistories( + userId, + today, + HISTORY_LIMIT + ); + + String prompt = promptBuilder.build( + request, + sameTagSimilarTitleHistories, + sameTagHistories, + recentHistories, + minMinutes, + maxMinutes + ); + + log.info( + "AI duration recommendation histories loaded. sameTagSimilarTitle={}, sameTag={}, recent={}", + sameTagSimilarTitleHistories.size(), + sameTagHistories.size(), + recentHistories.size() + ); + + String geminiJson = geminiService.generateJson(prompt); + GeminiDurationRecommendation recommendation = parseRecommendation(geminiJson); + return validate(recommendation); + } + + private GeminiDurationRecommendation parseRecommendation(String geminiJson) { + try { + return objectMapper.readValue( + stripMarkdownFence(geminiJson), + GeminiDurationRecommendation.class + ); + } catch (Exception exception) { + throw new IllegalStateException("Failed to parse Gemini duration recommendation.", exception); + } + } + + private RecommendDurationResponse validate(GeminiDurationRecommendation recommendation) { + if (recommendation == null + || recommendation.recommendedMinutes() == null + || recommendation.patternBasis() == null + || recommendation.feedback() == null + || recommendation.feedback().isBlank() + ) { + throw new IllegalArgumentException("Gemini recommendation has missing fields."); + } + + int recommendedMinutes = normalizeMinutes(recommendation.recommendedMinutes()); + + return new RecommendDurationResponse( + recommendedMinutes, + recommendation.patternBasis(), + recommendation.feedback() + ); + } + + private int normalizeMinutes(int minutes) { + int rounded = Math.round(minutes / 5.0f) * 5; + return Math.max(minMinutes, Math.min(maxMinutes, rounded)); + } + + private String stripMarkdownFence(String value) { + String trimmed = value.trim(); + if (trimmed.startsWith("```json")) { + return trimmed.substring(7, trimmed.length() - 3).trim(); + } + if (trimmed.startsWith("```")) { + return trimmed.substring(3, trimmed.length() - 3).trim(); + } + return trimmed; + } +} 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..db9afb9b --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java @@ -0,0 +1,77 @@ +package com.Timo.Timo.domain.ai.service; + +import java.util.List; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +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?key={apiKey}"; + + private final ObjectMapper objectMapper; + private final RestClient restClient = RestClient.create(); + + @Value("${ai.gemini.api-key:}") + private String apiKey; + + @Value("${ai.gemini.model:gemini-2.0-flash}") + private String model; + + public String generateJson(String prompt) { + if (apiKey == null || apiKey.isBlank()) { + throw new IllegalStateException("Gemini API key is not configured."); + } + + 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, apiKey) + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .body(String.class); + + return extractText(response); + } + + 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 IllegalStateException("Gemini response text is empty."); + } + + return textNode.asText(); + } catch (Exception exception) { + throw new IllegalStateException("Failed to parse Gemini response.", exception); + } + } +} From 95d76b78741cf1f057929598d380535759efd833 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 03:45:21 +0900 Subject: [PATCH 184/383] =?UTF-8?q?feat:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/Timo/Timo/global/config/SecurityConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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..f204c7bf 100644 --- a/src/main/java/com/Timo/Timo/global/config/SecurityConfig.java +++ b/src/main/java/com/Timo/Timo/global/config/SecurityConfig.java @@ -38,7 +38,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .httpBasic(AbstractHttpConfigurer::disable) .cors(cors -> cors.configurationSource(corsConfigurationSource())) .sessionManagement(session -> session - .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) .authorizeHttpRequests(authorize -> authorize .requestMatchers( "/", From 158c71b318a49af213f2ae96e0afc1691f1d4f57 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 03:47:06 +0900 Subject: [PATCH 185/383] =?UTF-8?q?feat:=20gemini=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-local.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 95913ca7..c3776b83 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -37,3 +37,8 @@ jwt: secret: ${JWT_SECRET} access-token-expires-in-seconds: 1800 refresh-token-expires-in-seconds: 1209600 + +ai: + gemini: + api-key: ${GEMINI_API_KEY:} + model: ${GEMINI_MODEL:gemini-3.1-flash-lite} From 609ca41f2799e66792da38954b043c3e8be670bb Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 06:53:48 +0900 Subject: [PATCH 186/383] =?UTF-8?q?feat:=20=EA=B0=9C=EC=84=A0=EB=90=9C=20?= =?UTF-8?q?=EC=98=88=EC=83=81=20=EC=86=8C=EC=9A=94=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=ED=98=95=EC=8B=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/ai/docs/AiTodoDocs.java | 22 ++++--- .../GeminiDurationRecommendation.java | 6 +- .../response/RecommendDurationResponse.java | 12 +--- .../ai/prompt/TodoDurationPromptBuilder.java | 42 +++++-------- .../Timo/domain/ai/service/AiTodoService.java | 60 ++++++------------- 5 files changed, 51 insertions(+), 91 deletions(-) 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 index 3e259b59..1972f93f 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java +++ b/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java @@ -22,14 +22,14 @@ public interface AiTodoDocs { description = """ 투두명과 태그를 기준으로 예상 소요 시간을 추천합니다. - 서버는 사용자 과거 투두 기록을 아래 우선순위로 조회해 Gemini에 전달합니다. - 1. 비슷한 투두명 기록 - 2. 같은 태그 기록 - 3. 최근 기록 - 4. 데이터가 부족하면 현재 투두 기준 + 서버는 사용자의 타이머 기반 실제 소요시간 기록을 조회해 Gemini에 전달합니다. + 1. 현재 투두명과 비슷한 과거 투두의 실제 소요시간 기록 + 2. 사용자가 지정한 태그의 최근 실제 소요시간 기록 + 3. 기록이 없으면 현재 투두명 기준 - Gemini API 키가 없거나 호출/응답 검증에 실패하면 서버 fallback 로직으로 추천 시간을 반환합니다. - 추천 시간은 5분 단위이며, 서비스 정책 범위 안으로 보정됩니다. + Gemini는 위 기록을 종합해 예상 소요 시간을 생성합니다. + 호출량과 토큰 사용량을 줄이기 위해 각 기록은 최근 순 최대 5개씩만 전달합니다. + RPM, RPD, TPM 제한을 초과하면 Gemini 호출 전 429 응답을 반환합니다. """ ) @ApiResponses({ @@ -54,6 +54,14 @@ public interface AiTodoDocs { schema = @Schema(implementation = ErrorDto.class) ) ), + @ApiResponse( + responseCode = "429", + description = "AI 추천 요청 횟수 제한을 초과한 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), @ApiResponse( responseCode = "500", description = "서버 내부 오류", 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 index 78d9ca0a..cc80ea1b 100644 --- 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 @@ -1,10 +1,6 @@ package com.Timo.Timo.domain.ai.dto.response; -import com.Timo.Timo.domain.ai.enums.PatternBasis; - public record GeminiDurationRecommendation( - Integer recommendedMinutes, - PatternBasis patternBasis, - String feedback + Integer recommendedMinutes ) { } 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 index 253e07f6..69412d6f 100644 --- 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 @@ -1,17 +1,9 @@ package com.Timo.Timo.domain.ai.dto.response; -import com.Timo.Timo.domain.ai.enums.PatternBasis; - import io.swagger.v3.oas.annotations.media.Schema; public record RecommendDurationResponse( - @Schema(description = "추천 예상 소요 시간(분). 5분 단위", example = "45") - Integer recommendedMinutes, - - @Schema(description = "추천 판단에 가장 크게 사용한 근거", example = "SAME_TAG") - PatternBasis patternBasis, - - @Schema(description = "AI 또는 fallback 추천 설명", example = "비슷한 투두 기록을 기준으로 45분 정도를 추천해요.") - String feedback + @Schema(description = "추천 예상 소요 시간(분)", example = "45") + Integer recommendedMinutes ) { } 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 index 097f4b85..a640a2a3 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java +++ b/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java @@ -12,35 +12,27 @@ public class TodoDurationPromptBuilder { public String build( RecommendDurationRequest request, - List sameTagSimilarTitleHistories, - List sameTagHistories, - List recentHistories, - int minMinutes, - int maxMinutes + List similarTitleHistories, + List recentTagHistories ) { return """ - 너는 Timo 투두 앱의 예상 소요 시간 추천 AI야. - 현재 투두명/태그와 사용자의 타이머 기반 실제 소요시간 기록을 보고 예상 소요 시간을 추천해. + 너는 Timo 투두 앱에서 사용자의 실제 작업 시간을 분석해 예상 소요 시간을 추천하는 시간 계획 코치야. + 사용자가 입력한 투두명을 먼저 이해하고, 과거 타이머 기록의 실제 소요시간 패턴을 참고해 현실적인 예상 소요 시간을 제안해. 판단 방식: - - 같은 태그 안에서 투두명이 비슷한 기록을 가장 중요하게 참고해. - - 같은 태그의 전체 기록을 함께 참고해서 평균/경향을 보정해. - - 위 기록이 부족하면 사용자의 최근 타이머 기록 경향을 참고해. - - 모든 기록이 비어 있으면 현재 투두명과 태그만 기준으로 판단해. + - 먼저 현재 투두명과 비슷한 과거 투두의 실제 소요시간을 확인해. + - 그다음 사용자가 지정한 태그에서 최근 실제 소요시간 경향을 참고해. + - 비슷한 투두명 기록과 태그 기록이 모두 있으면 둘을 함께 보고, 비슷한 투두명 기록을 조금 더 중요하게 봐. + - 기록이 아예 없으면 현재 투두명만 기준으로 일반적인 예상 소요 시간을 판단해. 규칙: - 응답은 반드시 JSON 객체 하나만 반환해. - - recommendedMinutes는 5분 단위 정수여야 해. - - recommendedMinutes는 %d분 이상 %d분 이하만 가능해. - - feedback은 한국어 1문장으로 부드럽게 작성해. + - recommendedMinutes는 분 단위 정수로 반환해. - 실제 기록에 없는 패턴은 만들지 마. - - patternBasis는 가장 크게 참고한 근거를 기준으로 SIMILAR_TITLE, SAME_TAG, RECENT_HISTORY, CURRENT_ONLY 중 하나만 사용해. 응답 JSON 형식: { - "recommendedMinutes": 45, - "patternBasis": "SAME_TAG", - "feedback": "같은 태그의 실제 소요시간 기록을 기준으로 45분 정도를 추천해요." + "recommendedMinutes": 45 } 현재 투두: @@ -49,22 +41,16 @@ public String build( "tagId": %s } - 같은 태그 내 비슷한 투두명 기록: + 비슷한 투두명 실제 소요시간 기록: %s - 같은 태그 기록: - %s - - 최근 기록: + 사용자가 지정한 태그의 최근 실제 소요시간 기록: %s """.formatted( - minMinutes, - maxMinutes, escape(request.title()), request.tagId() == null ? "null" : request.tagId().toString(), - formatHistories(sameTagSimilarTitleHistories), - formatHistories(sameTagHistories), - formatHistories(recentHistories) + formatHistories(similarTitleHistories), + formatHistories(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 index 907b358f..ce31880c 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java @@ -4,14 +4,12 @@ import java.time.ZoneOffset; import java.util.List; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; 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.RecommendDurationResponse; -import com.Timo.Timo.domain.ai.enums.PatternBasis; import com.Timo.Timo.domain.ai.prompt.TodoDurationPromptBuilder; import com.Timo.Timo.domain.ai.repository.AiTodoQueryRepository; import com.Timo.Timo.domain.ai.repository.TodoDurationHistory; @@ -26,35 +24,27 @@ @Transactional(readOnly = true) public class AiTodoService { - private static final int HISTORY_LIMIT = 10; + 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 AiTodoQueryRepository aiTodoQueryRepository; private final TodoDurationPromptBuilder promptBuilder; private final GeminiService geminiService; private final ObjectMapper objectMapper; - - @Value("${ai.duration.min-minutes:5}") - private int minMinutes; - - @Value("${ai.duration.max-minutes:240}") - private int maxMinutes; - - @Value("${ai.duration.default-minutes:30}") - private int defaultMinutes; + private final AiRequestRateLimiter rateLimiter; public RecommendDurationResponse recommendDuration(Long userId, RecommendDurationRequest request) { LocalDate today = LocalDate.now(ZoneOffset.UTC); - List sameTagSimilarTitleHistories = request.tagId() == null - ? List.of() - : aiTodoQueryRepository.findActualDurationHistoriesBySimilarTitleAndTag( + List similarTitleHistories = + aiTodoQueryRepository.findActualDurationHistoriesBySimilarTitle( userId, request.title(), - request.tagId(), today, HISTORY_LIMIT ); - List sameTagHistories = request.tagId() == null + List recentTagHistories = request.tagId() == null ? List.of() : aiTodoQueryRepository.findActualDurationHistoriesByTagId( userId, @@ -62,26 +52,18 @@ public RecommendDurationResponse recommendDuration(Long userId, RecommendDuratio today, HISTORY_LIMIT ); - List recentHistories = aiTodoQueryRepository.findRecentActualDurationHistories( - userId, - today, - HISTORY_LIMIT - ); String prompt = promptBuilder.build( request, - sameTagSimilarTitleHistories, - sameTagHistories, - recentHistories, - minMinutes, - maxMinutes + similarTitleHistories, + recentTagHistories ); + rateLimiter.validate(userId, estimateTokenCost(prompt)); log.info( - "AI duration recommendation histories loaded. sameTagSimilarTitle={}, sameTag={}, recent={}", - sameTagSimilarTitleHistories.size(), - sameTagHistories.size(), - recentHistories.size() + "AI duration recommendation histories loaded. similarTitle={}, recentTag={}", + similarTitleHistories.size(), + recentTagHistories.size() ); String geminiJson = geminiService.generateJson(prompt); @@ -103,25 +85,21 @@ private GeminiDurationRecommendation parseRecommendation(String geminiJson) { private RecommendDurationResponse validate(GeminiDurationRecommendation recommendation) { if (recommendation == null || recommendation.recommendedMinutes() == null - || recommendation.patternBasis() == null - || recommendation.feedback() == null - || recommendation.feedback().isBlank() ) { throw new IllegalArgumentException("Gemini recommendation has missing fields."); } int recommendedMinutes = normalizeMinutes(recommendation.recommendedMinutes()); - return new RecommendDurationResponse( - recommendedMinutes, - recommendation.patternBasis(), - recommendation.feedback() - ); + return new RecommendDurationResponse(recommendedMinutes); } private int normalizeMinutes(int minutes) { - int rounded = Math.round(minutes / 5.0f) * 5; - return Math.max(minMinutes, Math.min(maxMinutes, rounded)); + 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) { From 437e3932f82531af9023b945a5b0a85373ec080a Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 06:55:05 +0900 Subject: [PATCH 187/383] =?UTF-8?q?feat:=20AI=20request=20rate=20limiter?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/ai/enums/PatternBasis.java | 8 - .../Timo/domain/ai/exception/AiErrorCode.java | 19 +++ .../ai/repository/AiTodoQueryRepository.java | 31 +--- .../ai/service/AiRequestRateLimiter.java | 139 ++++++++++++++++++ src/main/resources/application-local.yml | 10 +- 5 files changed, 167 insertions(+), 40 deletions(-) delete mode 100644 src/main/java/com/Timo/Timo/domain/ai/enums/PatternBasis.java create mode 100644 src/main/java/com/Timo/Timo/domain/ai/exception/AiErrorCode.java create mode 100644 src/main/java/com/Timo/Timo/domain/ai/service/AiRequestRateLimiter.java diff --git a/src/main/java/com/Timo/Timo/domain/ai/enums/PatternBasis.java b/src/main/java/com/Timo/Timo/domain/ai/enums/PatternBasis.java deleted file mode 100644 index d9a29358..00000000 --- a/src/main/java/com/Timo/Timo/domain/ai/enums/PatternBasis.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.Timo.Timo.domain.ai.enums; - -public enum PatternBasis { - SIMILAR_TITLE, - SAME_TAG, - RECENT_HISTORY, - CURRENT_ONLY -} 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..9ff309a2 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/exception/AiErrorCode.java @@ -0,0 +1,19 @@ +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 추천 요청이 많습니다. 잠시 후 다시 시도해주세요."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} 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 index 60a75daf..44750355 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java +++ b/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java @@ -17,10 +17,9 @@ public class AiTodoQueryRepository { private final EntityManager entityManager; - public List findActualDurationHistoriesBySimilarTitleAndTag( + public List findActualDurationHistoriesBySimilarTitle( Long userId, String title, - Long tagId, LocalDate today, int limit ) { @@ -36,7 +35,6 @@ public List findActualDurationHistoriesBySimilarTitleAndTag and tr.user_id = :userId and tr.actual_seconds is not null and date(coalesce(tr.ended_at, tr.started_at)) <= :today - and t.tag_id = :tagId and ( lower(t.title) like lower(concat('%', :title, '%')) or lower(:title) like lower(concat('%', t.title, '%')) @@ -45,7 +43,6 @@ order by coalesce(tr.ended_at, tr.started_at) desc, tr.id desc """) .setParameter("userId", userId) .setParameter("title", title) - .setParameter("tagId", tagId) .setParameter("today", today) .setMaxResults(limit); @@ -81,32 +78,6 @@ order by coalesce(tr.ended_at, tr.started_at) desc, tr.id desc return toHistories(query.getResultList()); } - public List findRecentActualDurationHistories( - Long userId, - LocalDate today, - int limit - ) { - Query query = entityManager.createNativeQuery(""" - select - t.title, - t.tag_id, - tr.actual_seconds, - date(coalesce(tr.ended_at, tr.started_at)) as recorded_date - from todos t - join timer_records tr on tr.todo_id = t.id - where t.user_id = :userId - and tr.user_id = :userId - and tr.actual_seconds is not null - and date(coalesce(tr.ended_at, tr.started_at)) <= :today - order by coalesce(tr.ended_at, tr.started_at) desc, tr.id desc - """) - .setParameter("userId", userId) - .setParameter("today", today) - .setMaxResults(limit); - - return toHistories(query.getResultList()); - } - @SuppressWarnings("unchecked") private List toHistories(List rows) { return ((List)rows).stream() 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..edc5410f --- /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()); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index c3776b83..b94caf5b 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -40,5 +40,11 @@ jwt: ai: gemini: - api-key: ${GEMINI_API_KEY:} - model: ${GEMINI_MODEL:gemini-3.1-flash-lite} + 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} From d093cb52064dc64120d4cd41f2b724346f379d1b Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 06:55:11 +0900 Subject: [PATCH 188/383] =?UTF-8?q?feat:=20gemini=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=EC=9D=84=20=ED=99=98=EA=B2=BD=20=EB=B3=80?= =?UTF-8?q?=EC=88=98=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/domain/ai/service/GeminiService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index db9afb9b..fee5755f 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java @@ -26,7 +26,7 @@ public class GeminiService { @Value("${ai.gemini.api-key:}") private String apiKey; - @Value("${ai.gemini.model:gemini-2.0-flash}") + @Value("${ai.gemini.model}") private String model; public String generateJson(String prompt) { From eaa3ad38c78d6a22e6f943b8730934fc0e0f1b40 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 08:22:07 +0900 Subject: [PATCH 189/383] =?UTF-8?q?feat:=20=ED=95=A0=20=EC=9D=BC=20?= =?UTF-8?q?=EC=86=8C=EC=9A=94=20=EC=8B=9C=EA=B0=84=20=EC=9D=B4=EB=A0=A5=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EB=A5=BC=20=EC=9C=84=ED=95=9C=20AiTodoHistor?= =?UTF-8?q?ies=20=EB=B0=8F=20AiTodoHistoryService=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/ai/service/AiTodoHistories.java | 11 +++++ .../ai/service/AiTodoHistoryService.java | 46 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistories.java create mode 100644 src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java 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..be71d046 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistories.java @@ -0,0 +1,11 @@ +package com.Timo.Timo.domain.ai.service; + +import java.util.List; + +import com.Timo.Timo.domain.ai.repository.TodoDurationHistory; + +public record AiTodoHistories( + List similarTitleHistories, + List recentTagHistories +) { +} 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..2e58e874 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java @@ -0,0 +1,46 @@ +package com.Timo.Timo.domain.ai.service; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.Timo.Timo.domain.ai.repository.AiTodoQueryRepository; +import com.Timo.Timo.domain.ai.repository.TodoDurationHistory; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AiTodoHistoryService { + + private final AiTodoQueryRepository aiTodoQueryRepository; + + @Transactional(readOnly = true) + public AiTodoHistories findHistories( + Long userId, + String title, + Long tagId, + LocalDate today, + int limit + ) { + List similarTitleHistories = + aiTodoQueryRepository.findActualDurationHistoriesBySimilarTitle( + userId, + title, + today, + limit + ); + List recentTagHistories = tagId == null + ? List.of() + : aiTodoQueryRepository.findActualDurationHistoriesByTagId( + userId, + tagId, + today, + limit + ); + + return new AiTodoHistories(similarTitleHistories, recentTagHistories); + } +} From d1e89195a16d6fbbc3484e07fc8107a23a769998 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 08:22:39 +0900 Subject: [PATCH 190/383] =?UTF-8?q?feat:=20=EC=88=98=EC=A0=95=EB=90=9C=20A?= =?UTF-8?q?I=20=EC=86=8C=EC=9A=94=20=EC=8B=9C=EA=B0=84=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20API=20=EB=A1=9C=EA=B7=B8=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=A0=9C=EB=AA=A9=20=EC=A0=95=EB=B3=B4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/ai/controller/AiTodoController.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 index 95e61771..f90ec4a1 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java +++ b/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java @@ -36,9 +36,8 @@ public ResponseEntity> recommendDuration @Valid @RequestBody RecommendDurationRequest request ) { log.info( - "AI duration recommendation API called. userId={}, title={}, tagId={}", + "AI duration recommendation API called. userId={}, tagId={}", userDetails.getUserId(), - request.title(), request.tagId() ); From e990b8131e47fc8ec89b3d42506e6222b4fa3695 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 08:22:56 +0900 Subject: [PATCH 191/383] =?UTF-8?q?feat:=20=EA=B0=9C=EC=84=A0=EB=90=9C=20G?= =?UTF-8?q?eminiService=EC=97=90=EC=84=9C=20API=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EC=8B=9C=20=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20API=20=ED=82=A4=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/ai/service/GeminiService.java | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) 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 index fee5755f..add42a84 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java @@ -1,10 +1,12 @@ 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; @@ -18,10 +20,12 @@ public class GeminiService { private static final String GENERATE_CONTENT_URL = - "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={apiKey}"; + "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 = RestClient.create(); + private final RestClient restClient = createRestClient(); @Value("${ai.gemini.api-key:}") private String apiKey; @@ -33,6 +37,9 @@ public String generateJson(String prompt) { if (apiKey == null || apiKey.isBlank()) { throw new IllegalStateException("Gemini API key is not configured."); } + if (model == null || model.isBlank()) { + throw new IllegalStateException("Gemini model is not configured."); + } Map request = Map.of( "contents", List.of(Map.of( @@ -45,8 +52,9 @@ public String generateJson(String prompt) { ); String response = restClient.post() - .uri(GENERATE_CONTENT_URL, model, apiKey) + .uri(GENERATE_CONTENT_URL, model) .contentType(MediaType.APPLICATION_JSON) + .header("x-goog-api-key", apiKey) .body(request) .retrieve() .body(String.class); @@ -54,6 +62,16 @@ public String generateJson(String prompt) { 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); From 3ef8e07c98e43f81b6cf9b3f86533f73d080b9f2 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 08:23:01 +0900 Subject: [PATCH 192/383] =?UTF-8?q?feat:=20TodoDurationPromptBuilder?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=AC=B8=EC=9E=90=EC=97=B4=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EA=B0=9C=EC=84=A0=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EA=B3=B5=EB=B0=B1=20=EB=B0=8F=20=ED=83=AD=20=EB=AC=B8=EC=9E=90?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/ai/prompt/TodoDurationPromptBuilder.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index a640a2a3..b19b7f70 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java +++ b/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java @@ -85,6 +85,9 @@ private String escape(String value) { } return value .replace("\\", "\\\\") - .replace("\"", "\\\""); + .replace("\"", "\\\"") + .replace("\n", " ") + .replace("\r", " ") + .replace("\t", " "); } } From 1f4e3fef5850a63c68f84d3e661f73cc5e731d7d Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 08:23:06 +0900 Subject: [PATCH 193/383] =?UTF-8?q?feat:=20AiTodoService=EC=97=90=EC=84=9C?= =?UTF-8?q?=20AiTodoHistoryService=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EC=9D=B4=EB=A0=A5=20=EC=A1=B0=ED=9A=8C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/ai/service/AiTodoService.java | 37 ++++++------------- 1 file changed, 12 insertions(+), 25 deletions(-) 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 index ce31880c..5113b429 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java @@ -2,17 +2,13 @@ import java.time.LocalDate; import java.time.ZoneOffset; -import java.util.List; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; 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.RecommendDurationResponse; import com.Timo.Timo.domain.ai.prompt.TodoDurationPromptBuilder; -import com.Timo.Timo.domain.ai.repository.AiTodoQueryRepository; -import com.Timo.Timo.domain.ai.repository.TodoDurationHistory; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; @@ -21,14 +17,13 @@ @Slf4j @Service @RequiredArgsConstructor -@Transactional(readOnly = true) 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 AiTodoQueryRepository aiTodoQueryRepository; + private final AiTodoHistoryService historyService; private final TodoDurationPromptBuilder promptBuilder; private final GeminiService geminiService; private final ObjectMapper objectMapper; @@ -37,33 +32,25 @@ public class AiTodoService { public RecommendDurationResponse recommendDuration(Long userId, RecommendDurationRequest request) { LocalDate today = LocalDate.now(ZoneOffset.UTC); - List similarTitleHistories = - aiTodoQueryRepository.findActualDurationHistoriesBySimilarTitle( - userId, - request.title(), - today, - HISTORY_LIMIT - ); - List recentTagHistories = request.tagId() == null - ? List.of() - : aiTodoQueryRepository.findActualDurationHistoriesByTagId( - userId, - request.tagId(), - today, - HISTORY_LIMIT - ); + AiTodoHistories histories = historyService.findHistories( + userId, + request.title(), + request.tagId(), + today, + HISTORY_LIMIT + ); String prompt = promptBuilder.build( request, - similarTitleHistories, - recentTagHistories + histories.similarTitleHistories(), + histories.recentTagHistories() ); rateLimiter.validate(userId, estimateTokenCost(prompt)); log.info( "AI duration recommendation histories loaded. similarTitle={}, recentTag={}", - similarTitleHistories.size(), - recentTagHistories.size() + histories.similarTitleHistories().size(), + histories.recentTagHistories().size() ); String geminiJson = geminiService.generateJson(prompt); From f9d44a29eb979c7c77b0fdda5b665b2886caa105 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 08:23:20 +0900 Subject: [PATCH 194/383] =?UTF-8?q?fix:=20application-local.yml=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-local.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index b94caf5b..0f904202 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -45,6 +45,6 @@ ai: rate-limit: user-rpm: ${AI_USER_RPM} global-rpm: ${AI_GLOBAL_RPM} - user-지rpd: ${AI_USER_RPD} + user-rpd: ${AI_USER_RPD} global-rpd: ${AI_GLOBAL_RPD} global-tpm: ${AI_GLOBAL_TPM} From 80ece1aa1b1e785c9d4026bc9c09b8c91b71724d Mon Sep 17 00:00:00 2001 From: jy000n Date: Wed, 8 Jul 2026 14:36:59 +0900 Subject: [PATCH 195/383] =?UTF-8?q?feat:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/controller/TimerController.java | 40 ++++++++++ .../timer/docs/TimerControllerDocs.java | 77 +++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java create mode 100644 src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java 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..94ce8e72 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java @@ -0,0 +1,40 @@ +package com.Timo.Timo.domain.timer.controller; + +import com.Timo.Timo.domain.timer.docs.TimerControllerDocs; +import com.Timo.Timo.domain.timer.dto.response.TimerStartResponse; +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 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.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +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 TimerControllerDocs { + + private final TimerService timerService; + + @Override + @PostMapping("/todos/{todoId}/timers/start") + public ResponseEntity> startTimer( + @PathVariable Long todoId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + + Long userId = userDetails.getUser().getId(); + TimerStartResponse response = timerService.startTimer(userId, todoId); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(BaseResponse.onSuccess(TimerSuccessCode.TIMER_STARTED, response)); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java new file mode 100644 index 00000000..61a2a4c9 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.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 TimerControllerDocs { + + @Operation( + summary = "타이머 시작", + description = """ + 투두의 타이머를 시작합니다. + 한 번에 한 개의 타이머만 실행 가능하며, 이미 실행/일시정지 중인 타이머가 있으면 409를 반환합니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "201", + 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 = "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 + ); +} From 52e9844dd4f7579ed72594fdbc852f989d860281 Mon Sep 17 00:00:00 2001 From: jy000n Date: Wed, 8 Jul 2026 14:37:48 +0900 Subject: [PATCH 196/383] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=97=AC=EB=B0=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/timer/exception/TimerErrorCode.java | 1 - .../com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java | 1 - 2 files changed, 2 deletions(-) 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 index 439bf108..69549ed3 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/exception/TimerErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/timer/exception/TimerErrorCode.java @@ -10,7 +10,6 @@ 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", "요청을 처리할 수 없는 타이머 상태입니다."); 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 index dd6eb2f8..4ee0d0f8 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java @@ -11,7 +11,6 @@ public enum TimerSuccessCode implements BaseSuccessCode { TIMER_PAUSED(HttpStatus.OK, "TIMER_200", "타이머가 일시정지되었습니다."), TIMER_RESUMED(HttpStatus.OK, "TIMER_200", "타이머가 재개되었습니다."), - TIMER_STARTED(HttpStatus.CREATED, "TIMER_201", "타이머가 시작되었습니다."); private final HttpStatus httpStatus; From 3f85830b5c4a6f2ed4feae9a13fd8248ebadab8f Mon Sep 17 00:00:00 2001 From: jy000n Date: Wed, 8 Jul 2026 14:44:14 +0900 Subject: [PATCH 197/383] =?UTF-8?q?refactor:=20TimerResponseFactory=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85=20=EB=B0=8F=20TimerController=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/controller/TimerController.java | 7 +++---- .../timer/factory/TimerResponseFactory.java | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java 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 index 94ce8e72..0817ab82 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java +++ b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java @@ -2,13 +2,12 @@ import com.Timo.Timo.domain.timer.docs.TimerControllerDocs; import com.Timo.Timo.domain.timer.dto.response.TimerStartResponse; -import com.Timo.Timo.domain.timer.exception.TimerSuccessCode; +import com.Timo.Timo.domain.timer.factory.TimerResponseFactory; 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 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.PathVariable; @@ -23,6 +22,7 @@ public class TimerController implements TimerControllerDocs { private final TimerService timerService; + private final TimerResponseFactory timerResponseFactory; @Override @PostMapping("/todos/{todoId}/timers/start") @@ -34,7 +34,6 @@ public ResponseEntity> startTimer( Long userId = userDetails.getUser().getId(); TimerStartResponse response = timerService.startTimer(userId, todoId); - return ResponseEntity.status(HttpStatus.CREATED) - .body(BaseResponse.onSuccess(TimerSuccessCode.TIMER_STARTED, response)); + return timerResponseFactory.startResponse(response); } } diff --git a/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java b/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java new file mode 100644 index 00000000..4eb36503 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java @@ -0,0 +1,17 @@ +package com.Timo.Timo.domain.timer.factory; + +import com.Timo.Timo.domain.timer.dto.response.TimerStartResponse; +import com.Timo.Timo.domain.timer.exception.TimerSuccessCode; +import com.Timo.Timo.global.response.BaseResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; + +@Component +public class TimerResponseFactory { + + public ResponseEntity> startResponse(TimerStartResponse response){ + return ResponseEntity.status(HttpStatus.CREATED) + .body(BaseResponse.onSuccess(TimerSuccessCode.TIMER_STARTED, response)); + } +} From 7ca1c1d7bc475a6222585d00be77ad8f3d572d5e Mon Sep 17 00:00:00 2001 From: jy000n Date: Wed, 8 Jul 2026 14:45:45 +0900 Subject: [PATCH 198/383] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83/=ED=83=88=ED=87=B4=20=EC=9D=91=EB=8B=B5=EC=97=90=20Ca?= =?UTF-8?q?che-Control=20no-store=20=ED=97=A4=EB=8D=94=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/factory/AuthResponseFactory.java | 1 + 1 file changed, 1 insertion(+) 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 index 03208c03..c470653b 100644 --- a/src/main/java/com/Timo/Timo/global/auth/factory/AuthResponseFactory.java +++ b/src/main/java/com/Timo/Timo/global/auth/factory/AuthResponseFactory.java @@ -52,6 +52,7 @@ private ResponseEntity> expiredCookieResponse(AuthSuccessCode 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)); } From c79cfb6473699a98ea78060a7a3bdb2b548c7a51 Mon Sep 17 00:00:00 2001 From: jy000n Date: Wed, 8 Jul 2026 15:27:10 +0900 Subject: [PATCH 199/383] =?UTF-8?q?docs:=20swagger=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/docs/TimerControllerDocs.java | 10 ++++- .../user/docs/OnboardingControllerDocs.java | 43 ++++++++++++++++++- .../auth/controller/AuthController.java | 12 ------ .../global/auth/docs/AuthControllerDocs.java | 16 +++---- 4 files changed, 56 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java index 61a2a4c9..9f63bf67 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java @@ -18,7 +18,7 @@ public interface TimerControllerDocs { @Operation( summary = "타이머 시작", description = """ - 투두의 타이머를 시작합니다. + 투두의 타이머를 시작합니다.
한 번에 한 개의 타이머만 실행 가능하며, 이미 실행/일시정지 중인 타이머가 있으면 409를 반환합니다. """ ) @@ -52,6 +52,14 @@ public interface TimerControllerDocs { schema = @Schema(implementation = ErrorDto.class) ) ), + @ApiResponse( + responseCode = "403", + description = "본인 소유의 투두가 아닌 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), @ApiResponse( responseCode = "409", description = "이미 실행 중인 타이머가 있는 경우", 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 index 5fb72056..13b7de82 100644 --- a/src/main/java/com/Timo/Timo/domain/user/docs/OnboardingControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/user/docs/OnboardingControllerDocs.java @@ -3,13 +3,54 @@ 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 = "언어, 예측 정확도, 기상/취침 시간을 저장하고 온보딩을 완료 처리합니다.") + @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/global/auth/controller/AuthController.java b/src/main/java/com/Timo/Timo/global/auth/controller/AuthController.java index 0fff9596..dfeb28ad 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 @@ -11,9 +11,6 @@ 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.security.SecurityRequirements; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; @@ -36,15 +33,6 @@ public class AuthController implements AuthControllerDocs { private final AuthService authService; private final AuthResponseFactory authResponseFactory; - @Operation(summary = "AccessToken 발급", description = "1회성 code로 AccessToken을 발급합니다.") - @SecurityRequirements - @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( 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 index dbae0502..d8250639 100644 --- a/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java +++ b/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java @@ -24,12 +24,8 @@ public interface AuthControllerDocs { @Operation( summary = "AccessToken 발급", description = """ - 1회성 인증 코드(code)를 AccessToken으로 교환 - - isNewUser: 이번 요청으로 신규 회원가입이 이루어졌는지 여부 (true면 아직 온보딩을 진행한 적이 없는 사용자) - - user.onboardingCompleted: 온보딩 완료 여부 (false면 온보딩 화면으로, true면 홈 화면으로 이동) - - 기존 가입자가 온보딩을 중단한 경우에도 isNewUser는 false, onboardingCompleted는 false로 반환될 수 가능 - - RefreshToken과 sessionId는 Set-Cookie 헤더로 전달되며, 응답 바디에는 포함되지 않음 - """ + 1회성 인증 코드(code)를 AccessToken으로 교환합니다. + """ ) @SecurityRequirements @io.swagger.v3.oas.annotations.parameters.RequestBody( @@ -93,8 +89,8 @@ ResponseEntity> token( @Operation( summary = "AccessToken 재발급", description = """ - 쿠키로 전달된 RefreshToken과 sessionId를 검증하여 AccessToken 재발급 - 재발급 성공 시 RefreshToken과 sessionId 쿠키 갱신 + 쿠키로 전달된 RefreshToken과 sessionId를 검증하여 AccessToken 재발급합니다.
+ 재발급 성공 시 RefreshToken과 sessionId 쿠키를 갱신합니다. """ ) @@ -139,7 +135,6 @@ ResponseEntity> reissue( summary = "로그아웃", description = """ 현재 세션을 로그아웃하고 RefreshToken 및 sessionId 쿠키를 만료시킵니다. - Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. """ ) @ApiResponses({ @@ -174,8 +169,7 @@ ResponseEntity> logout( @Operation( summary = "회원 탈퇴", description = """ - 회원 탈퇴를 진행하며, 사용자와 관련된 모든 데이터를 영구 삭제합니다. - Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. + 회원 탈퇴를 진행하며, 사용자와 관련된 모든 데이터를 영구 삭제합니다.
이 작업은 되돌릴 수 없습니다. """ ) From ba397ee0362e1325f0c956cdef4d18e4af3e15e5 Mon Sep 17 00:00:00 2001 From: jy000n Date: Wed, 8 Jul 2026 15:37:46 +0900 Subject: [PATCH 200/383] =?UTF-8?q?feat:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=20Service=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/docs/TimerControllerDocs.java | 8 +- .../domain/timer/service/TimerService.java | 74 +++++++++++++++++++ .../domain/todo/exception/TodoErrorCode.java | 3 +- 3 files changed, 80 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java diff --git a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java index 9f63bf67..91866880 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java @@ -45,16 +45,16 @@ public interface TimerControllerDocs { ) ), @ApiResponse( - responseCode = "404", - description = "존재하지 않는 투두인 경우", + responseCode = "403", + description = "본인 소유의 투두가 아닌 경우", content = @Content( mediaType = "application/json", schema = @Schema(implementation = ErrorDto.class) ) ), @ApiResponse( - responseCode = "403", - description = "본인 소유의 투두가 아닌 경우", + responseCode = "404", + description = "존재하지 않는 투두인 경우", content = @Content( mediaType = "application/json", schema = @Schema(implementation = ErrorDto.class) 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..4096f42f --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -0,0 +1,74 @@ +package com.Timo.Timo.domain.timer.service; + +import com.Timo.Timo.domain.timer.dto.response.TimerStartResponse; +import com.Timo.Timo.domain.timer.entity.TimerRecord; +import com.Timo.Timo.domain.timer.entity.TimerSession; +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.exception.TodoErrorCode; +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.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@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; + + @Transactional + public TimerStartResponse startTimer(Long userId, Long todoId) { + User user = userRepository.findById(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); + } + + if (todo.getDurationSeconds() == null) { + throw new CustomException(ErrorCode.BAD_REQUEST); + } + + 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) + .plannedMinutes(todo.getDurationSeconds()) + .startedAt(now) + .build(); + timerRecordRepository.save(timerRecord); + + TimerSession session = TimerSession.builder() + .timerRecord(timerRecord) + .startedAt(now) + .build(); + timerSessionRepository.save(session); + + return TimerStartResponse.from(timerRecord); + } +} \ No newline at end of file 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 index eae57c03..61fc9928 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java @@ -13,7 +13,8 @@ public enum TodoErrorCode implements BaseErrorCode { INVALID_REQUEST(HttpStatus.BAD_REQUEST, "TODO_400", "필수 필드가 누락되었거나 형식이 올바르지 않습니다."), INVALID_TITLE(HttpStatus.BAD_REQUEST, "TODO_400", "투두명은 한국어 20자/영어 30자를 초과할 수 없습니다."), - MAX_COUNT_EXCEEDED(HttpStatus.CONFLICT, "TODO_409", "해당 날짜의 투두가 최대 개수(20개)를 초과했습니다."); + TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_404", "존재하지 않는 투두입니다"), + MAX_COUNT_EXCEEDED(HttpStatus.CONFLICT, "TODO_409", "해당 날짜의 투두가 최대 개수(20개)를 초과했습니다."); private final HttpStatus httpStatus; private final String code; From 9900126f900ddc75016a433fec4fb0dd05d53b50 Mon Sep 17 00:00:00 2001 From: jy000n Date: Wed, 8 Jul 2026 16:00:01 +0900 Subject: [PATCH 201/383] =?UTF-8?q?fix:=20TimerRecord=20pause/resume?= =?UTF-8?q?=EC=97=90=20=EC=83=81=ED=83=9C=20=EC=A0=84=EC=9D=B4=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/timer/entity/TimerRecord.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 index 160f391d..ea3ebcab 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java +++ b/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java @@ -1,8 +1,10 @@ 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; @@ -79,11 +81,17 @@ private TimerRecord(User user, Todo todo, Integer plannedMinutes, LocalDateTime this.status = TimerStatus.RUNNING; } - public void pause(){ + 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; } From 130e73e9a10c8e222c564d5c4d335a7eb7797e9c Mon Sep 17 00:00:00 2001 From: jy000n Date: Wed, 8 Jul 2026 16:02:23 +0900 Subject: [PATCH 202/383] =?UTF-8?q?fix:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=20API=EC=9D=98=20TOCTOU=20race=20condition?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/domain/timer/service/TimerService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 4096f42f..4eebe23c 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -35,7 +35,7 @@ public class TimerService { @Transactional public TimerStartResponse startTimer(Long userId, Long todoId) { - User user = userRepository.findById(userId) + User user = userRepository.findByIdForUpdate(userId) .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); Todo todo = todoRepository.findById(todoId) .orElseThrow(() -> new CustomException(TodoErrorCode.TODO_NOT_FOUND)); From c580368824d23428f94cae70c2eb1aeb9f3506c2 Mon Sep 17 00:00:00 2001 From: jy000n Date: Wed, 8 Jul 2026 16:41:13 +0900 Subject: [PATCH 203/383] =?UTF-8?q?refactor:=20TimerRecord=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EB=8B=A8=EC=9C=84=EB=A5=BC=20=EB=B6=84=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=B4=88=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/dto/response/TimerStartResponse.java | 4 ++-- .../Timo/Timo/domain/timer/entity/TimerRecord.java | 14 +++++++------- .../Timo/domain/timer/service/TimerService.java | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) 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 index dcc9addc..71c1b8f3 100644 --- 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 @@ -7,7 +7,7 @@ public record TimerStartResponse( Long timerId, Long todoId, String status, - Integer plannedMinutes, + Integer plannedSeconds, LocalDateTime startedAt ) { public static TimerStartResponse from(TimerRecord timerRecord) { @@ -15,7 +15,7 @@ public static TimerStartResponse from(TimerRecord timerRecord) { timerRecord.getId(), timerRecord.getTodo().getId(), timerRecord.getStatus().name(), - timerRecord.getPlannedMinutes(), + timerRecord.getPlannedSeconds(), timerRecord.getStartedAt() ); } 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 index ea3ebcab..c61f861c 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java +++ b/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java @@ -45,11 +45,11 @@ public class TimerRecord { @JoinColumn(name = "todo_id", nullable = false) private Todo todo; - @Column(name = "planned_minutes", nullable = false) - private Integer plannedMinutes; + @Column(name = "planned_seconds", nullable = false) + private Integer plannedSeconds; - @Column(name = "extended_minutes", nullable = false) - private Integer extendedMinutes; + @Column(name = "extended_seconds", nullable = false) + private Integer extendedSeconds; @Column(name = "started_at", nullable = false) private LocalDateTime startedAt; @@ -72,12 +72,12 @@ public class TimerRecord { private LocalDateTime createdAt; @Builder - private TimerRecord(User user, Todo todo, Integer plannedMinutes, LocalDateTime startedAt) { + private TimerRecord(User user, Todo todo, Integer plannedSeconds, LocalDateTime startedAt) { this.user = user; this.todo = todo; - this.plannedMinutes = plannedMinutes; + this.plannedSeconds = plannedSeconds; this.startedAt = startedAt; - this.extendedMinutes = 0; + this.extendedSeconds = 0; this.status = TimerStatus.RUNNING; } 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 index 4eebe23c..34e23147 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -58,7 +58,7 @@ public TimerStartResponse startTimer(Long userId, Long todoId) { TimerRecord timerRecord = TimerRecord.builder() .user(user) .todo(todo) - .plannedMinutes(todo.getDurationSeconds()) + .plannedSeconds(todo.getDurationSeconds()) .startedAt(now) .build(); timerRecordRepository.save(timerRecord); From a71066fc27627faa8f3e65f7a9d6ab7c6f57775d Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:20:08 +0900 Subject: [PATCH 204/383] =?UTF-8?q?feat(todo):=20=ED=88=AC=EB=91=90=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=9A=A9=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95=20(=ED=83=80=EC=9D=B4=EB=A8=B8=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=C2=B7=EC=99=84=EB=A3=8C=20=EC=97=AC=EB=B6=80=20?= =?UTF-8?q?=EB=93=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/tag/entity/Tag.java | 3 +++ .../Timo/Timo/domain/todo/entity/Subtask.java | 4 ++++ .../Timo/Timo/domain/todo/entity/Todo.java | 3 +++ .../Timo/domain/todo/entity/TodoInstance.java | 24 +++++++++++++++++++ .../domain/todo/enums/TodoTimerStatus.java | 7 ++++++ .../Timo/Timo/domain/todo/enums/Weekday.java | 16 ++++++++++++- .../todo/repository/TodoRepository.java | 1 + 7 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/Timo/Timo/domain/todo/enums/TodoTimerStatus.java 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 index a553b11a..528669a2 100644 --- a/src/main/java/com/Timo/Timo/domain/tag/entity/Tag.java +++ b/src/main/java/com/Timo/Timo/domain/tag/entity/Tag.java @@ -20,4 +20,7 @@ public class Tag { @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") private Long id; + + @Column(name = "name", length = 20) + private String name; } 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 index 7f59d06e..ea51ba1a 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/entity/Subtask.java +++ b/src/main/java/com/Timo/Timo/domain/todo/entity/Subtask.java @@ -36,10 +36,14 @@ public class Subtask extends BaseTimeEntity { @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) { Subtask subtask = new Subtask(); subtask.content = content; subtask.sortOrder = sortOrder; + subtask.completed = false; return subtask; } 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 index a8efb38c..47835a1b 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java +++ b/src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java @@ -161,6 +161,9 @@ public static Todo create( } public List getSubtasks() { + if (subtasks == null) { + return List.of(); + } return Collections.unmodifiableList(subtasks); } 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 index 1bec4612..a8692ed5 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/entity/TodoInstance.java +++ b/src/main/java/com/Timo/Timo/domain/todo/entity/TodoInstance.java @@ -2,10 +2,13 @@ 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; @@ -48,15 +51,24 @@ public class TodoInstance extends BaseTimeEntity { @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; } @@ -68,4 +80,16 @@ public void markIncomplete() { 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/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 index 73ccd0ae..6a9beb77 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/enums/Weekday.java +++ b/src/main/java/com/Timo/Timo/domain/todo/enums/Weekday.java @@ -1,5 +1,7 @@ package com.Timo.Timo.domain.todo.enums; +import java.time.DayOfWeek; + public enum Weekday { MON, TUE, @@ -7,5 +9,17 @@ public enum Weekday { THU, FRI, SAT, - SUN + 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/repository/TodoRepository.java b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java index 12c1e6d5..06e75a06 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java @@ -16,6 +16,7 @@ public interface TodoRepository extends JpaRepository { 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, From a430a6e8c02bcd2338a709892117c0d2befa73be Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:21:38 +0900 Subject: [PATCH 205/383] =?UTF-8?q?feat(user):=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=EB=B3=84=20=EC=8B=9C=EA=B0=84=EB=8C=80(zoneId)=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserController.java | 19 ++++++++++++++- .../dto/response/UserProfileResponse.java | 2 ++ .../Timo/Timo/domain/user/entity/User.java | 8 +++++++ .../Timo/Timo/domain/user/enums/Language.java | 15 ++++++++++-- .../domain/user/exception/UserErrorCode.java | 3 ++- .../user/exception/UserSuccessCode.java | 3 ++- .../Timo/domain/user/service/UserService.java | 24 +++++++++++++++++++ 7 files changed, 69 insertions(+), 5 deletions(-) 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 index 770e169d..e8a9d859 100644 --- a/src/main/java/com/Timo/Timo/domain/user/controller/UserController.java +++ b/src/main/java/com/Timo/Timo/domain/user/controller/UserController.java @@ -2,6 +2,7 @@ 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; @@ -11,7 +12,9 @@ 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; @@ -26,7 +29,7 @@ @RequestMapping("/api/v1/users") @RequiredArgsConstructor @Tag(name = "User", description = "사용자 API") -public class UserController implements UserProfileDocs, UserLanguageDocs { +public class UserController implements UserProfileDocs, UserLanguageDocs, UserTimezoneDocs { private final UserService userService; @@ -56,4 +59,18 @@ public ResponseEntity> updateLanguage( 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/dto/response/UserProfileResponse.java b/src/main/java/com/Timo/Timo/domain/user/dto/response/UserProfileResponse.java index f9850a35..5f7a3918 100644 --- 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 @@ -8,6 +8,7 @@ public record UserProfileResponse( String email, String profileImageUrl, String language, + String zoneId, boolean calendarConnected, String calendarEmail ) { @@ -19,6 +20,7 @@ public static UserProfileResponse from(User user) { 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 763e0be4..b9d441ca 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 @@ -53,6 +53,9 @@ public class User extends BaseTimeEntity { @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; @@ -84,6 +87,10 @@ public void updateLanguage(Language language) { this.language = language; } + public void updateZoneId(String zoneId) { + this.zoneId = zoneId; + } + @Builder private User( Provider provider, @@ -99,6 +106,7 @@ private User( this.profileImageUrl = profileImageUrl; this.language = Language.KO; + this.zoneId = Language.KO.getDefaultZoneId(); this.wakeUpTime = LocalTime.of(7, 0); this.bedTime = LocalTime.of(23, 0); this.predictionAccuracy = 0L; 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 index 31483389..9327283d 100644 --- a/src/main/java/com/Timo/Timo/domain/user/enums/Language.java +++ b/src/main/java/com/Timo/Timo/domain/user/enums/Language.java @@ -1,6 +1,17 @@ package com.Timo.Timo.domain.user.enums; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor public enum Language { - KO, - EN + 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..b685abc5 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,7 +9,8 @@ @RequiredArgsConstructor public enum UserErrorCode implements BaseErrorCode { - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404", "존재하지 않는 사용자입니다."); + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404", "존재하지 않는 사용자입니다."), + INVALID_TIMEZONE(HttpStatus.BAD_REQUEST, "USER_400", "유효하지 않은 시간대 ID입니다."); private final HttpStatus httpStatus; private final String code; 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 index 61c25b7d..f2398714 100644 --- a/src/main/java/com/Timo/Timo/domain/user/exception/UserSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/user/exception/UserSuccessCode.java @@ -12,7 +12,8 @@ public enum UserSuccessCode implements BaseSuccessCode { PROFILE_RETRIEVED(HttpStatus.OK, "프로필을 조회했습니다."), - LANGUAGE_UPDATED(HttpStatus.OK, "언어설정이 수정되었습니다."); + LANGUAGE_UPDATED(HttpStatus.OK, "언어설정이 수정되었습니다."), + TIMEZONE_UPDATED(HttpStatus.OK, "시간대설정이 수정되었습니다."); private final HttpStatus httpStatus; private final String message; 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 index 22b575e6..ef3e3c05 100644 --- a/src/main/java/com/Timo/Timo/domain/user/service/UserService.java +++ b/src/main/java/com/Timo/Timo/domain/user/service/UserService.java @@ -1,11 +1,16 @@ 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; @@ -36,4 +41,23 @@ public UpdateLanguageResponse updateLanguage(Long userId, UpdateLanguageRequest 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); + } + } } From 7c0589acd191b4933d6daae57a5d35a86b3b79dc Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:29:04 +0900 Subject: [PATCH 206/383] =?UTF-8?q?feat:=20UTC=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=EB=8C=80=20=EA=B8=B0=EC=A4=80=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/Timo/Timo/TimoApplication.java | 13 +++++++++++++ .../com/Timo/Timo/global/common/BaseTimeEntity.java | 4 ++-- src/main/resources/application.yml | 5 +++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/Timo/Timo/TimoApplication.java b/src/main/java/com/Timo/Timo/TimoApplication.java index 2093fce2..04d168d1 100644 --- a/src/main/java/com/Timo/Timo/TimoApplication.java +++ b/src/main/java/com/Timo/Timo/TimoApplication.java @@ -1,13 +1,26 @@ 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; +import jakarta.annotation.PostConstruct; + @SpringBootApplication @EnableJpaAuditing public class TimoApplication { + /** + * 서버가 어느 지역에 배포되든 감사 로그/타임스탬프가 항상 UTC 기준으로 기록되도록 + * JVM 기본 시간대를 UTC로 고정한다. 사용자별 시간대 변환은 각 조회 시점에 처리한다. + */ + @PostConstruct + void init() { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + } + public static void main(String[] args) { SpringApplication.run(TimoApplication.class, args); } 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 bc4e6703..8553f2af 100644 --- a/src/main/java/com/Timo/Timo/global/common/BaseTimeEntity.java +++ b/src/main/java/com/Timo/Timo/global/common/BaseTimeEntity.java @@ -17,11 +17,11 @@ public abstract class BaseTimeEntity { @CreatedDate @Column(name = "created_at", nullable = false, updatable = false) - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @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 HH:mm:ss") + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") private LocalDateTime updatedAt; } 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 From 295c077e091d026f691b7aa3686fbdf6fc9be838 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 17:29:30 +0900 Subject: [PATCH 207/383] chore: trigger CodeRabbit review From 85c2a7a574c1f11fa59544596d86c700d18d354e Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:29:36 +0900 Subject: [PATCH 208/383] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EB=B3=84=20=EC=8B=9C=EA=B0=84=EB=8C=80=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?APT=20=EB=AC=B8=EC=84=9C/DTO=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/docs/UserTimezoneDocs.java | 80 +++++++++++++++++++ .../dto/request/UpdateTimezoneRequest.java | 11 +++ .../dto/response/UpdateTimezoneResponse.java | 9 +++ 3 files changed, 100 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/user/docs/UserTimezoneDocs.java create mode 100644 src/main/java/com/Timo/Timo/domain/user/dto/request/UpdateTimezoneRequest.java create mode 100644 src/main/java/com/Timo/Timo/domain/user/dto/response/UpdateTimezoneResponse.java 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/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/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 +) { +} From e0cff25a0f27db3ab2debf452975c71812071e68 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:30:07 +0900 Subject: [PATCH 209/383] =?UTF-8?q?feat(home):=20=ED=99=88=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/controller/HomeController.java | 41 ++++ .../domain/home/docs/HomeControllerDocs.java | 65 +++++++ .../home/dto/response/HomeResponse.java | 93 +++++++++ .../Timo/domain/home/enums/HomeFilter.java | 6 + .../domain/home/exception/HomeErrorCode.java | 20 ++ .../home/exception/HomeSuccessCode.java | 18 ++ .../domain/home/mapper/HomeTodoMapper.java | 61 ++++++ .../domain/home/service/HolidayChecker.java | 44 +++++ .../Timo/domain/home/service/HomeService.java | 180 ++++++++++++++++++ 9 files changed, 528 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/home/controller/HomeController.java create mode 100644 src/main/java/com/Timo/Timo/domain/home/docs/HomeControllerDocs.java create mode 100644 src/main/java/com/Timo/Timo/domain/home/dto/response/HomeResponse.java create mode 100644 src/main/java/com/Timo/Timo/domain/home/enums/HomeFilter.java create mode 100644 src/main/java/com/Timo/Timo/domain/home/exception/HomeErrorCode.java create mode 100644 src/main/java/com/Timo/Timo/domain/home/exception/HomeSuccessCode.java create mode 100644 src/main/java/com/Timo/Timo/domain/home/mapper/HomeTodoMapper.java create mode 100644 src/main/java/com/Timo/Timo/domain/home/service/HolidayChecker.java create mode 100644 src/main/java/com/Timo/Timo/domain/home/service/HomeService.java 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..11aa0569 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/home/controller/HomeController.java @@ -0,0 +1,41 @@ +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.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)); + } +} 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..352a26f8 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/home/docs/HomeControllerDocs.java @@ -0,0 +1,65 @@ +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.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 io.swagger.v3.oas.annotations.security.SecurityRequirement; + +public interface HomeControllerDocs { + + @Operation( + summary = "HOME 화면 TODO 목록 조회", + description = """ + HOME 화면의 필터 조건에 따라 일별 TODO 목록을 조회합니다. + DEFAULT는 기준 날짜 전후 7일, WEEK는 기준 날짜부터 7일을 반환합니다. + 각 날짜의 TODO는 미완료 먼저, 같은 완료 그룹 안에서는 sortOrder 오름차순으로 정렬됩니다. + """, + security = @SecurityRequirement(name = "bearerAuth") + ) + @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 + ); +} 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/enums/HomeFilter.java b/src/main/java/com/Timo/Timo/domain/home/enums/HomeFilter.java new file mode 100644 index 00000000..3104ec11 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/home/enums/HomeFilter.java @@ -0,0 +1,6 @@ +package com.Timo.Timo.domain.home.enums; + +public enum HomeFilter { + DEFAULT, + WEEK +} 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..817658ce --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/home/exception/HomeSuccessCode.java @@ -0,0 +1,18 @@ +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, "홈 뷰 조회 성공"); + + 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..6c4551a4 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/home/service/HomeService.java @@ -0,0 +1,180 @@ +package com.Timo.Timo.domain.home.service; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeParseException; +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.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.HomeResponse.TodoResponse; +import com.Timo.Timo.domain.home.enums.HomeFilter; +import com.Timo.Timo.domain.home.exception.HomeErrorCode; +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 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 TodoRepository todoRepository; + private final TodoInstanceRepository todoInstanceRepository; + private final TagRepository tagRepository; + private final TodoDateCalculator todoDateCalculator; + private final HolidayChecker holidayChecker; + private final HomeTodoMapper homeTodoMapper; + private final UserRepository userRepository; + + public HomeResponse getHome(Long userId, String filterValue, String baseDateValue) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + ZoneId userZone = ZoneId.of(user.getZoneId()); + Language language = user.getLanguage(); + HomeFilter filter = parseFilter(filterValue); + LocalDate baseDate = parseBaseDate(baseDateValue, userZone); + LocalDate from = resolveFrom(filter, baseDate); + LocalDate to = resolveTo(filter, baseDate); + List dates = from.datesUntil(to.plusDays(1)).toList(); + + List rules = todoRepository.findRulesInRange(userId, from, to); + List todoIds = rules.stream() + .map(Todo::getId) + .toList(); + + Map instancesByKey = todoIds.isEmpty() + ? Map.of() + : todoInstanceRepository.findByTodoIdsAndDateRange(todoIds, from, to).stream() + .collect(Collectors.toMap( + instance -> new InstanceKey(instance.getTodo().getId(), instance.getDate()), + Function.identity() + )); + + Map tagsById = loadTags(rules); + LocalDate today = LocalDate.now(userZone); + + List days = dates.stream() + .map(date -> buildDay(date, today, language, rules, instancesByKey, tagsById)) + .toList(); + + return new HomeResponse(filter, baseDate, days); + } + + private DayResponse buildDay( + LocalDate date, + LocalDate today, + Language language, + List rules, + Map instancesByKey, + Map tagsById + ) { + List occurredRules = 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 = instancesByKey.get(new InstanceKey(rule.getId(), date)); + todos.add(homeTodoMapper.toResponse( + new TodoContext(rule, instance, tagsById.get(rule.getTagId()), index) + )); + } + + todos = todos.stream() + .sorted(Comparator + .comparing(TodoResponse::completed) + .thenComparing(TodoResponse::sortOrder, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(TodoResponse::todoId)) + .toList(); + + return DayResponse.of( + date, + holidayChecker.isHoliday(date, language), + date.equals(today), + todos + ); + } + + 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())); + } + + private HomeFilter parseFilter(String filterValue) { + if (filterValue == null || filterValue.isBlank()) { + throw new CustomException(HomeErrorCode.INVALID_FILTER_OR_DATE); + } + + try { + return HomeFilter.valueOf(filterValue); + } catch (IllegalArgumentException exception) { + throw new CustomException(HomeErrorCode.INVALID_FILTER_OR_DATE); + } + } + + 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); + } + } + + private LocalDate resolveFrom(HomeFilter filter, LocalDate baseDate) { + return switch (filter) { + case DEFAULT -> baseDate.minusDays(7); + case WEEK -> baseDate; + }; + } + + private LocalDate resolveTo(HomeFilter filter, LocalDate baseDate) { + return switch (filter) { + case DEFAULT -> baseDate.plusDays(7); + case WEEK -> baseDate.plusDays(6); + }; + } + + private record InstanceKey( + Long todoId, + LocalDate date + ) { + } +} From e60b7ec54bd0abdfab5ba0cf6ec25cfbb4601b96 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:49:27 +0900 Subject: [PATCH 210/383] =?UTF-8?q?refactor(home):=20filter=20=EB=B9=88?= =?UTF-8?q?=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EB=93=A4=EC=96=B4=EC=98=AC=20?= =?UTF-8?q?=EC=8B=9C=20defualt=20=EB=B0=98=ED=99=98=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/domain/home/service/HomeService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 6c4551a4..c7c32644 100644 --- a/src/main/java/com/Timo/Timo/domain/home/service/HomeService.java +++ b/src/main/java/com/Timo/Timo/domain/home/service/HomeService.java @@ -136,7 +136,7 @@ private Map loadTags(List rules) { private HomeFilter parseFilter(String filterValue) { if (filterValue == null || filterValue.isBlank()) { - throw new CustomException(HomeErrorCode.INVALID_FILTER_OR_DATE); + return HomeFilter.DEFAULT; } try { From 938189700042868c92c3d85555d35976b5c652d7 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:55:12 +0900 Subject: [PATCH 211/383] =?UTF-8?q?refactor(tag):=20=ED=83=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=ED=95=84=EC=88=98=20=EA=B0=92=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/Timo/Timo/domain/tag/entity/Tag.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 528669a2..5d41ecee 100644 --- a/src/main/java/com/Timo/Timo/domain/tag/entity/Tag.java +++ b/src/main/java/com/Timo/Timo/domain/tag/entity/Tag.java @@ -21,6 +21,6 @@ public class Tag { @Column(name = "id") private Long id; - @Column(name = "name", length = 20) + @Column(name = "name", nullable = false, length = 20) private String name; } From 03af13c71716c1da97ed70695bfea7146bd34ad5 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:15:27 +0900 Subject: [PATCH 212/383] =?UTF-8?q?fix:=20UTC=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=EB=8C=80=20=EC=84=A4=EC=A0=95=EC=9D=84=20main()=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99=ED=95=B4=20=EC=95=B1=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=20=EC=A0=84=20=EB=B3=B4=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/Timo/Timo/TimoApplication.java | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/main/java/com/Timo/Timo/TimoApplication.java b/src/main/java/com/Timo/Timo/TimoApplication.java index 04d168d1..f339dd26 100644 --- a/src/main/java/com/Timo/Timo/TimoApplication.java +++ b/src/main/java/com/Timo/Timo/TimoApplication.java @@ -6,22 +6,12 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; -import jakarta.annotation.PostConstruct; - @SpringBootApplication @EnableJpaAuditing public class TimoApplication { - /** - * 서버가 어느 지역에 배포되든 감사 로그/타임스탬프가 항상 UTC 기준으로 기록되도록 - * JVM 기본 시간대를 UTC로 고정한다. 사용자별 시간대 변환은 각 조회 시점에 처리한다. - */ - @PostConstruct - void init() { - TimeZone.setDefault(TimeZone.getTimeZone("UTC")); - } - public static void main(String[] args) { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); SpringApplication.run(TimoApplication.class, args); } From 4485877a1b89dd02fbbfd196dfe52823be8f655e Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 21:45:12 +0900 Subject: [PATCH 213/383] =?UTF-8?q?refactor:yearMonth=20=ED=8C=8C=EC=8B=B1?= =?UTF-8?q?=20=EB=B0=8F=20=EA=B2=80=EC=A6=9D=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/StatisticsController.java | 27 ++------------- .../support/StatisticsDateParser.java | 33 +++++++++++++++++++ 2 files changed, 36 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/domain/statistics/support/StatisticsDateParser.java 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 index be23ded6..bcac36f6 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/controller/StatisticsController.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/controller/StatisticsController.java @@ -1,8 +1,6 @@ package com.Timo.Timo.domain.statistics.controller; import java.time.YearMonth; -import java.time.format.DateTimeFormatter; -import java.time.format.DateTimeParseException; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -13,11 +11,10 @@ import com.Timo.Timo.domain.statistics.docs.StatisticsCalendarDocs; import com.Timo.Timo.domain.statistics.dto.response.StatisticsCalendarResponse; -import com.Timo.Timo.domain.statistics.exception.StatisticsErrorCode; import com.Timo.Timo.domain.statistics.exception.StatisticsSuccessCode; import com.Timo.Timo.domain.statistics.service.StatisticsService; +import com.Timo.Timo.domain.statistics.support.StatisticsDateParser; import com.Timo.Timo.global.auth.principal.CustomUserDetails; -import com.Timo.Timo.global.exception.CustomException; import com.Timo.Timo.global.response.BaseResponse; import io.swagger.v3.oas.annotations.tags.Tag; @@ -29,10 +26,8 @@ @Tag(name = "Statistics", description = "통계 API") public class StatisticsController implements StatisticsCalendarDocs { - private static final DateTimeFormatter YEAR_MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM"); - private static final String YEAR_MONTH_PATTERN = "^\\d{4}-\\d{2}$"; - private final StatisticsService statisticsService; + private final StatisticsDateParser statisticsDateParser; @Override @GetMapping("/calendar") @@ -40,7 +35,7 @@ public ResponseEntity> getCalendar( @AuthenticationPrincipal CustomUserDetails userDetails, @RequestParam(required = false) String yearMonth ) { - YearMonth parsedYearMonth = parseYearMonth(yearMonth); + YearMonth parsedYearMonth = statisticsDateParser.parseYearMonth(yearMonth); StatisticsCalendarResponse response = statisticsService.getCalendar( userDetails.getUserId(), parsedYearMonth @@ -50,20 +45,4 @@ public ResponseEntity> getCalendar( BaseResponse.onSuccess(StatisticsSuccessCode.CALENDAR_RETRIEVED, response) ); } - - private 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); - } - } } 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..8e0a217a --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/statistics/support/StatisticsDateParser.java @@ -0,0 +1,33 @@ +package com.Timo.Timo.domain.statistics.support; + +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 String YEAR_MONTH_PATTERN = "^\\d{4}-\\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); + } + } +} From 72fee6d650ccc745d7532e0051e106af445608d8 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 22:04:31 +0900 Subject: [PATCH 214/383] =?UTF-8?q?feat:=20API=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=EB=A5=BC=20/api/v1/todos=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EC=9D=BC=EA=B4=80=EC=84=B1=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/ai/controller/AiTodoController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index f90ec4a1..661b4c23 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java +++ b/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java @@ -22,7 +22,7 @@ @Slf4j @RestController -@RequestMapping("/api/v1/ai/todos") +@RequestMapping("/api/v1/todos") @RequiredArgsConstructor @Tag(name = "AI Todo", description = "투두 AI API") public class AiTodoController implements AiTodoDocs { From 7a7b904cd4038260aafc1363e08bfebb9a458d99 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 22:12:24 +0900 Subject: [PATCH 215/383] =?UTF-8?q?fix:=20=EA=B3=B5=EB=B0=B1=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/dto/response/GeminiDurationRecommendation.java | 4 +--- .../domain/ai/dto/response/RecommendDurationResponse.java | 7 ++----- 2 files changed, 3 insertions(+), 8 deletions(-) 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 index cc80ea1b..e57732e0 100644 --- 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 @@ -1,6 +1,4 @@ package com.Timo.Timo.domain.ai.dto.response; -public record GeminiDurationRecommendation( - Integer recommendedMinutes -) { +public record GeminiDurationRecommendation(Integer recommendedMinutes) { } 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 index 69412d6f..f447e82c 100644 --- 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 @@ -2,8 +2,5 @@ import io.swagger.v3.oas.annotations.media.Schema; -public record RecommendDurationResponse( - @Schema(description = "추천 예상 소요 시간(분)", example = "45") - Integer recommendedMinutes -) { -} +public record RecommendDurationResponse(@Schema(description = "추천 예상 소요 시간(분)", example = "45") Integer recommendedMinutes) { +} \ No newline at end of file From f0f341b47355255cf8ee12d8295babe5298b7455 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 22:14:35 +0900 Subject: [PATCH 216/383] =?UTF-8?q?feat:=20=EA=B0=9C=EC=84=A0=EB=90=9C=20J?= =?UTF-8?q?SON=20=EC=9D=91=EB=8B=B5=20=ED=98=95=EC=8B=9D=20=EB=B0=8F=20?= =?UTF-8?q?=EB=AC=B8=EC=9E=90=EC=97=B4=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/prompt/TodoDurationPromptBuilder.java | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) 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 index b19b7f70..5d791f4b 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java +++ b/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java @@ -4,8 +4,8 @@ import org.springframework.stereotype.Component; +import com.Timo.Timo.domain.ai.dto.TodoDurationHistory; import com.Timo.Timo.domain.ai.dto.request.RecommendDurationRequest; -import com.Timo.Timo.domain.ai.repository.TodoDurationHistory; @Component public class TodoDurationPromptBuilder { @@ -30,25 +30,25 @@ public String build( - recommendedMinutes는 분 단위 정수로 반환해. - 실제 기록에 없는 패턴은 만들지 마. - 응답 JSON 형식: + 반환해야 할 응답 JSON 형식: { "recommendedMinutes": 45 } - 현재 투두: + 아래 데이터는 응답에 포함할 값이 아니라 추천 계산에만 참고할 입력 데이터야. + + 입력 데이터 - 현재 투두: { - "title": "%s", - "tagId": %s + "title": "%s" } - 비슷한 투두명 실제 소요시간 기록: + 입력 데이터 - 비슷한 투두명 실제 소요시간 기록: %s - 사용자가 지정한 태그의 최근 실제 소요시간 기록: + 입력 데이터 - 사용자가 지정한 태그의 최근 실제 소요시간 기록: %s """.formatted( - escape(request.title()), - request.tagId() == null ? "null" : request.tagId().toString(), + escapeJsonString(request.title()), formatHistories(similarTitleHistories), formatHistories(recentTagHistories) ); @@ -61,10 +61,9 @@ private String formatHistories(List histories) { return histories.stream() .map(history -> """ - {"title":"%s","tagId":%s,"date":"%s","actualMinutes":%d} + {"title":"%s","date":"%s","actualMinutes":%d} """.formatted( - escape(history.title()), - history.tagId() == null ? "null" : history.tagId().toString(), + escapeJsonString(history.title()), history.date(), toMinutes(history.actualSeconds()) ).trim()) @@ -79,7 +78,7 @@ private int toMinutes(Integer durationSeconds) { return Math.max(1, (int)Math.round(durationSeconds / 60.0)); } - private String escape(String value) { + private String escapeJsonString(String value) { if (value == null) { return ""; } From 0e647f6d7363fa5ecbee8f56751996d0842ddd0b Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 22:31:20 +0900 Subject: [PATCH 217/383] =?UTF-8?q?feat:=20=EA=B0=9C=EC=84=A0=EB=90=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20TodoDurat?= =?UTF-8?q?ionHistory=20DTO=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/repository/AiTodoQueryRepository.java | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) 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 index 44750355..9f9cbfae 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java +++ b/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java @@ -7,6 +7,8 @@ import org.springframework.stereotype.Repository; +import com.Timo.Timo.domain.ai.dto.TodoDurationHistory; + import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; @@ -26,7 +28,6 @@ public List findActualDurationHistoriesBySimilarTitle( Query query = entityManager.createNativeQuery(""" select t.title, - t.tag_id, tr.actual_seconds, date(coalesce(tr.ended_at, tr.started_at)) as recorded_date from todos t @@ -39,7 +40,15 @@ and date(coalesce(tr.ended_at, tr.started_at)) <= :today lower(t.title) like lower(concat('%', :title, '%')) or lower(:title) like lower(concat('%', t.title, '%')) ) - order by coalesce(tr.ended_at, tr.started_at) desc, tr.id desc + 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.ended_at, tr.started_at) desc, + tr.id desc """) .setParameter("userId", userId) .setParameter("title", title) @@ -58,7 +67,6 @@ public List findActualDurationHistoriesByTagId( Query query = entityManager.createNativeQuery(""" select t.title, - t.tag_id, tr.actual_seconds, date(coalesce(tr.ended_at, tr.started_at)) as recorded_date from todos t @@ -83,9 +91,8 @@ private List toHistories(List rows) { return ((List)rows).stream() .map(row -> new TodoDurationHistory( (String)row[0], - toLong(row[1]), - toInteger(row[2]), - toLocalDate(row[3]) + toInteger(row[1]), + toLocalDate(row[2]) )) .toList(); } From 2e58c38f833d412b95fcc9e16dfae82f3c02a793 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 22:33:49 +0900 Subject: [PATCH 218/383] =?UTF-8?q?refacotr=20:=20dto=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/ai/dto/TodoDurationHistory.java | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/ai/dto/TodoDurationHistory.java 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..09c45959 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/dto/TodoDurationHistory.java @@ -0,0 +1,10 @@ +package com.Timo.Timo.domain.ai.dto; + +import java.time.LocalDate; + +public record TodoDurationHistory( + String title, + Integer actualSeconds, + LocalDate date +) { +} From 289d4c249f9707c2ebcb327fc9a3d7f7cbfcc3d7 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 22:34:13 +0900 Subject: [PATCH 219/383] refactor: move TodoDurationHistory to dto package --- .../domain/ai/repository/TodoDurationHistory.java | 11 ----------- .../Timo/Timo/domain/ai/service/AiTodoHistories.java | 2 +- 2 files changed, 1 insertion(+), 12 deletions(-) delete mode 100644 src/main/java/com/Timo/Timo/domain/ai/repository/TodoDurationHistory.java diff --git a/src/main/java/com/Timo/Timo/domain/ai/repository/TodoDurationHistory.java b/src/main/java/com/Timo/Timo/domain/ai/repository/TodoDurationHistory.java deleted file mode 100644 index 11eca5a2..00000000 --- a/src/main/java/com/Timo/Timo/domain/ai/repository/TodoDurationHistory.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.Timo.Timo.domain.ai.repository; - -import java.time.LocalDate; - -public record TodoDurationHistory( - String title, - Long tagId, - Integer actualSeconds, - LocalDate date -) { -} 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 index be71d046..8b3cb07a 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistories.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistories.java @@ -2,7 +2,7 @@ import java.util.List; -import com.Timo.Timo.domain.ai.repository.TodoDurationHistory; +import com.Timo.Timo.domain.ai.dto.TodoDurationHistory; public record AiTodoHistories( List similarTitleHistories, From e68eea61b8ae720db7d5a2a2d1bd90367a533fb8 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 22:34:26 +0900 Subject: [PATCH 220/383] refactor: move TodoDurationHistory to dto package --- .../com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 2e58e874..f608a430 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java @@ -6,8 +6,8 @@ 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 com.Timo.Timo.domain.ai.repository.TodoDurationHistory; import lombok.RequiredArgsConstructor; From a0d10aae46dcde10e338b838d6d3f87ff6eed68c Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 22:37:14 +0900 Subject: [PATCH 221/383] =?UTF-8?q?refactor:=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20@SecurityRequir?= =?UTF-8?q?ement(name=20=3D=20"bearerAuth")=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/home/docs/HomeControllerDocs.java | 4 +--- .../com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java | 4 +--- .../com/Timo/Timo/global/auth/docs/AuthControllerDocs.java | 7 ++----- 3 files changed, 4 insertions(+), 11 deletions(-) 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 index 352a26f8..282f7c03 100644 --- a/src/main/java/com/Timo/Timo/domain/home/docs/HomeControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/home/docs/HomeControllerDocs.java @@ -13,7 +13,6 @@ 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.SecurityRequirement; public interface HomeControllerDocs { @@ -23,8 +22,7 @@ public interface HomeControllerDocs { HOME 화면의 필터 조건에 따라 일별 TODO 목록을 조회합니다. DEFAULT는 기준 날짜 전후 7일, WEEK는 기준 날짜부터 7일을 반환합니다. 각 날짜의 TODO는 미완료 먼저, 같은 완료 그룹 안에서는 sortOrder 오름차순으로 정렬됩니다. - """, - security = @SecurityRequirement(name = "bearerAuth") + """ ) @ApiResponses({ @ApiResponse( 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 index 4099b2b8..26284a23 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java @@ -16,7 +16,6 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; public interface TodoControllerDocs { @@ -30,8 +29,7 @@ public interface TodoControllerDocs { 반복 일정은 시작일 기준 최대 1년까지 생성됩니다. Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. - """, - security = @SecurityRequirement(name = "bearerAuth") + """ ) @RequestBody( required = true, 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 index 7cd7ccd7..dcb4fad6 100644 --- a/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java +++ b/src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java @@ -13,7 +13,6 @@ 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.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirements; import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.ResponseEntity; @@ -141,8 +140,7 @@ ResponseEntity> reissue( description = """ 현재 세션을 로그아웃하고 RefreshToken 및 sessionId 쿠키를 만료시킵니다. Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. - """, - security = @SecurityRequirement(name = "bearerAuth") + """ ) @ApiResponses({ @ApiResponse( @@ -179,8 +177,7 @@ ResponseEntity> logout( 회원 탈퇴를 진행하며, 사용자와 관련된 모든 데이터를 영구 삭제합니다. Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. 이 작업은 되돌릴 수 없습니다. - """, - security = @SecurityRequirement(name = "bearerAuth") + """ ) @ApiResponses({ @ApiResponse( From 2e77108a91e59a11e2c016b01b8afe6964afaee4 Mon Sep 17 00:00:00 2001 From: jy000n Date: Wed, 8 Jul 2026 22:49:11 +0900 Subject: [PATCH 222/383] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20null=20=EC=B2=B4=ED=81=AC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/timer/docs/TimerControllerDocs.java | 8 -------- .../com/Timo/Timo/domain/timer/service/TimerService.java | 4 ---- 2 files changed, 12 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java index 91866880..e5f22bd8 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java @@ -28,14 +28,6 @@ public interface TimerControllerDocs { description = "타이머 시작 성공", useReturnTypeSchema = true ), - @ApiResponse( - responseCode = "400", - description = "예상 소요 시간이 설정되지 않은 투두인 경우", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorDto.class) - ) - ), @ApiResponse( responseCode = "401", description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", 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 index 34e23147..87b83d9a 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -44,10 +44,6 @@ public TimerStartResponse startTimer(Long userId, Long todoId) { throw new CustomException(ErrorCode.FORBIDDEN); } - if (todo.getDurationSeconds() == null) { - throw new CustomException(ErrorCode.BAD_REQUEST); - } - timerRecordRepository.findByUserIdAndStatusIn(userId, ACTIVE_STATUS) .ifPresent(existing -> { throw new CustomException(TimerErrorCode.TIMER_ALREADY_RUNNING); From a27fb0bbfaf0d05c227a53ed42bb1c33bcfdbb50 Mon Sep 17 00:00:00 2001 From: jy000n Date: Wed, 8 Jul 2026 22:51:50 +0900 Subject: [PATCH 223/383] =?UTF-8?q?refactor:=20getUserId()=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/timer/controller/TimerController.java | 2 +- .../com/Timo/Timo/global/auth/controller/AuthController.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 index 0817ab82..3192830a 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java +++ b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java @@ -31,7 +31,7 @@ public ResponseEntity> startTimer( @AuthenticationPrincipal CustomUserDetails userDetails ) { - Long userId = userDetails.getUser().getId(); + Long userId = userDetails.getUserId(); TimerStartResponse response = timerService.startTimer(userId, todoId); return timerResponseFactory.startResponse(response); 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 dfeb28ad..6e1ee14f 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 @@ -60,7 +60,7 @@ public ResponseEntity> logout( HttpServletRequest request ) { String accessToken = TokenExtractor.resolveToken(request); - authService.logout(accessToken, userDetails.getUser().getId(), sessionId); + authService.logout(accessToken, userDetails.getUserId(), sessionId); return authResponseFactory.logoutResponse(); } @@ -73,7 +73,7 @@ public ResponseEntity> withdraw( HttpServletRequest request ) { String accessToken = TokenExtractor.resolveToken(request); - authService.withdraw(accessToken, userDetails.getUser().getId(), sessionId); + authService.withdraw(accessToken, userDetails.getUserId(), sessionId); return authResponseFactory.withdrawResponse(); } From 8aa7c6e05dd7f95dff14c0ec6d7f5a8791519d33 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 23:26:50 +0900 Subject: [PATCH 224/383] =?UTF-8?q?refactor:=20=EA=B3=B5=EB=B0=B1=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/ai/dto/TodoDurationHistory.java | 3 +-- .../domain/ai/dto/response/RecommendDurationResponse.java | 6 ++++-- 2 files changed, 5 insertions(+), 4 deletions(-) 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 index 09c45959..b8c697f6 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/dto/TodoDurationHistory.java +++ b/src/main/java/com/Timo/Timo/domain/ai/dto/TodoDurationHistory.java @@ -6,5 +6,4 @@ public record TodoDurationHistory( String title, Integer actualSeconds, LocalDate date -) { -} +) {} 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 index f447e82c..fdd45707 100644 --- 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 @@ -2,5 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; -public record RecommendDurationResponse(@Schema(description = "추천 예상 소요 시간(분)", example = "45") Integer recommendedMinutes) { -} \ No newline at end of file +public record RecommendDurationResponse( + @Schema(description = "추천 예상 소요 시간(분)", example = "45") + Integer recommendedMinutes +) {} \ No newline at end of file From 28be588526691c77ab6c51ea9a01f2edb0c110ee Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 23:27:24 +0900 Subject: [PATCH 225/383] =?UTF-8?q?feat:=20application-prod.yml=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-prod.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 490c5b71..15315a0c 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -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 From ea410d5be281ff8c2f0cc6ef14d3e1ea1b877134 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 23:28:15 +0900 Subject: [PATCH 226/383] =?UTF-8?q?fix:=20=EB=A7=88=ED=81=AC=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=20=EC=BD=94=EB=93=9C=20=EB=B8=94=EB=A1=9D=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/domain/ai/service/AiTodoService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 5113b429..dd4a4152 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java @@ -91,10 +91,10 @@ private int estimateTokenCost(String prompt) { private String stripMarkdownFence(String value) { String trimmed = value.trim(); - if (trimmed.startsWith("```json")) { + if (trimmed.startsWith("```json") && trimmed.endsWith("```")) { return trimmed.substring(7, trimmed.length() - 3).trim(); } - if (trimmed.startsWith("```")) { + if (trimmed.startsWith("```") && trimmed.endsWith("```")) { return trimmed.substring(3, trimmed.length() - 3).trim(); } return trimmed; From aad168bc75028c05ea200b68acab5b37d3437836 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:29:19 +0900 Subject: [PATCH 227/383] =?UTF-8?q?refactor(home):=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=A5=BC=20=EC=A4=84=EC=9D=B4=EA=B8=B0=20?= =?UTF-8?q?=EC=9C=84=ED=95=B4=20enum=20=EC=BD=94=EB=93=9C=EC=97=90=20?= =?UTF-8?q?=EB=82=A0=EC=A7=9C=20=EA=B3=84=EC=82=B0=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/home/enums/HomeFilter.java | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) 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 index 3104ec11..a322149c 100644 --- a/src/main/java/com/Timo/Timo/domain/home/enums/HomeFilter.java +++ b/src/main/java/com/Timo/Timo/domain/home/enums/HomeFilter.java @@ -1,6 +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, - WEEK + 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); + } + } } From 01ba43ba56a3ce4c44e4e770ba4d763114a6272d Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:29:47 +0900 Subject: [PATCH 228/383] =?UTF-8?q?feat:=20=ED=99=88=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=98=A4=EB=8A=98=20=EC=A1=B0=ED=9A=8C=20service=20=EC=B1=85?= =?UTF-8?q?=EC=9E=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/dto/response/TodayResponse.java | 73 +++++++++ .../Timo/domain/home/service/HomeService.java | 148 +++--------------- .../domain/home/service/HomeTodoReader.java | 107 +++++++++++++ 3 files changed, 206 insertions(+), 122 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/domain/home/dto/response/TodayResponse.java create mode 100644 src/main/java/com/Timo/Timo/domain/home/service/HomeTodoReader.java 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/service/HomeService.java b/src/main/java/com/Timo/Timo/domain/home/service/HomeService.java index c7c32644..123681c1 100644 --- a/src/main/java/com/Timo/Timo/domain/home/service/HomeService.java +++ b/src/main/java/com/Timo/Timo/domain/home/service/HomeService.java @@ -3,31 +3,17 @@ import java.time.LocalDate; import java.time.ZoneId; import java.time.format.DateTimeParseException; -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.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.HomeResponse.TodoResponse; +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.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 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; @@ -41,109 +27,47 @@ @Transactional(readOnly = true) public class HomeService { - private final TodoRepository todoRepository; - private final TodoInstanceRepository todoInstanceRepository; - private final TagRepository tagRepository; - private final TodoDateCalculator todoDateCalculator; - private final HolidayChecker holidayChecker; - private final HomeTodoMapper homeTodoMapper; private final UserRepository userRepository; + private final HomeTodoReader homeTodoReader; + private final HolidayChecker holidayChecker; public HomeResponse getHome(Long userId, String filterValue, String baseDateValue) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + User user = getUser(userId); ZoneId userZone = ZoneId.of(user.getZoneId()); Language language = user.getLanguage(); - HomeFilter filter = parseFilter(filterValue); - LocalDate baseDate = parseBaseDate(baseDateValue, userZone); - LocalDate from = resolveFrom(filter, baseDate); - LocalDate to = resolveTo(filter, baseDate); - List dates = from.datesUntil(to.plusDays(1)).toList(); - List rules = todoRepository.findRulesInRange(userId, from, to); - List todoIds = rules.stream() - .map(Todo::getId) - .toList(); - - Map instancesByKey = todoIds.isEmpty() - ? Map.of() - : todoInstanceRepository.findByTodoIdsAndDateRange(todoIds, from, to).stream() - .collect(Collectors.toMap( - instance -> new InstanceKey(instance.getTodo().getId(), instance.getDate()), - Function.identity() - )); - - Map tagsById = loadTags(rules); + 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); - List days = dates.stream() - .map(date -> buildDay(date, today, language, rules, instancesByKey, tagsById)) - .toList(); - - return new HomeResponse(filter, baseDate, days); - } - - private DayResponse buildDay( - LocalDate date, - LocalDate today, - Language language, - List rules, - Map instancesByKey, - Map tagsById - ) { - List occurredRules = 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 = instancesByKey.get(new InstanceKey(rule.getId(), date)); - todos.add(homeTodoMapper.toResponse( - new TodoContext(rule, instance, tagsById.get(rule.getTagId()), index) - )); - } + LoadedTodos loaded = homeTodoReader.load(userId, from, to); - todos = todos.stream() - .sorted(Comparator - .comparing(TodoResponse::completed) - .thenComparing(TodoResponse::sortOrder, Comparator.nullsLast(Integer::compareTo)) - .thenComparing(TodoResponse::todoId)) + 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 DayResponse.of( - date, - holidayChecker.isHoliday(date, language), - date.equals(today), - todos - ); + return new HomeResponse(filter, baseDate, days); } - private Map loadTags(List rules) { - List tagIds = rules.stream() - .map(Todo::getTagId) - .filter(Objects::nonNull) - .distinct() - .toList(); + public TodayResponse getToday(Long userId) { + User user = getUser(userId); + LocalDate today = LocalDate.now(ZoneId.of(user.getZoneId())); - if (tagIds.isEmpty()) { - return Map.of(); - } + LoadedTodos loaded = homeTodoReader.load(userId, today, today); - return tagRepository.findAllById(tagIds).stream() - .collect(Collectors.toMap(Tag::getId, Function.identity())); + return TodayResponse.of(today, homeTodoReader.sortedTodosOn(loaded, today)); } - private HomeFilter parseFilter(String filterValue) { - if (filterValue == null || filterValue.isBlank()) { - return HomeFilter.DEFAULT; - } - - try { - return HomeFilter.valueOf(filterValue); - } catch (IllegalArgumentException exception) { - throw new CustomException(HomeErrorCode.INVALID_FILTER_OR_DATE); - } + private User getUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); } private LocalDate parseBaseDate(String baseDateValue, ZoneId userZone) { @@ -157,24 +81,4 @@ private LocalDate parseBaseDate(String baseDateValue, ZoneId userZone) { throw new CustomException(HomeErrorCode.INVALID_DATE); } } - - private LocalDate resolveFrom(HomeFilter filter, LocalDate baseDate) { - return switch (filter) { - case DEFAULT -> baseDate.minusDays(7); - case WEEK -> baseDate; - }; - } - - private LocalDate resolveTo(HomeFilter filter, LocalDate baseDate) { - return switch (filter) { - case DEFAULT -> baseDate.plusDays(7); - case WEEK -> baseDate.plusDays(6); - }; - } - - private record InstanceKey( - Long todoId, - LocalDate 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 + ) { + } +} From 7bf9396d47e0b25d300f57bbfb936e6df54fa993 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:29:57 +0900 Subject: [PATCH 229/383] =?UTF-8?q?feat:=20=ED=99=88=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=98=A4=EB=8A=98=20=EC=A1=B0=ED=9A=8C=20=EC=84=B1=EA=B3=B5=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/home/exception/HomeSuccessCode.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 817658ce..dd2ef245 100644 --- a/src/main/java/com/Timo/Timo/domain/home/exception/HomeSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/home/exception/HomeSuccessCode.java @@ -11,7 +11,8 @@ @RequiredArgsConstructor public enum HomeSuccessCode implements BaseSuccessCode { - GET_HOME(HttpStatus.OK, "홈 뷰 조회 성공"); + GET_HOME(HttpStatus.OK, "홈 뷰 조회 성공"), + GET_TODAY(HttpStatus.OK, "오늘 뷰 조회 성공"); private final HttpStatus httpStatus; private final String message; From c180215c2c46a75a0cab562f3554c9adc526c68d Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:30:07 +0900 Subject: [PATCH 230/383] =?UTF-8?q?feat:=20=ED=99=88=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=98=A4=EB=8A=98=20=EC=A1=B0=ED=9A=8C=20controller=20?= =?UTF-8?q?=EB=B0=8F=20swagger=20=EB=AC=B8=EC=84=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/controller/HomeController.java | 13 +++++++ .../domain/home/docs/HomeControllerDocs.java | 35 +++++++++++++++++++ 2 files changed, 48 insertions(+) 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 index 11aa0569..f097e65e 100644 --- a/src/main/java/com/Timo/Timo/domain/home/controller/HomeController.java +++ b/src/main/java/com/Timo/Timo/domain/home/controller/HomeController.java @@ -9,6 +9,7 @@ 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; @@ -38,4 +39,16 @@ public ResponseEntity> getHome( .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 index 282f7c03..2eaddb88 100644 --- a/src/main/java/com/Timo/Timo/domain/home/docs/HomeControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/home/docs/HomeControllerDocs.java @@ -3,6 +3,7 @@ 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; @@ -60,4 +61,38 @@ ResponseEntity> getHome( @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 + ); } From cf2b8705c47629110308f001b17edd886f80c64d Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 23:40:35 +0900 Subject: [PATCH 231/383] =?UTF-8?q?fix:=20=EC=84=B8=EC=85=98=20stateless?= =?UTF-8?q?=EB=A1=9C=20=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/Timo/Timo/global/config/SecurityConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f204c7bf..400b8a07 100644 --- a/src/main/java/com/Timo/Timo/global/config/SecurityConfig.java +++ b/src/main/java/com/Timo/Timo/global/config/SecurityConfig.java @@ -38,7 +38,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .httpBasic(AbstractHttpConfigurer::disable) .cors(cors -> cors.configurationSource(corsConfigurationSource())) .sessionManagement(session -> session - .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(authorize -> authorize .requestMatchers( "/", From 8ca8811948ca5fcc053819eb0e2fc9c579e6eaf1 Mon Sep 17 00:00:00 2001 From: jy000n Date: Wed, 8 Jul 2026 23:43:37 +0900 Subject: [PATCH 232/383] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EC=8B=9C=20DB-Redis=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=20=EB=B6=88=EC=9D=BC=EC=B9=98=20=EA=B0=80=EB=8A=A5=EC=84=B1=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/global/auth/service/AuthService.java | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) 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 5b24c793..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 @@ -12,6 +12,8 @@ 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 @@ -101,13 +103,18 @@ public void withdraw(String accessToken, Long userId, String sessionId) { User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); - if (accessToken != null) { - long remainingExpiry = jwtTokenProvider.getRemainingExpiry(accessToken); - blacklistService.addToBlacklist(accessToken, remainingExpiry); - } + userRepository.delete(user); - refreshTokenService.deleteAllRefreshTokens(String.valueOf(userId)); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit(){ + if (accessToken != null) { + long remainingExpiry = jwtTokenProvider.getRemainingExpiry(accessToken); + blacklistService.addToBlacklist(accessToken, remainingExpiry); + } + } + }); - userRepository.delete(user); + refreshTokenService.deleteAllRefreshTokens(String.valueOf(userId)); } } From edb98c15129ef447dd3cd3ae760effac434c2e85 Mon Sep 17 00:00:00 2001 From: jy000n Date: Wed, 8 Jul 2026 23:44:00 +0900 Subject: [PATCH 233/383] =?UTF-8?q?fix:=20RefreshTokenService=20=EB=A6=AC?= =?UTF-8?q?=ED=94=84=EB=A0=88=EC=8B=9C=ED=86=A0=ED=81=B0=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EC=8B=9C=20KEYS=20=EB=8C=80=EC=8B=A0=20SCAN=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/service/RefreshTokenService.java | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) 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 0c994cb2..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,12 +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 @@ -38,9 +42,21 @@ public void deleteRefreshToken(String userId, String sessionId) { } public void deleteAllRefreshTokens(String userId) { - Set keys = redisTemplate.keys(KEY_PREFIX + userId + ":*"); - if (keys != null && !keys.isEmpty()) { - redisTemplate.delete(keys); + 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); + } } } From 8ca5e575789b543bda8d34e180aa27f4ca53d064 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:47:50 +0900 Subject: [PATCH 234/383] =?UTF-8?q?chore:=20swagger=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=20=EC=84=A4=EB=AA=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/domain/home/docs/HomeControllerDocs.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 2eaddb88..fa445253 100644 --- a/src/main/java/com/Timo/Timo/domain/home/docs/HomeControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/home/docs/HomeControllerDocs.java @@ -63,7 +63,7 @@ ResponseEntity> getHome( ); @Operation( - summary = "오늘 뷰 TODO 목록 상세 조회", + summary = "오늘 뷰 TODO 목록 조회", description = """ 오늘 하루의 TODO 목록을 하위 태스크 상세 정보까지 포함해 단일 객체로 조회합니다. TODO는 미완료 먼저, 같은 완료 그룹 안에서는 sortOrder 오름차순으로 정렬됩니다. From 1d1799a7521ea648fb8405e82c2daaeca85c6b73 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 00:30:05 +0900 Subject: [PATCH 235/383] =?UTF-8?q?feat:=20TimerAction=20enum=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/domain/timer/enums/TimerAction.java | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/timer/enums/TimerAction.java 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 +} From 46c77f79b3a73ac16c17f682cff83e18fb7a08da Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 00:35:12 +0900 Subject: [PATCH 236/383] =?UTF-8?q?feat:=20TimerSessionRepository=EC=97=90?= =?UTF-8?q?=20findByTimerRecordId=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/timer/repository/TimerSessionRepository.java | 2 ++ 1 file changed, 2 insertions(+) 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 index fd811590..eb9a11e5 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java @@ -1,10 +1,12 @@ package com.Timo.Timo.domain.timer.repository; import com.Timo.Timo.domain.timer.entity.TimerSession; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface TimerSessionRepository extends JpaRepository { Optional findByTimerRecordIdAndPausedAtIsNull(Long timerRecordId); + List findByTimerRecordId(Long timerRecordId); } From b4871a5f896ec4efe38d86cdab420a50919ed702 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 00:52:36 +0900 Subject: [PATCH 237/383] =?UTF-8?q?feat:=20=EC=95=BD=EA=B4=80=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=B6=94=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/Timo/Timo/global/config/SecurityConfig.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From 6bddedd0850879dd3548b857e30e3a1253f5a579 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 00:53:13 +0900 Subject: [PATCH 238/383] =?UTF-8?q?feat:=20=EC=95=BD=EA=B4=80=20controller?= =?UTF-8?q?=20&=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20=EB=82=B4=EC=9A=A9=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../terms/controller/TermsController.java | 37 ++++++++++++++ .../terms/docs/TermsControllerDocs.java | 50 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/terms/controller/TermsController.java create mode 100644 src/main/java/com/Timo/Timo/domain/terms/docs/TermsControllerDocs.java 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..017044cf --- /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 From 9200f8301cba61ee2ef53f4f062125ffb4fbe562 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 00:53:51 +0900 Subject: [PATCH 239/383] =?UTF-8?q?feat:=20TermsSuccessCode=20and=20TermsT?= =?UTF-8?q?ype=20enums=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/terms/enums/TermsType.java | 17 +++++++++++++++++ .../terms/exception/TermsSuccessCode.java | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/terms/enums/TermsType.java create mode 100644 src/main/java/com/Timo/Timo/domain/terms/exception/TermsSuccessCode.java 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 From c7a3f0243f238031a79b7334e0c210343115e035 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 00:54:41 +0900 Subject: [PATCH 240/383] =?UTF-8?q?feat:Terms=20entity=20and=20TermsListRe?= =?UTF-8?q?sponse=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../terms/dto/response/TermsListResponse.java | 24 ++++++++++++ .../Timo/Timo/domain/terms/entity/Terms.java | 38 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/terms/dto/response/TermsListResponse.java create mode 100644 src/main/java/com/Timo/Timo/domain/terms/entity/Terms.java 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..4697d258 --- /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", example = "1") + Long termsId, + + @Schema(description = "약관 타입", example = "SERVICE") + String type, + + @Schema(description = "약관 제목", example = "서비스 이용약관") + String title, + + @Schema(description = "약관 전문", example = "TiMO는 사용자의 일정 관리와 할 일 수행을 돕기 위한 서비스입니다...") + 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 From 00c53831c8f82d008b9b4c595fb18236c023b528 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 00:54:56 +0900 Subject: [PATCH 241/383] =?UTF-8?q?feat:TermsRepository=20and=20TermsServi?= =?UTF-8?q?ce=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../terms/repository/TermsRepository.java | 13 +++++ .../domain/terms/service/TermsService.java | 51 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/terms/repository/TermsRepository.java create mode 100644 src/main/java/com/Timo/Timo/domain/terms/service/TermsService.java 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 From d547ca5184a926fd0fd0aa7a6997ffe4c6d6514f Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 00:56:17 +0900 Subject: [PATCH 242/383] =?UTF-8?q?fix:=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/terms/docs/TermsControllerDocs.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 017044cf..8a472c9b 100644 --- a/src/main/java/com/Timo/Timo/domain/terms/docs/TermsControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/terms/docs/TermsControllerDocs.java @@ -1,4 +1,4 @@ -기package com.Timo.Timo.domain.terms.docs; +package com.Timo.Timo.domain.terms.docs; import org.springframework.http.ResponseEntity; @@ -47,4 +47,4 @@ public interface TermsControllerDocs { ResponseEntity> getTerms( @Parameter(description = "약관 타입(SERVICE, PRIVACY)", example = "SERVICE") String type ); -} \ No newline at end of file +} From 35bb9ea136ab913cf460dcd45a32cfdd4a384543 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 00:58:01 +0900 Subject: [PATCH 243/383] =?UTF-8?q?feat:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EB=B3=80=EA=B2=BD(=EC=9D=BC=EC=8B=9C?= =?UTF-8?q?=EC=A0=95=EC=A7=80/=EC=9E=AC=EA=B0=9C)=20Request/Response=20DTO?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/dto/request/TimerActionRequest.java | 11 ++++++++ .../dto/response/TimerStatusResponse.java | 26 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/timer/dto/request/TimerActionRequest.java create mode 100644 src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerStatusResponse.java 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/response/TimerStatusResponse.java b/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerStatusResponse.java new file mode 100644 index 00000000..bf5f440b --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerStatusResponse.java @@ -0,0 +1,26 @@ +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 + ); + } +} From 1e7b5816b56c08f01c00aa10454680107866c0d9 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 00:59:58 +0900 Subject: [PATCH 244/383] =?UTF-8?q?chore:=EA=B3=B5=EB=B0=B1=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20=EB=B6=88=ED=95=84=EC=9A=94=20=EC=98=88?= =?UTF-8?q?=EC=8B=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/terms/docs/TermsControllerDocs.java | 2 +- .../Timo/domain/terms/dto/response/TermsListResponse.java | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) 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 index 8a472c9b..2539e8cd 100644 --- a/src/main/java/com/Timo/Timo/domain/terms/docs/TermsControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/terms/docs/TermsControllerDocs.java @@ -47,4 +47,4 @@ public interface TermsControllerDocs { 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 index 4697d258..1259e59d 100644 --- 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 @@ -9,16 +9,16 @@ public record TermsListResponse( List terms ) { public record TermsResponse( - @Schema(description = "약관 ID", example = "1") + @Schema(description = "약관 ID") Long termsId, - @Schema(description = "약관 타입", example = "SERVICE") + @Schema(description = "약관 타입") String type, - @Schema(description = "약관 제목", example = "서비스 이용약관") + @Schema(description = "약관 제목") String title, - @Schema(description = "약관 전문", example = "TiMO는 사용자의 일정 관리와 할 일 수행을 돕기 위한 서비스입니다...") + @Schema(description = "약관 전문") String content ) {} } \ No newline at end of file From 33e0d09e31a0d68fa07145cf98014397d554cf2e Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 01:20:37 +0900 Subject: [PATCH 245/383] =?UTF-8?q?feat:=20TimerService=EC=97=90=20?= =?UTF-8?q?=ED=83=80=EC=9D=B4=EB=A8=B8=20=EC=83=81=ED=83=9C=EB=B3=80?= =?UTF-8?q?=EA=B2=BD(=EC=9D=BC=EC=8B=9C=EC=A0=95=EC=A7=80/=EC=9E=AC?= =?UTF-8?q?=EA=B0=9C)=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/timer/service/TimerService.java | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) 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 index 87b83d9a..db4e2493 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -1,8 +1,10 @@ package com.Timo.Timo.domain.timer.service; 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; @@ -15,6 +17,7 @@ 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.LocalDateTime; import java.util.List; import lombok.RequiredArgsConstructor; @@ -67,4 +70,45 @@ public TimerStartResponse startTimer(Long userId, Long todoId) { return TimerStartResponse.from(timerRecord); } + + @Transactional + public TimerStatusResponse changeStatus(Long userId, Long timerId, TimerAction action){ + TimerRecord timerRecord = timerRecordRepository.findById(timerId) + .orElseThrow(() -> new CustomException(TimerErrorCode.TIMER_NOT_FOUND)); + + if (!timerRecord.getUser().getId().equals(userId)){ + throw new CustomException(ErrorCode.FORBIDDEN); + } + + LocalDateTime now = LocalDateTime.now(); + + if (action == TimerAction.PAUSE){ + timerRecord.pause(); + TimerSession activeSession = timerSessionRepository.findByTimerRecordIdAndPausedAtIsNull(timerId) + .orElseThrow(() -> new CustomException(TimerErrorCode.TIMER_INVALID_STATUS_TRANSITION)); + activeSession.pause(now); + } else { + timerRecord.resume(); + TimerSession newSession = TimerSession.builder() + .timerRecord(timerRecord) + .startedAt(now) + .build(); + timerSessionRepository.save(newSession); + } + + int elapsedSeconds = calculateElapsedSeconds(timerId, now); + + return TimerStatusResponse.of(timerRecord, elapsedSeconds); + } + + 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; + } } \ No newline at end of file From f1045c1ff664929a8a71b31ea9d453d689a539ed Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 01:36:51 +0900 Subject: [PATCH 246/383] =?UTF-8?q?feat:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=9D=BC=EC=8B=9C=EC=A0=95=EC=A7=80/=EC=9E=AC=EA=B0=9C=20API?= =?UTF-8?q?=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...troller.java => TimerStartController.java} | 29 +++++++++++++++++-- .../timer/factory/TimerResponseFactory.java | 9 ++++++ 2 files changed, 36 insertions(+), 2 deletions(-) rename src/main/java/com/Timo/Timo/domain/timer/controller/{TimerController.java => TimerStartController.java} (52%) diff --git a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerStartController.java similarity index 52% rename from src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java rename to src/main/java/com/Timo/Timo/domain/timer/controller/TimerStartController.java index 3192830a..0ffa74c4 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java +++ b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerStartController.java @@ -1,17 +1,25 @@ package com.Timo.Timo.domain.timer.controller; -import com.Timo.Timo.domain.timer.docs.TimerControllerDocs; +import com.Timo.Timo.domain.timer.docs.TimerStartControllerDocs; +import com.Timo.Timo.domain.timer.docs.TimerStatusControllerDocs; +import com.Timo.Timo.domain.timer.dto.request.TimerActionRequest; import com.Timo.Timo.domain.timer.dto.response.TimerStartResponse; +import com.Timo.Timo.domain.timer.dto.response.TimerStatusResponse; +import com.Timo.Timo.domain.timer.enums.TimerAction; +import com.Timo.Timo.domain.timer.exception.TimerSuccessCode; import com.Timo.Timo.domain.timer.factory.TimerResponseFactory; 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.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -19,7 +27,7 @@ @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor -public class TimerController implements TimerControllerDocs { +public class TimerStartController implements TimerStartControllerDocs, TimerStatusControllerDocs { private final TimerService timerService; private final TimerResponseFactory timerResponseFactory; @@ -36,4 +44,21 @@ public ResponseEntity> startTimer( return timerResponseFactory.startResponse(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 = request.action() == TimerAction.PAUSE + ? TimerSuccessCode.TIMER_PAUSED + : TimerSuccessCode.TIMER_RESUMED; + + return timerResponseFactory.statusResponse(response, successCode); + } } diff --git a/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java b/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java index 4eb36503..d2afd7a4 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java +++ b/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java @@ -1,6 +1,7 @@ package com.Timo.Timo.domain.timer.factory; 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.global.response.BaseResponse; import org.springframework.http.HttpStatus; @@ -14,4 +15,12 @@ public ResponseEntity> startResponse(TimerStart return ResponseEntity.status(HttpStatus.CREATED) .body(BaseResponse.onSuccess(TimerSuccessCode.TIMER_STARTED, response)); } + + public ResponseEntity> statusResponse( + TimerStatusResponse response, + TimerSuccessCode successCode + ){ + return ResponseEntity.ok() + .body(BaseResponse.onSuccess(successCode, response)); + } } From 86a158117ad3cec74bedbaab4d2ffe37281defab Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 01:37:03 +0900 Subject: [PATCH 247/383] =?UTF-8?q?docs:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=9D=BC=EC=8B=9C=EC=A0=95=EC=A7=80/=EC=9E=AC=EA=B0=9C=20API?= =?UTF-8?q?=20Swagger=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ocs.java => TimerStartControllerDocs.java} | 2 +- .../timer/docs/TimerStatusControllerDocs.java | 82 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) rename src/main/java/com/Timo/Timo/domain/timer/docs/{TimerControllerDocs.java => TimerStartControllerDocs.java} (98%) create mode 100644 src/main/java/com/Timo/Timo/domain/timer/docs/TimerStatusControllerDocs.java diff --git a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStartControllerDocs.java similarity index 98% rename from src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java rename to src/main/java/com/Timo/Timo/domain/timer/docs/TimerStartControllerDocs.java index e5f22bd8..d7ec365c 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStartControllerDocs.java @@ -13,7 +13,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; -public interface TimerControllerDocs { +public interface TimerStartControllerDocs { @Operation( summary = "타이머 시작", 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..ff5fba17 --- /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 + ); +} \ No newline at end of file From 8ce4f5e5bdabde64df796c73c878a8b6da0ad497 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 01:44:01 +0900 Subject: [PATCH 248/383] =?UTF-8?q?fix:=20TimerStartResponse=EC=9D=98=20st?= =?UTF-8?q?artedAt=20=EC=9D=91=EB=8B=B5=20=ED=8F=AC=EB=A7=B7=20=EC=A7=80?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/timer/docs/TimerStatusControllerDocs.java | 2 +- .../Timo/domain/timer/dto/response/TimerStartResponse.java | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) 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 index ff5fba17..5136c0c8 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStatusControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStatusControllerDocs.java @@ -22,7 +22,7 @@ public interface TimerStatusControllerDocs { summary = "타이머 일시정지/재개", description = """ 실행 중인 타이머의 일시정지 / 재개를 처리합니다.
- PAUSE: 현재 세션의 paused_at 기록, status → PAUSED + PAUSE: 현재 세션의 paused_at 기록, status → PAUSED
RESUME: 새 세션 생성, status → RUNNING """ ) 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 index 71c1b8f3..78ffca3a 100644 --- 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 @@ -1,6 +1,8 @@ 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( @@ -8,6 +10,8 @@ public record TimerStartResponse( 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) { From 878d8fd0b53299556867847e6671b246b12ae5cc Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:47:37 +0900 Subject: [PATCH 249/383] =?UTF-8?q?feat(todo):=20=ED=95=98=EC=9C=84=20?= =?UTF-8?q?=ED=83=9C=EC=8A=A4=ED=8A=B8=20=EC=99=84=EB=A3=8C=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/todo/entity/Subtask.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 index ea51ba1a..231ea760 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/entity/Subtask.java +++ b/src/main/java/com/Timo/Timo/domain/todo/entity/Subtask.java @@ -40,13 +40,23 @@ public class Subtask extends BaseTimeEntity { 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 = false; + 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; } From 46ab188d89c20db1363503df1218a7fa7d8e8ed4 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:51:14 +0900 Subject: [PATCH 250/383] =?UTF-8?q?feat(todo):=20todo=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../todo/controller/TodoController.java | 19 +++++ .../domain/todo/docs/TodoControllerDocs.java | 81 +++++++++++++++++++ .../dto/request/TodoSubtaskUpdateRequest.java | 17 ++++ .../todo/dto/request/TodoUpdateRequest.java | 59 ++++++++++++++ .../Timo/Timo/domain/todo/entity/Todo.java | 80 ++++++++++++++++++ .../domain/todo/exception/TodoErrorCode.java | 6 +- .../todo/exception/TodoSuccessCode.java | 3 +- .../todo/repository/TodoRepository.java | 3 + .../Timo/domain/todo/service/TodoService.java | 74 +++++++++++++++++ 9 files changed, 339 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoSubtaskUpdateRequest.java create mode 100644 src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoUpdateRequest.java 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 index 874620d8..c83d6f4a 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/controller/TodoController.java +++ b/src/main/java/com/Timo/Timo/domain/todo/controller/TodoController.java @@ -1,7 +1,11 @@ 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.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; @@ -9,6 +13,7 @@ import com.Timo.Timo.domain.todo.docs.TodoControllerDocs; import com.Timo.Timo.domain.todo.dto.request.TodoCreateRequest; +import com.Timo.Timo.domain.todo.dto.request.TodoUpdateRequest; import com.Timo.Timo.domain.todo.dto.response.TodoCreateResponse; import com.Timo.Timo.domain.todo.exception.TodoSuccessCode; import com.Timo.Timo.domain.todo.service.TodoService; @@ -39,4 +44,18 @@ public ResponseEntity> createTodo( .status(TodoSuccessCode.CREATED.getHttpStatus()) .body(BaseResponse.onSuccess(TodoSuccessCode.CREATED, 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())); + } } 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 index 26284a23..246b1fc1 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java @@ -3,6 +3,7 @@ import org.springframework.http.ResponseEntity; import com.Timo.Timo.domain.todo.dto.request.TodoCreateRequest; +import com.Timo.Timo.domain.todo.dto.request.TodoUpdateRequest; import com.Timo.Timo.domain.todo.dto.response.TodoCreateResponse; import com.Timo.Timo.global.auth.principal.CustomUserDetails; import com.Timo.Timo.global.exception.dto.ErrorDto; @@ -113,4 +114,84 @@ ResponseEntity> createTodo( @Parameter(hidden = true) CustomUserDetails userDetails, TodoCreateRequest 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를 전달한 경우", + 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 + ); } 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/entity/Todo.java b/src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java index 47835a1b..3d0970fe 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java +++ b/src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java @@ -4,13 +4,17 @@ 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; @@ -160,6 +164,82 @@ public static Todo create( 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 = repeatWeekdays != null ? new ArrayList<>(repeatWeekdays) : new ArrayList<>(); + this.repeatDayOfMonth = repeatDayOfMonth; + } + + 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.INVALID_REQUEST); + } + 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(); 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 index 61fc9928..7e4852e9 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java @@ -13,8 +13,10 @@ public enum TodoErrorCode implements BaseErrorCode { INVALID_REQUEST(HttpStatus.BAD_REQUEST, "TODO_400", "필수 필드가 누락되었거나 형식이 올바르지 않습니다."), INVALID_TITLE(HttpStatus.BAD_REQUEST, "TODO_400", "투두명은 한국어 20자/영어 30자를 초과할 수 없습니다."), - TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_404", "존재하지 않는 투두입니다"), - MAX_COUNT_EXCEEDED(HttpStatus.CONFLICT, "TODO_409", "해당 날짜의 투두가 최대 개수(20개)를 초과했습니다."); + NO_UPDATE_FIELDS(HttpStatus.BAD_REQUEST, "COMMON_400", "수정할 필드가 없습니다."), + TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_404", "존재하지 않는 투두입니다"), + MAX_COUNT_EXCEEDED(HttpStatus.CONFLICT, "TODO_409", "해당 날짜의 투두가 최대 개수(20개)를 초과했습니다."), + ; private final HttpStatus httpStatus; private final String code; 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 index f36845e4..9192a1db 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoSuccessCode.java @@ -11,7 +11,8 @@ @RequiredArgsConstructor public enum TodoSuccessCode implements BaseSuccessCode { - CREATED(HttpStatus.CREATED, "TODO가 생성되었습니다."); + CREATED(HttpStatus.CREATED, "TODO가 생성되었습니다."), + UPDATED(HttpStatus.OK, "TODO가 수정되었습니다."); private final HttpStatus httpStatus; private final String message; 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 index 06e75a06..0da956c7 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java @@ -2,6 +2,7 @@ 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.Query; @@ -11,6 +12,8 @@ public interface TodoRepository extends JpaRepository { + Optional findByIdAndUser_Id(Long id, Long userId); + @Query(""" select t from Todo t where t.user.id = :userId 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 index e61b1023..394af665 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java @@ -9,9 +9,13 @@ import com.Timo.Timo.domain.tag.exception.TagErrorCode; import com.Timo.Timo.domain.tag.repository.TagRepository; import com.Timo.Timo.domain.todo.dto.request.TodoCreateRequest; +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.TodoCreateResponse; 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.domain.todo.repository.TodoRepository; import com.Timo.Timo.domain.todo.vo.Duration; import com.Timo.Timo.domain.user.entity.User; @@ -66,6 +70,76 @@ public TodoCreateResponse createTodo(Long userId, TodoCreateRequest request) { return TodoCreateResponse.from(savedTodo); } + @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)); + + 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())); + } + } + + private void applyScheduleChange(Todo todo, TodoUpdateRequest request) { + boolean scheduleChanged = request.date() != null + || request.repeatType() != null + || request.repeatWeekdays() != null + || request.repeatDayOfMonth() != null; + if (!scheduleChanged) { + 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); + + 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 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); From 6d9593a3430da42905939501bdf26e5e77ca3189 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 03:10:59 +0900 Subject: [PATCH 251/383] =?UTF-8?q?feat:=20=EC=99=84=EB=A3=8C/=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=20=EC=97=90=EB=9F=AC/=EC=84=B1=EA=B3=B5=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/timer/exception/TimerErrorCode.java | 3 ++- .../Timo/Timo/domain/timer/exception/TimerSuccessCode.java | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) 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 index 69549ed3..87eb7e89 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/exception/TimerErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/timer/exception/TimerErrorCode.java @@ -11,7 +11,8 @@ 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_INVALID_STATUS_TRANSITION(HttpStatus.CONFLICT, "TIMER_409", "요청을 처리할 수 없는 타이머 상태입니다."), + TIMER_ALREADY_FINISHED(HttpStatus.CONFLICT, "TIMER_409", "이미 종료된 타이머입니다."); private final HttpStatus httpStatus; private final String code; 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 index 4ee0d0f8..f2612ff1 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java @@ -11,7 +11,9 @@ public enum TimerSuccessCode implements BaseSuccessCode { TIMER_PAUSED(HttpStatus.OK, "TIMER_200", "타이머가 일시정지되었습니다."), TIMER_RESUMED(HttpStatus.OK, "TIMER_200", "타이머가 재개되었습니다."), - TIMER_STARTED(HttpStatus.CREATED, "TIMER_201", "타이머가 시작되었습니다."); + TIMER_STARTED(HttpStatus.CREATED, "TIMER_201", "타이머가 시작되었습니다."), + TIMER_COMPLETED(HttpStatus.OK, "TIMER_200_3", "타이머가 완료되었습니다."), + TIMER_STOPPED(HttpStatus.OK, "TIMER_200_4", "타이머가 종료되었습니다."); private final HttpStatus httpStatus; private final String code; From 81381e42fb48e7febed434719d7833d2490f02e0 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 03:11:21 +0900 Subject: [PATCH 252/383] =?UTF-8?q?feat:=20todo=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=EC=A0=84=20timer=20=EC=83=81=ED=83=9C=20=ED=99=95=EC=9D=B8?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/timer/repository/TimerRecordRepository.java | 9 +++++++++ 1 file changed, 9 insertions(+) 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 index b7ac0642..3d01435d 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java @@ -5,8 +5,17 @@ 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 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); } From 8737d59f72a9892c92a82c3d8797282beb59230d Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 03:11:50 +0900 Subject: [PATCH 253/383] =?UTF-8?q?feat:=20TimerRecord=EC=97=90=20?= =?UTF-8?q?=EC=A2=85=EB=A3=8C=20=EC=B2=98=EB=A6=AC=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/timer/entity/TimerRecord.java | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 index c61f861c..3deeb35d 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java +++ b/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java @@ -95,6 +95,16 @@ public void resume() { this.status = TimerStatus.RUNNING; } + public void finish(TimerStatus targetStatus, LocalDateTime endedAt, int actualSeconds, String aiFeedback) { + if (isFinished()) { + throw new CustomException(TimerErrorCode.TIMER_ALREADY_FINISHED); + } + this.status = targetStatus; + this.endedAt = endedAt; + this.actualSeconds = actualSeconds; + this.aiFeedback = aiFeedback; + } + public boolean isRunning() { return this.status == TimerStatus.RUNNING; } From 628efc125584815eda91398c2b61eec52e2e4b75 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 03:12:47 +0900 Subject: [PATCH 254/383] =?UTF-8?q?feat:=20TimerFinishResponse=20DTO=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/TimerFinishResponse.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerFinishResponse.java 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..893a983d --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerFinishResponse.java @@ -0,0 +1,23 @@ +package com.Timo.Timo.domain.timer.dto.response; + +import com.Timo.Timo.domain.timer.entity.TimerRecord; + +public record TimerFinishResponse( + Long timerId, + Long todoId, + String status, + Integer plannedSeconds, + Integer actualSeconds, + 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() + ); + } +} From 74b1e7821f99ae4d0662b7e8d111e7f6e5298785 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 03:13:02 +0900 Subject: [PATCH 255/383] =?UTF-8?q?feat:=20todo=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=B4=20=EC=88=9C=EC=B0=A8=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/repository/TimerSessionRepository.java | 7 +++++++ .../Timo/Timo/domain/timer/service/TimerService.java | 12 +++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) 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 index fd811590..bd803684 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java @@ -3,8 +3,15 @@ import com.Timo.Timo.domain.timer.entity.TimerSession; 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); + + @Modifying(clearAutomatically = true) + @Query("delete from TimerSession s where s.timerRecord.todo.id = :todoId") + void deleteByTodoId(@Param("todoId") Long todoId); } 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 index 87b83d9a..b617783b 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -67,4 +67,14 @@ public TimerStartResponse startTimer(Long userId, Long todoId) { return TimerStartResponse.from(timerRecord); } -} \ No newline at end of file + + public boolean hasActiveTimer(Long todoId) { + return timerRecordRepository.existsByTodo_IdAndStatusIn(todoId, ACTIVE_STATUS); + } + + @Transactional + public void deleteTimersByTodo(Long todoId) { + timerSessionRepository.deleteByTodoId(todoId); + timerRecordRepository.deleteByTodoId(todoId); + } +} From 94dedf4d93ca2f648d89d4ce602b6296834eb791 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 03:13:16 +0900 Subject: [PATCH 256/383] =?UTF-8?q?feat:=20todo=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=97=90=EB=9F=AC,=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/todo/exception/TodoErrorCode.java | 1 + .../com/Timo/Timo/domain/todo/exception/TodoSuccessCode.java | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) 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 index 7e4852e9..b84b642e 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java @@ -16,6 +16,7 @@ public enum TodoErrorCode implements BaseErrorCode { NO_UPDATE_FIELDS(HttpStatus.BAD_REQUEST, "COMMON_400", "수정할 필드가 없습니다."), TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_404", "존재하지 않는 투두입니다"), MAX_COUNT_EXCEEDED(HttpStatus.CONFLICT, "TODO_409", "해당 날짜의 투두가 최대 개수(20개)를 초과했습니다."), + TIMER_RUNNING(HttpStatus.CONFLICT, "TODO_409", "타이머가 실행 중인 TODO는 삭제할 수 없습니다. 타이머를 먼저 종료해주세요."), ; private final HttpStatus httpStatus; 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 index 9192a1db..6ed26a3a 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoSuccessCode.java @@ -12,7 +12,8 @@ public enum TodoSuccessCode implements BaseSuccessCode { CREATED(HttpStatus.CREATED, "TODO가 생성되었습니다."), - UPDATED(HttpStatus.OK, "TODO가 수정되었습니다."); + UPDATED(HttpStatus.OK, "TODO가 수정되었습니다."), + DELETED(HttpStatus.OK, "TODO가 삭제되었습니다."); private final HttpStatus httpStatus; private final String message; From fa212fe02beafbaf99873be6a87d6ff4f5b18d7e Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 03:13:49 +0900 Subject: [PATCH 257/383] =?UTF-8?q?feat:=20todo=20=EC=82=AD=EC=A0=9C=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../todo/controller/TodoController.java | 14 +++++ .../domain/todo/docs/TodoControllerDocs.java | 53 +++++++++++++++++++ .../repository/TodoInstanceRepository.java | 5 ++ .../Timo/domain/todo/service/TodoService.java | 18 +++++++ 4 files changed, 90 insertions(+) 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 index c83d6f4a..65fb43a3 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/controller/TodoController.java +++ b/src/main/java/com/Timo/Timo/domain/todo/controller/TodoController.java @@ -4,6 +4,7 @@ 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.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -58,4 +59,17 @@ public ResponseEntity> updateTodo( .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 index 246b1fc1..5e1969b3 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java @@ -194,4 +194,57 @@ ResponseEntity> updateTodo( @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 + ); } 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 index 1a7f2fa5..a32f2ff5 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoInstanceRepository.java +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoInstanceRepository.java @@ -5,6 +5,7 @@ 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; @@ -14,6 +15,10 @@ public interface TodoInstanceRepository extends JpaRepository 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 i from TodoInstance i where i.todo.id in :todoIds 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 index 394af665..51a51de1 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java @@ -16,8 +16,10 @@ 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.timer.service.TimerService; import com.Timo.Timo.domain.user.entity.User; import com.Timo.Timo.domain.user.exception.UserErrorCode; import com.Timo.Timo.domain.user.repository.UserRepository; @@ -31,10 +33,12 @@ 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 TimerService timerService; @Transactional public TodoCreateResponse createTodo(Long userId, TodoCreateRequest request) { @@ -97,6 +101,20 @@ public void updateTodo(Long userId, Long todoId, TodoUpdateRequest request) { } } + @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 void applyScheduleChange(Todo todo, TodoUpdateRequest request) { boolean scheduleChanged = request.date() != null || request.repeatType() != null From 8f2efc5574c5d5eaa15236d556d6e4c96e965f75 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:13:18 +0900 Subject: [PATCH 258/383] =?UTF-8?q?feat:=20todo=20=EC=99=84=EB=A3=8C?= =?UTF-8?q?=EC=97=AC=EB=B6=80=20=EB=B3=80=EA=B2=BD=20dto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/TodoStatusUpdateRequest.java | 13 +++++++++++++ .../dto/response/TodoStatusChangeResponse.java | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoStatusUpdateRequest.java create mode 100644 src/main/java/com/Timo/Timo/domain/todo/dto/response/TodoStatusChangeResponse.java 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..f2ae0bca --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoStatusUpdateRequest.java @@ -0,0 +1,13 @@ +package com.Timo.Timo.domain.todo.dto.request; + +import java.time.LocalDate; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record TodoStatusUpdateRequest( + @JsonProperty("isCompleted") + Boolean isCompleted, + + LocalDate date +) { +} 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() + ); + } +} From fc7622a4653e4c204e3ff92dc05c52bcb3005798 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:13:35 +0900 Subject: [PATCH 259/383] =?UTF-8?q?feat:=20todo=20=EC=99=84=EB=A3=8C?= =?UTF-8?q?=EC=97=AC=EB=B6=80=20=EB=B3=80=EA=B2=BD=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=BD=94=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/todo/exception/TodoErrorCode.java | 6 ++++-- .../Timo/Timo/domain/todo/exception/TodoSuccessCode.java | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) 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 index 61fc9928..1de82e79 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java @@ -13,8 +13,10 @@ public enum TodoErrorCode implements BaseErrorCode { INVALID_REQUEST(HttpStatus.BAD_REQUEST, "TODO_400", "필수 필드가 누락되었거나 형식이 올바르지 않습니다."), INVALID_TITLE(HttpStatus.BAD_REQUEST, "TODO_400", "투두명은 한국어 20자/영어 30자를 초과할 수 없습니다."), - TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_404", "존재하지 않는 투두입니다"), - MAX_COUNT_EXCEEDED(HttpStatus.CONFLICT, "TODO_409", "해당 날짜의 투두가 최대 개수(20개)를 초과했습니다."); + TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_404", "존재하지 않는 TODO입니다."), + MAX_COUNT_EXCEEDED(HttpStatus.CONFLICT, "TODO_409", "해당 날짜의 투두가 최대 개수(20개)를 초과했습니다."), + IS_COMPLETED_REQUIRED(HttpStatus.BAD_REQUEST, "COMMON_400", "isCompleted는 필수입니다."), + TIMER_RUNNING(HttpStatus.CONFLICT, "TODO_409", "타이머가 실행 중인 TODO는 변경할 수 없습니다. 타이머를 먼저 종료해주세요."); private final HttpStatus httpStatus; private final String code; 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 index f36845e4..96bdf57a 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoSuccessCode.java @@ -11,7 +11,8 @@ @RequiredArgsConstructor public enum TodoSuccessCode implements BaseSuccessCode { - CREATED(HttpStatus.CREATED, "TODO가 생성되었습니다."); + CREATED(HttpStatus.CREATED, "TODO가 생성되었습니다."), + STATUS_CHANGED(HttpStatus.OK, "TODO 상태가 변경되었습니다."); private final HttpStatus httpStatus; private final String message; From e647966147a115ebc818bebf4cb9732239325ca0 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:14:18 +0900 Subject: [PATCH 260/383] =?UTF-8?q?feat:=20timer=20=ED=99=9C=EC=84=B1?= =?UTF-8?q?=ED=99=94=20=EC=97=AC=EB=B6=80=20=ED=99=95=EC=9D=B8=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/timer/service/TimerService.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 index 87b83d9a..f3faef76 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -67,4 +67,8 @@ public TimerStartResponse startTimer(Long userId, Long todoId) { return TimerStartResponse.from(timerRecord); } -} \ No newline at end of file + + public boolean hasActiveTimer(Long todoId) { + return timerRecordRepository.existsByTodo_IdAndStatusIn(todoId, ACTIVE_STATUS); + } +} From 2ebcc6df5332a38fdfd5af839cd3b18f4b809f91 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:14:38 +0900 Subject: [PATCH 261/383] =?UTF-8?q?feat:=20todo=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=EC=97=AC=EB=B6=80=20=EA=B4=80=EB=A0=A8=20repository=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/timer/repository/TimerRecordRepository.java | 2 ++ .../com/Timo/Timo/domain/todo/repository/TodoRepository.java | 3 +++ 2 files changed, 5 insertions(+) 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 index b7ac0642..54b12b69 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java @@ -9,4 +9,6 @@ public interface TimerRecordRepository extends JpaRepository { Optional findByUserIdAndStatusIn(Long userId, List statuses); + + boolean existsByTodo_IdAndStatusIn(Long todoId, List statuses); } 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 index 06e75a06..0da956c7 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java @@ -2,6 +2,7 @@ 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.Query; @@ -11,6 +12,8 @@ public interface TodoRepository extends JpaRepository { + Optional findByIdAndUser_Id(Long id, Long userId); + @Query(""" select t from Todo t where t.user.id = :userId From 8c57bc69e171689d56f9e31edeb91de75bfbcc57 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:15:16 +0900 Subject: [PATCH 262/383] =?UTF-8?q?feat:=20todo=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=EC=97=AC=EB=B6=80=20=EA=B4=80=EB=A0=A8=20=EC=88=9C=EC=84=9C=20?= =?UTF-8?q?=EC=9E=AC=EC=A0=95=EB=A0=AC=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../todo/service/TodoInstanceReorderer.java | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/todo/service/TodoInstanceReorderer.java 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..82189f1b --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoInstanceReorderer.java @@ -0,0 +1,93 @@ +package com.Timo.Timo.domain.todo.service; + +import java.time.LocalDate; +import java.util.ArrayList; +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.repository.TodoInstanceRepository; +import com.Timo.Timo.domain.todo.repository.TodoRepository; + +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 = instancesByTodoId.get(targetRule.getId()); + + if (completed) { + moveToCompletedBottom(target, instancesByTodoId.values()); + } else { + moveToIncompleteTop(target, instancesByTodoId.values()); + } + 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); + } +} From a734422cdd054215565b882067f20ba5db76f0eb Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:18:29 +0900 Subject: [PATCH 263/383] =?UTF-8?q?feat:=20todo=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=EC=97=AC=EB=B6=80=20=EB=A1=9C=EC=A7=81=20service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/todo/service/TodoService.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) 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 index e61b1023..3beab77a 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java @@ -1,6 +1,7 @@ package com.Timo.Timo.domain.todo.service; import java.time.LocalDate; +import java.time.ZoneId; import java.util.List; import org.springframework.stereotype.Service; @@ -8,10 +9,15 @@ 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.TodoCreateRequest; +import com.Timo.Timo.domain.todo.dto.request.TodoStatusUpdateRequest; import com.Timo.Timo.domain.todo.dto.response.TodoCreateResponse; +import com.Timo.Timo.domain.todo.dto.response.TodoStatusChangeResponse; 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.exception.TodoErrorCode; import com.Timo.Timo.domain.todo.repository.TodoRepository; import com.Timo.Timo.domain.todo.vo.Duration; import com.Timo.Timo.domain.user.entity.User; @@ -31,6 +37,8 @@ public class TodoService { 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) { @@ -66,6 +74,37 @@ public TodoCreateResponse createTodo(Long userId, TodoCreateRequest request) { return TodoCreateResponse.from(savedTodo); } + @Transactional + public TodoStatusChangeResponse changeCompletion(Long userId, Long todoId, TodoStatusUpdateRequest request) { + if (request.isCompleted() == null) { + throw new CustomException(TodoErrorCode.IS_COMPLETED_REQUIRED); + } + + 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); + } + + 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 void validateTagExists(Long tagId) { if (tagId != null && !tagRepository.existsById(tagId)) { throw new CustomException(TagErrorCode.TAG_NOT_FOUND); From 3d4560f5fc9e86c550c84f090c787d5596fbfb57 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:18:37 +0900 Subject: [PATCH 264/383] =?UTF-8?q?feat:=20todo=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=EC=97=AC=EB=B6=80=20controller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../todo/controller/TodoController.java | 18 ++++ .../domain/todo/docs/TodoControllerDocs.java | 84 +++++++++++++++++++ 2 files changed, 102 insertions(+) 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 index 874620d8..a347261a 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/controller/TodoController.java +++ b/src/main/java/com/Timo/Timo/domain/todo/controller/TodoController.java @@ -2,6 +2,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -9,7 +11,9 @@ import com.Timo.Timo.domain.todo.docs.TodoControllerDocs; import com.Timo.Timo.domain.todo.dto.request.TodoCreateRequest; +import com.Timo.Timo.domain.todo.dto.request.TodoStatusUpdateRequest; import com.Timo.Timo.domain.todo.dto.response.TodoCreateResponse; +import com.Timo.Timo.domain.todo.dto.response.TodoStatusChangeResponse; import com.Timo.Timo.domain.todo.exception.TodoSuccessCode; import com.Timo.Timo.domain.todo.service.TodoService; import com.Timo.Timo.global.auth.principal.CustomUserDetails; @@ -39,4 +43,18 @@ public ResponseEntity> createTodo( .status(TodoSuccessCode.CREATED.getHttpStatus()) .body(BaseResponse.onSuccess(TodoSuccessCode.CREATED, response)); } + + @Override + @PatchMapping("/{todoId}/status") + public ResponseEntity> changeTodoStatus( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long todoId, + @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)); + } } 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 index 26284a23..3c008189 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java @@ -3,7 +3,9 @@ import org.springframework.http.ResponseEntity; import com.Timo.Timo.domain.todo.dto.request.TodoCreateRequest; +import com.Timo.Timo.domain.todo.dto.request.TodoStatusUpdateRequest; import com.Timo.Timo.domain.todo.dto.response.TodoCreateResponse; +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; @@ -113,4 +115,86 @@ ResponseEntity> createTodo( @Parameter(hidden = true) CustomUserDetails userDetails, TodoCreateRequest request ); + + @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 + ); } From 796074287cd9b06eb5c8401750f9b75f66d9d142 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:31:38 +0900 Subject: [PATCH 265/383] =?UTF-8?q?refactor:=20todo=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EC=8B=9C=20=EC=9A=A9=EB=9F=89=20=EA=B2=80=EC=82=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../todo/service/TodoCapacityChecker.java | 5 +++++ .../todo/service/TodoDateCalculator.java | 21 +++++++++++++++---- .../Timo/domain/todo/service/TodoService.java | 3 +++ 3 files changed, 25 insertions(+), 4 deletions(-) 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 index b2769413..89bd5842 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/service/TodoCapacityChecker.java +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoCapacityChecker.java @@ -22,6 +22,10 @@ public class TodoCapacityChecker { 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; } @@ -33,6 +37,7 @@ public void assertCapacity(Long userId, List newRuleDates) { for (LocalDate date : newRuleDates) { long existingCount = existingRules.stream() + .filter(rule -> !rule.getId().equals(excludeTodoId)) .filter(rule -> todoDateCalculator.occursOn(rule, date)) .count(); 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 index a4e9fabf..746e36b0 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/service/TodoDateCalculator.java +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoDateCalculator.java @@ -37,14 +37,27 @@ public class TodoDateCalculator { ); public List calculate(TodoCreateRequest request) { - LocalDate start = request.date(); + 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 (request.repeatType()) { + Set dates = switch (repeatType) { case NONE -> Set.of(start); case DAILY -> daily(start, end); - case WEEKLY -> weekly(start, end, request.repeatWeekdays()); - case MONTHLY -> monthly(start, end, request.repeatDayOfMonth()); + case WEEKLY -> weekly(start, end, repeatWeekdays); + case MONTHLY -> monthly(start, end, repeatDayOfMonth); }; if (dates.isEmpty()) { 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 index 51a51de1..b005a077 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java @@ -133,6 +133,9 @@ private void applyScheduleChange(Todo todo, TodoUpdateRequest request) { validateRepeatRule(repeatType, repeatWeekdays, repeatDayOfMonth); + List todoDates = todoDateCalculator.calculate(startDate, repeatType, repeatWeekdays, repeatDayOfMonth); + todoCapacityChecker.assertCapacity(todo.getUser().getId(), todo.getId(), todoDates); + LocalDate endDate = resolveEndDate(startDate, repeatType); todo.changeSchedule(startDate, endDate, repeatType, repeatWeekdays, repeatDayOfMonth); } From 89251c9b3fa2997491ff038e54cb246760ee4673 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:39:56 +0900 Subject: [PATCH 266/383] =?UTF-8?q?refactor:=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20DTO=20=EC=B2=98=EB=A6=AC=20=EC=9D=BC=EA=B4=80?= =?UTF-8?q?=EC=84=B1=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/todo/controller/TodoController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index a347261a..967b4901 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/controller/TodoController.java +++ b/src/main/java/com/Timo/Timo/domain/todo/controller/TodoController.java @@ -49,7 +49,7 @@ public ResponseEntity> createTodo( public ResponseEntity> changeTodoStatus( @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long todoId, - @RequestBody TodoStatusUpdateRequest request + @Valid @RequestBody TodoStatusUpdateRequest request ) { TodoStatusChangeResponse response = todoService.changeCompletion(userDetails.getUserId(), todoId, request); From 54f813085e270ee28185632d1955d2355d746a01 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:48:24 +0900 Subject: [PATCH 267/383] =?UTF-8?q?chore:=20=EC=97=AC=EB=B0=B1=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/todo/dto/request/TodoStatusUpdateRequest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 index f2ae0bca..cf359cbc 100644 --- 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 @@ -9,5 +9,4 @@ public record TodoStatusUpdateRequest( Boolean isCompleted, LocalDate date -) { -} +) { } From 6ac8dd477f25a72509dcd5fc55708f44265bac53 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 12:24:50 +0900 Subject: [PATCH 268/383] =?UTF-8?q?chore:=20TimeController=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EB=AA=85=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{TimerStartController.java => TimerController.java} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/main/java/com/Timo/Timo/domain/timer/controller/{TimerStartController.java => TimerController.java} (96%) diff --git a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerStartController.java b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java similarity index 96% rename from src/main/java/com/Timo/Timo/domain/timer/controller/TimerStartController.java rename to src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java index 0ffa74c4..504819d7 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerStartController.java +++ b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java @@ -27,7 +27,7 @@ @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor -public class TimerStartController implements TimerStartControllerDocs, TimerStatusControllerDocs { +public class TimerController implements TimerStartControllerDocs, TimerStatusControllerDocs { private final TimerService timerService; private final TimerResponseFactory timerResponseFactory; From 5a531958d304038d3b6a10fe710d0439c0f5230f Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 12:36:09 +0900 Subject: [PATCH 269/383] =?UTF-8?q?feat:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91/=EC=9D=BC=EC=8B=9C=EC=A0=95=EC=A7=80/?= =?UTF-8?q?=EC=9E=AC=EA=B0=9C=20=EC=8B=9C=20TodoInstance=20timerStatus=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/timer/service/TimerService.java | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) 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 index db4e2493..5d200b18 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -10,7 +10,9 @@ 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; @@ -18,6 +20,7 @@ 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; @@ -35,6 +38,7 @@ public class TimerService { private final TimerSessionRepository timerSessionRepository; private final TodoRepository todoRepository; private final UserRepository userRepository; + private final TodoInstanceRepository todoInstanceRepository; @Transactional public TimerStartResponse startTimer(Long userId, Long todoId) { @@ -68,6 +72,9 @@ public TimerStartResponse startTimer(Long userId, Long todoId) { .build(); timerSessionRepository.save(session); + TodoInstance instance = getOrCreateInstance(todo, now.toLocalDate()); + instance.startTimer(); + return TimerStartResponse.from(timerRecord); } @@ -82,11 +89,14 @@ public TimerStatusResponse changeStatus(Long userId, Long timerId, TimerAction a LocalDateTime now = LocalDateTime.now(); - if (action == TimerAction.PAUSE){ + 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 { timerRecord.resume(); TimerSession newSession = TimerSession.builder() @@ -94,6 +104,7 @@ public TimerStatusResponse changeStatus(Long userId, Long timerId, TimerAction a .startedAt(now) .build(); timerSessionRepository.save(newSession); + instance.startTimer(); } int elapsedSeconds = calculateElapsedSeconds(timerId, now); @@ -111,4 +122,9 @@ private int calculateElapsedSeconds(Long timerRecordId, LocalDateTime now){ 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))); + } } \ No newline at end of file From 385497a4780df0096c9f99fe09e5065f453370d6 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 12:41:41 +0900 Subject: [PATCH 270/383] =?UTF-8?q?feat:=20TimerService=EC=97=90=20?= =?UTF-8?q?=ED=83=80=EC=9D=B4=EB=A8=B8=20=EC=99=84=EB=A3=8C/=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/TimerSessionRepository.java | 2 + .../domain/timer/service/TimerService.java | 53 ++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) 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 index fd811590..eb9a11e5 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java @@ -1,10 +1,12 @@ package com.Timo.Timo.domain.timer.repository; import com.Timo.Timo.domain.timer.entity.TimerSession; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface TimerSessionRepository extends JpaRepository { Optional findByTimerRecordIdAndPausedAtIsNull(Long timerRecordId); + List findByTimerRecordId(Long timerRecordId); } 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 index f3faef76..f7c3ae07 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -1,5 +1,6 @@ package com.Timo.Timo.domain.timer.service; +import com.Timo.Timo.domain.timer.dto.response.TimerFinishResponse; import com.Timo.Timo.domain.timer.dto.response.TimerStartResponse; import com.Timo.Timo.domain.timer.entity.TimerRecord; import com.Timo.Timo.domain.timer.entity.TimerSession; @@ -8,13 +9,17 @@ 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; @@ -32,6 +37,7 @@ public class TimerService { private final TimerSessionRepository timerSessionRepository; private final TodoRepository todoRepository; private final UserRepository userRepository; + private final TodoInstanceRepository todoInstanceRepository; @Transactional public TimerStartResponse startTimer(Long userId, Long todoId) { @@ -71,4 +77,49 @@ public TimerStartResponse startTimer(Long userId, Long todoId) { public boolean hasActiveTimer(Long todoId) { return timerRecordRepository.existsByTodo_IdAndStatusIn(todoId, ACTIVE_STATUS); } -} + + @Transactional + public TimerFinishResponse completeTimer(Long userId, Long timerId) { + return finishTimer(userId, timerId, TimerStatus.COMPLETED); + } + + @Transactional + public TimerFinishResponse stopTimer(Long userId, Long timerId) { + return finishTimer(userId, timerId, TimerStatus.STOPPED); + } + + private TimerFinishResponse finishTimer(Long userId, Long timerId, TimerStatus targetStatus) { + TimerRecord timerRecord = timerRecordRepository.findById(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); + + timerRecord.finish(targetStatus, now, actualSeconds, null); + + TodoInstance instance = getOrCreateInstance(timerRecord.getTodo(), timerRecord.getStartedAt().toLocalDate()); + instance.stopTimer(); + instance.markCompleted(); + + return TimerFinishResponse.of(timerRecord); + } + + 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))); + } +} \ No newline at end of file From 8b448bbf5f66924dd3496a4a1b67d04621b5383e Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 12:51:29 +0900 Subject: [PATCH 271/383] =?UTF-8?q?feat:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C/=EC=A2=85=EB=A3=8C=20API=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/controller/TimerController.java | 32 ++++++++++++++++++- .../timer/factory/TimerResponseFactory.java | 9 ++++++ 2 files changed, 40 insertions(+), 1 deletion(-) 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 index 3192830a..878a9b62 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java +++ b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java @@ -1,7 +1,11 @@ package com.Timo.Timo.domain.timer.controller; +import com.Timo.Timo.domain.timer.docs.TimerCompleteControllerDocs; import com.Timo.Timo.domain.timer.docs.TimerControllerDocs; +import com.Timo.Timo.domain.timer.docs.TimerStopControllerDocs; +import com.Timo.Timo.domain.timer.dto.response.TimerFinishResponse; import com.Timo.Timo.domain.timer.dto.response.TimerStartResponse; +import com.Timo.Timo.domain.timer.exception.TimerSuccessCode; import com.Timo.Timo.domain.timer.factory.TimerResponseFactory; import com.Timo.Timo.domain.timer.service.TimerService; import com.Timo.Timo.global.auth.principal.CustomUserDetails; @@ -10,6 +14,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -19,7 +24,8 @@ @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor -public class TimerController implements TimerControllerDocs { +public class TimerController implements TimerControllerDocs, TimerCompleteControllerDocs, + TimerStopControllerDocs { private final TimerService timerService; private final TimerResponseFactory timerResponseFactory; @@ -36,4 +42,28 @@ public ResponseEntity> startTimer( return timerResponseFactory.startResponse(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 timerResponseFactory.finishResponse(response, TimerSuccessCode.TIMER_COMPLETED); + } + + @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 timerResponseFactory.finishResponse(response, TimerSuccessCode.TIMER_STOPPED); + } } diff --git a/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java b/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java index 4eb36503..c5a49257 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java +++ b/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java @@ -1,5 +1,6 @@ package com.Timo.Timo.domain.timer.factory; +import com.Timo.Timo.domain.timer.dto.response.TimerFinishResponse; import com.Timo.Timo.domain.timer.dto.response.TimerStartResponse; import com.Timo.Timo.domain.timer.exception.TimerSuccessCode; import com.Timo.Timo.global.response.BaseResponse; @@ -14,4 +15,12 @@ public ResponseEntity> startResponse(TimerStart return ResponseEntity.status(HttpStatus.CREATED) .body(BaseResponse.onSuccess(TimerSuccessCode.TIMER_STARTED, response)); } + + public ResponseEntity> finishResponse( + TimerFinishResponse response, + TimerSuccessCode successCode + ) { + return ResponseEntity.ok() + .body(BaseResponse.onSuccess(successCode, response)); + } } From 782a6f777a6d7628d405c0b62cc3df85770d5c7d Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 12:53:25 +0900 Subject: [PATCH 272/383] =?UTF-8?q?docs:=20=EC=99=84=EB=A3=8C/=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=20API=20Swagger=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/TimerCompleteControllerDocs.java | 79 +++++++++++++++++++ .../timer/docs/TimerStopControllerDocs.java | 79 +++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/timer/docs/TimerCompleteControllerDocs.java create mode 100644 src/main/java/com/Timo/Timo/domain/timer/docs/TimerStopControllerDocs.java 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..7c83b01e --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerCompleteControllerDocs.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 TimerCompleteControllerDocs { + + @Operation( + summary = "타이머 시간 완료", + description = """ + 예상 소요 시간이 모두 경과하여 타이머를 자동 종료합니다. + 종료 시각 기록, 실제 수행 시간 계산 (status → COMPLETED) + 해당 날짜 TodoInstance 완료 처리 및 타이머 상태 초기화 + aiFeedback은 현재 null로 반환되며, 추후 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> 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/TimerStopControllerDocs.java b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStopControllerDocs.java new file mode 100644 index 00000000..37566810 --- /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 완료 처리 및 타이머 상태 초기화 + aiFeedback은 현재 null로 반환되며, 추후 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 From 0a0e6996dba6e326086c72eb6adb4510444b4842 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 13:06:52 +0900 Subject: [PATCH 273/383] =?UTF-8?q?fix:COMMON=5F400=EC=97=90=EC=84=9C=20ST?= =?UTF-8?q?ATISTICS=5F400=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../statistics/controller/StatisticsController.java | 2 +- .../domain/statistics/docs/StatisticsCalendarDocs.java | 2 +- .../dto/response/StatisticsCalendarResponse.java | 2 +- .../domain/statistics/exception/StatisticsErrorCode.java | 8 ++++---- 4 files changed, 7 insertions(+), 7 deletions(-) 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 index bcac36f6..8342fd3c 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/controller/StatisticsController.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/controller/StatisticsController.java @@ -45,4 +45,4 @@ public ResponseEntity> getCalendar( BaseResponse.onSuccess(StatisticsSuccessCode.CALENDAR_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 index 13808e1e..b400d421 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/docs/StatisticsCalendarDocs.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/docs/StatisticsCalendarDocs.java @@ -68,4 +68,4 @@ ResponseEntity> getCalendar( ) 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 index 8bbe008f..ad77a537 100644 --- 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 @@ -25,4 +25,4 @@ public record DayCompletionResponse( Integer completionRate ) { } -} +} \ 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 index 4cf7956c..250f5bdc 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/exception/StatisticsErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/exception/StatisticsErrorCode.java @@ -11,11 +11,11 @@ @RequiredArgsConstructor public enum StatisticsErrorCode implements BaseErrorCode { - YEAR_MONTH_REQUIRED(HttpStatus.BAD_REQUEST, "COMMON_400", "yearMonth는 필수입니다."), - INVALID_YEAR_MONTH_FORMAT(HttpStatus.BAD_REQUEST, "COMMON_400", "yearMonth는 yyyy-MM 형식이어야 합니다."), - INVALID_YEAR_MONTH(HttpStatus.BAD_REQUEST, "COMMON_400", "유효하지 않은 연월입니다."); + 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", "유효하지 않은 연월입니다."); private final HttpStatus httpStatus; private final String code; private final String message; -} +} \ No newline at end of file From bfce3e25ebe95074199af686754207bcd976c2bf Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 15:53:15 +0900 Subject: [PATCH 274/383] =?UTF-8?q?refactor:=20TimerResponseFactory=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0,=20Controller=EB=A1=9C=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=20=EC=A1=B0=EB=A6=BD=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/controller/TimerController.java | 9 ++++--- .../timer/factory/TimerResponseFactory.java | 26 ------------------- 2 files changed, 5 insertions(+), 30 deletions(-) delete mode 100644 src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java 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 index 504819d7..edb21267 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java +++ b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java @@ -7,13 +7,13 @@ import com.Timo.Timo.domain.timer.dto.response.TimerStatusResponse; import com.Timo.Timo.domain.timer.enums.TimerAction; import com.Timo.Timo.domain.timer.exception.TimerSuccessCode; -import com.Timo.Timo.domain.timer.factory.TimerResponseFactory; 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.PatchMapping; @@ -30,7 +30,6 @@ public class TimerController implements TimerStartControllerDocs, TimerStatusControllerDocs { private final TimerService timerService; - private final TimerResponseFactory timerResponseFactory; @Override @PostMapping("/todos/{todoId}/timers/start") @@ -42,7 +41,8 @@ public ResponseEntity> startTimer( Long userId = userDetails.getUserId(); TimerStartResponse response = timerService.startTimer(userId, todoId); - return timerResponseFactory.startResponse(response); + return ResponseEntity.status(HttpStatus.CREATED) + .body(BaseResponse.onSuccess(TimerSuccessCode.TIMER_STARTED, response)); } @Override @@ -59,6 +59,7 @@ public ResponseEntity> changeStatus( ? TimerSuccessCode.TIMER_PAUSED : TimerSuccessCode.TIMER_RESUMED; - return timerResponseFactory.statusResponse(response, successCode); + return ResponseEntity.ok() + .body(BaseResponse.onSuccess(successCode, response)); } } diff --git a/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java b/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java deleted file mode 100644 index d2afd7a4..00000000 --- a/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.Timo.Timo.domain.timer.factory; - -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.global.response.BaseResponse; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; - -@Component -public class TimerResponseFactory { - - public ResponseEntity> startResponse(TimerStartResponse response){ - return ResponseEntity.status(HttpStatus.CREATED) - .body(BaseResponse.onSuccess(TimerSuccessCode.TIMER_STARTED, response)); - } - - public ResponseEntity> statusResponse( - TimerStatusResponse response, - TimerSuccessCode successCode - ){ - return ResponseEntity.ok() - .body(BaseResponse.onSuccess(successCode, response)); - } -} From 4c24b9637b42a9648bd8b57cf065007ec360e435 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 15:54:35 +0900 Subject: [PATCH 275/383] =?UTF-8?q?refactor:=20TimerResponseFactory=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0,=20Controller=EB=A1=9C=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=20=EC=A1=B0=EB=A6=BD=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/controller/TimerController.java | 13 ++++++---- .../timer/factory/TimerResponseFactory.java | 26 ------------------- 2 files changed, 8 insertions(+), 31 deletions(-) delete mode 100644 src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java 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 index 878a9b62..72ad6c3c 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java +++ b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java @@ -6,12 +6,12 @@ import com.Timo.Timo.domain.timer.dto.response.TimerFinishResponse; import com.Timo.Timo.domain.timer.dto.response.TimerStartResponse; import com.Timo.Timo.domain.timer.exception.TimerSuccessCode; -import com.Timo.Timo.domain.timer.factory.TimerResponseFactory; 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 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.PatchMapping; @@ -28,7 +28,6 @@ public class TimerController implements TimerControllerDocs, TimerCompleteContro TimerStopControllerDocs { private final TimerService timerService; - private final TimerResponseFactory timerResponseFactory; @Override @PostMapping("/todos/{todoId}/timers/start") @@ -40,7 +39,9 @@ public ResponseEntity> startTimer( Long userId = userDetails.getUserId(); TimerStartResponse response = timerService.startTimer(userId, todoId); - return timerResponseFactory.startResponse(response); + return ResponseEntity.status(HttpStatus.CREATED) + .body(BaseResponse.onSuccess(TimerSuccessCode.TIMER_STARTED, response)); + } @Override @@ -52,7 +53,8 @@ public ResponseEntity> completeTimer( Long userId = userDetails.getUserId(); TimerFinishResponse response = timerService.completeTimer(userId, timerId); - return timerResponseFactory.finishResponse(response, TimerSuccessCode.TIMER_COMPLETED); + return ResponseEntity.ok() + .body(BaseResponse.onSuccess(TimerSuccessCode.TIMER_COMPLETED, response)); } @Override @@ -64,6 +66,7 @@ public ResponseEntity> stopTimer( Long userId = userDetails.getUserId(); TimerFinishResponse response = timerService.stopTimer(userId, timerId); - return timerResponseFactory.finishResponse(response, TimerSuccessCode.TIMER_STOPPED); + return ResponseEntity.ok() + .body(BaseResponse.onSuccess(TimerSuccessCode.TIMER_STOPPED, response)); } } diff --git a/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java b/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java deleted file mode 100644 index c5a49257..00000000 --- a/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.Timo.Timo.domain.timer.factory; - -import com.Timo.Timo.domain.timer.dto.response.TimerFinishResponse; -import com.Timo.Timo.domain.timer.dto.response.TimerStartResponse; -import com.Timo.Timo.domain.timer.exception.TimerSuccessCode; -import com.Timo.Timo.global.response.BaseResponse; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; - -@Component -public class TimerResponseFactory { - - public ResponseEntity> startResponse(TimerStartResponse response){ - return ResponseEntity.status(HttpStatus.CREATED) - .body(BaseResponse.onSuccess(TimerSuccessCode.TIMER_STARTED, response)); - } - - public ResponseEntity> finishResponse( - TimerFinishResponse response, - TimerSuccessCode successCode - ) { - return ResponseEntity.ok() - .body(BaseResponse.onSuccess(successCode, response)); - } -} From dda0f083e62af142efd728f44c09765ac5852b77 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 16:00:25 +0900 Subject: [PATCH 276/383] =?UTF-8?q?fix:=20changeStatus=EC=97=90=20?= =?UTF-8?q?=EB=B9=84=EA=B4=80=EC=A0=81=20=EB=9D=BD=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/timer/repository/TimerRecordRepository.java | 8 ++++++++ .../com/Timo/Timo/domain/timer/service/TimerService.java | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) 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 index b7ac0642..018d3a2b 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java @@ -2,11 +2,19 @@ import com.Timo.Timo.domain.timer.entity.TimerRecord; import com.Timo.Timo.domain.timer.enums.TimerStatus; +import io.lettuce.core.dynamic.annotation.Param; +import jakarta.persistence.LockModeType; 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.Query; public interface TimerRecordRepository extends JpaRepository { Optional findByUserIdAndStatusIn(Long userId, List statuses); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select t from TimerRecord t where t.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); } 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 index 5d200b18..9ea0d20c 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -80,7 +80,7 @@ public TimerStartResponse startTimer(Long userId, Long todoId) { @Transactional public TimerStatusResponse changeStatus(Long userId, Long timerId, TimerAction action){ - TimerRecord timerRecord = timerRecordRepository.findById(timerId) + TimerRecord timerRecord = timerRecordRepository.findByIdForUpdate(timerId) .orElseThrow(() -> new CustomException(TimerErrorCode.TIMER_NOT_FOUND)); if (!timerRecord.getUser().getId().equals(userId)){ From 422627376f9f82dfd4eb9f1668cdcc75e84b7f6e Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 16:01:30 +0900 Subject: [PATCH 277/383] =?UTF-8?q?fix:=20finishTimer=EC=97=90=20=EB=B9=84?= =?UTF-8?q?=EA=B4=80=EC=A0=81=20=EB=9D=BD=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/timer/repository/TimerRecordRepository.java | 8 ++++++++ .../com/Timo/Timo/domain/timer/service/TimerService.java | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) 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 index 54b12b69..6b75bc89 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java @@ -2,13 +2,21 @@ import com.Timo.Timo.domain.timer.entity.TimerRecord; import com.Timo.Timo.domain.timer.enums.TimerStatus; +import io.lettuce.core.dynamic.annotation.Param; +import jakarta.persistence.LockModeType; 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.Query; public interface TimerRecordRepository extends JpaRepository { Optional findByUserIdAndStatusIn(Long userId, List statuses); boolean existsByTodo_IdAndStatusIn(Long todoId, List statuses); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select t from TimerRecord t where t.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); } 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 index f7c3ae07..7bf3624f 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -89,7 +89,7 @@ public TimerFinishResponse stopTimer(Long userId, Long timerId) { } private TimerFinishResponse finishTimer(Long userId, Long timerId, TimerStatus targetStatus) { - TimerRecord timerRecord = timerRecordRepository.findById(timerId) + TimerRecord timerRecord = timerRecordRepository.findByIdForUpdate(timerId) .orElseThrow(() -> new CustomException(TimerErrorCode.TIMER_NOT_FOUND)); if (!timerRecord.getUser().getId().equals(userId)) { From 681a439964347d56023acb040ee4a579fba56b20 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 16:10:22 +0900 Subject: [PATCH 278/383] =?UTF-8?q?refactor:=20changeStatus=EC=9D=98=20if/?= =?UTF-8?q?else=EB=A5=BC=20=EB=AA=85=EC=8B=9C=EC=A0=81=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/timer/controller/TimerController.java | 7 ++++--- .../com/Timo/Timo/domain/timer/service/TimerService.java | 4 +++- 2 files changed, 7 insertions(+), 4 deletions(-) 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 index edb21267..83e98d40 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java +++ b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java @@ -55,9 +55,10 @@ public ResponseEntity> changeStatus( Long userId = userDetails.getUserId(); TimerStatusResponse response = timerService.changeStatus(userId, timerId, request.action()); - TimerSuccessCode successCode = request.action() == TimerAction.PAUSE - ? TimerSuccessCode.TIMER_PAUSED - : TimerSuccessCode.TIMER_RESUMED; + TimerSuccessCode successCode = switch (request.action()) { + case PAUSE -> TimerSuccessCode.TIMER_PAUSED; + case RESUME -> TimerSuccessCode.TIMER_RESUMED; + }; return ResponseEntity.ok() .body(BaseResponse.onSuccess(successCode, response)); 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 index 9ea0d20c..92160fec 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -97,7 +97,7 @@ public TimerStatusResponse changeStatus(Long userId, Long timerId, TimerAction a .orElseThrow(() -> new CustomException(TimerErrorCode.TIMER_INVALID_STATUS_TRANSITION)); activeSession.pause(now); instance.pauseTimer(); - } else { + } else if (action == TimerAction.RESUME) { timerRecord.resume(); TimerSession newSession = TimerSession.builder() .timerRecord(timerRecord) @@ -105,6 +105,8 @@ public TimerStatusResponse changeStatus(Long userId, Long timerId, TimerAction a .build(); timerSessionRepository.save(newSession); instance.startTimer(); + } else { + throw new CustomException(TimerErrorCode.TIMER_INVALID_STATUS_TRANSITION); } int elapsedSeconds = calculateElapsedSeconds(timerId, now); From ce477c2662b2cd59eabd4c23af549b3cee8c81e2 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 16:30:47 +0900 Subject: [PATCH 279/383] =?UTF-8?q?fix:=20=EC=9E=98=EB=AA=BB=20=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=A8=20=ED=8C=A8=ED=82=A4=EC=A7=80=20import=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/timer/repository/TimerRecordRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 018d3a2b..5d0a4f67 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java @@ -2,13 +2,13 @@ import com.Timo.Timo.domain.timer.entity.TimerRecord; import com.Timo.Timo.domain.timer.enums.TimerStatus; -import io.lettuce.core.dynamic.annotation.Param; import jakarta.persistence.LockModeType; 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.Query; +import org.springframework.data.repository.query.Param; public interface TimerRecordRepository extends JpaRepository { From a8b18a07a506c877ea097c5ead1c859f06696e58 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 16:31:17 +0900 Subject: [PATCH 280/383] =?UTF-8?q?fix:=20=EC=9E=98=EB=AA=BB=20=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=A8=20=ED=8C=A8=ED=82=A4=EC=A7=80=20import=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/timer/repository/TimerRecordRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 6b75bc89..4723e58d 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java @@ -2,13 +2,13 @@ import com.Timo.Timo.domain.timer.entity.TimerRecord; import com.Timo.Timo.domain.timer.enums.TimerStatus; -import io.lettuce.core.dynamic.annotation.Param; import jakarta.persistence.LockModeType; 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.Query; +import org.springframework.data.repository.query.Param; public interface TimerRecordRepository extends JpaRepository { From a74f634e6cece51ac65366a4d576ab2222c46bd1 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 16:10:23 +0900 Subject: [PATCH 281/383] =?UTF-8?q?feat:=20=ED=86=B5=EA=B3=84=20=EC=9A=94?= =?UTF-8?q?=EC=95=BD=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?StatisticsSummaryDocs=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/StatisticsSummaryDocs.java | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/statistics/docs/StatisticsSummaryDocs.java 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..a7605ca7 --- /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 + ); +} From 1d3bd7c80cb37986899a88aa912265525b598938 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 16:10:55 +0900 Subject: [PATCH 282/383] =?UTF-8?q?feat:=20=ED=86=B5=EA=B3=84=20=EC=9A=94?= =?UTF-8?q?=EC=95=BD=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?StatisticsSummaryService=20=EB=B0=8F=20StatisticsSummary=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/StatisticsController.java | 23 +++++++++++++-- .../repository/StatisticsSummary.java | 10 +++++++ .../statistics/service/StatisticsService.java | 29 +++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsSummary.java 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 index 8342fd3c..b63ef043 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/controller/StatisticsController.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/controller/StatisticsController.java @@ -10,7 +10,9 @@ import org.springframework.web.bind.annotation.RestController; import com.Timo.Timo.domain.statistics.docs.StatisticsCalendarDocs; +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.StatisticsSummaryResponse; import com.Timo.Timo.domain.statistics.exception.StatisticsSuccessCode; import com.Timo.Timo.domain.statistics.service.StatisticsService; import com.Timo.Timo.domain.statistics.support.StatisticsDateParser; @@ -24,7 +26,7 @@ @RequestMapping("/api/v1/statistics") @RequiredArgsConstructor @Tag(name = "Statistics", description = "통계 API") -public class StatisticsController implements StatisticsCalendarDocs { +public class StatisticsController implements StatisticsCalendarDocs, StatisticsSummaryDocs { private final StatisticsService statisticsService; private final StatisticsDateParser statisticsDateParser; @@ -45,4 +47,21 @@ public ResponseEntity> getCalendar( BaseResponse.onSuccess(StatisticsSuccessCode.CALENDAR_RETRIEVED, response) ); } -} \ No newline at end of file + + @Override + @GetMapping("/summary") + public ResponseEntity> getSummary( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestParam(required = false) String yearMonth + ) { + YearMonth parsedYearMonth = statisticsDateParser.parseYearMonth(yearMonth); + StatisticsSummaryResponse response = statisticsService.getSummary( + userDetails.getUserId(), + parsedYearMonth + ); + + return ResponseEntity.ok( + BaseResponse.onSuccess(StatisticsSuccessCode.SUMMARY_RETRIEVED, response) + ); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsSummary.java b/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsSummary.java new file mode 100644 index 00000000..9fa93f46 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsSummary.java @@ -0,0 +1,10 @@ +package com.Timo.Timo.domain.statistics.repository; + +public record StatisticsSummary( + Long totalRecordSeconds, + Integer timerRecordedDayCount, + Integer activeDayCount, + Integer completedTodoCount, + Integer totalTodoCount +) { +} 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 index 2f0e17e2..4ce3f71d 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/service/StatisticsService.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/service/StatisticsService.java @@ -15,6 +15,9 @@ 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.StatisticsSummaryResponse; +import com.Timo.Timo.domain.statistics.repository.StatisticsQueryRepository; +import com.Timo.Timo.domain.statistics.repository.StatisticsSummary; import com.Timo.Timo.domain.todo.repository.TodoDailyCompletionStats; import com.Timo.Timo.domain.todo.repository.TodoRepository; @@ -26,8 +29,10 @@ 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 StatisticsQueryRepository statisticsQueryRepository; public StatisticsCalendarResponse getCalendar(Long userId, YearMonth yearMonth) { LocalDate today = LocalDate.now(ZoneOffset.UTC); @@ -55,6 +60,30 @@ public StatisticsCalendarResponse getCalendar(Long userId, YearMonth yearMonth) ); } + public StatisticsSummaryResponse getSummary(Long userId, YearMonth yearMonth) { + LocalDate startDate = yearMonth.atDay(1); + LocalDate nextMonthStartDate = yearMonth.plusMonths(1).atDay(1); + + StatisticsSummary summary = statisticsQueryRepository.findSummary( + userId, + startDate.atStartOfDay(), + nextMonthStartDate.atStartOfDay() + ); + + long totalRecordSeconds = summary.totalRecordSeconds(); + long averageRecordedMinutes = summary.timerRecordedDayCount() == 0 + ? 0L + : totalRecordSeconds / summary.timerRecordedDayCount() / SECONDS_PER_MINUTE; + + return new StatisticsSummaryResponse( + totalRecordSeconds / SECONDS_PER_MINUTE, + summary.activeDayCount(), + averageRecordedMinutes, + summary.completedTodoCount(), + summary.totalTodoCount() + ); + } + private int calculateCompletionRate(TodoDailyCompletionStats stats) { if (stats == null || stats.getTotalCount() == null || stats.getTotalCount() == 0) { return 0; From 33c3c947d5378099531fb586d6349021da9d59e2 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 16:11:04 +0900 Subject: [PATCH 283/383] =?UTF-8?q?feat:=20=ED=86=B5=EA=B3=84=20=EC=9A=94?= =?UTF-8?q?=EC=95=BD=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?StatisticsQueryRepository=20=EB=B0=8F=20StatisticsSummaryRespon?= =?UTF-8?q?se=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../response/StatisticsSummaryResponse.java | 21 +++ .../repository/StatisticsQueryRepository.java | 123 ++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/statistics/dto/response/StatisticsSummaryResponse.java create mode 100644 src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java 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..ecdc65c8 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/statistics/dto/response/StatisticsSummaryResponse.java @@ -0,0 +1,21 @@ +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 +) { +} diff --git a/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java b/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java new file mode 100644 index 00000000..6c34906b --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java @@ -0,0 +1,123 @@ +package com.Timo.Timo.domain.statistics.repository; + +import java.time.LocalDateTime; + +import org.springframework.stereotype.Repository; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class StatisticsQueryRepository { + + private final EntityManager entityManager; + + public StatisticsSummary findSummary( + Long userId, + LocalDateTime fromInclusive, + LocalDateTime toExclusive + ) { + return new StatisticsSummary( + sumTimerRecordSeconds(userId, fromInclusive, toExclusive), + countTimerRecordedDays(userId, fromInclusive, toExclusive), + countActiveTodoCreatedDays(userId, fromInclusive, toExclusive), + countCompletedCreatedTodos(userId, fromInclusive, toExclusive), + countCreatedTodos(userId, fromInclusive, toExclusive) + ); + } + + private Long sumTimerRecordSeconds(Long userId, LocalDateTime fromInclusive, LocalDateTime toExclusive) { + Query query = entityManager.createNativeQuery(""" + select coalesce(sum(tr.actual_seconds), 0) + from timer_records tr + where tr.user_id = :userId + and tr.actual_seconds is not null + and coalesce(tr.ended_at, tr.started_at) >= :fromInclusive + and coalesce(tr.ended_at, tr.started_at) < :toExclusive + """) + .setParameter("userId", userId) + .setParameter("fromInclusive", fromInclusive) + .setParameter("toExclusive", toExclusive); + + return toLong(query.getSingleResult()); + } + + private Integer countTimerRecordedDays(Long userId, LocalDateTime fromInclusive, LocalDateTime toExclusive) { + Query query = entityManager.createNativeQuery(""" + select count(distinct date(coalesce(tr.ended_at, tr.started_at))) + from timer_records tr + where tr.user_id = :userId + and tr.actual_seconds is not null + and coalesce(tr.ended_at, tr.started_at) >= :fromInclusive + and coalesce(tr.ended_at, tr.started_at) < :toExclusive + """) + .setParameter("userId", userId) + .setParameter("fromInclusive", fromInclusive) + .setParameter("toExclusive", toExclusive); + + return toInteger(query.getSingleResult()); + } + + private Integer countActiveTodoCreatedDays(Long userId, LocalDateTime fromInclusive, LocalDateTime toExclusive) { + Query query = entityManager.createNativeQuery(""" + select count(distinct date(t.created_at)) + from todos t + where t.user_id = :userId + and t.created_at >= :fromInclusive + and t.created_at < :toExclusive + """) + .setParameter("userId", userId) + .setParameter("fromInclusive", fromInclusive) + .setParameter("toExclusive", toExclusive); + + return toInteger(query.getSingleResult()); + } + + private Integer countCompletedCreatedTodos(Long userId, LocalDateTime fromInclusive, LocalDateTime toExclusive) { + Query query = entityManager.createNativeQuery(""" + select count(distinct t.id) + from todos t + join todo_instances ti on ti.todo_id = t.id + where t.user_id = :userId + and t.created_at >= :fromInclusive + and t.created_at < :toExclusive + and ti.completed = true + """) + .setParameter("userId", userId) + .setParameter("fromInclusive", fromInclusive) + .setParameter("toExclusive", toExclusive); + + return toInteger(query.getSingleResult()); + } + + private Integer countCreatedTodos(Long userId, LocalDateTime fromInclusive, LocalDateTime toExclusive) { + Query query = entityManager.createNativeQuery(""" + select count(t.id) + from todos t + where t.user_id = :userId + and t.created_at >= :fromInclusive + and t.created_at < :toExclusive + """) + .setParameter("userId", userId) + .setParameter("fromInclusive", fromInclusive) + .setParameter("toExclusive", toExclusive); + + return toInteger(query.getSingleResult()); + } + + private Long toLong(Object value) { + if (value == null) { + return 0L; + } + return ((Number)value).longValue(); + } + + private Integer toInteger(Object value) { + if (value == null) { + return 0; + } + return ((Number)value).intValue(); + } +} From 849f1f551e7acf4cf572c2a299746c5dea4df29d Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 16:11:18 +0900 Subject: [PATCH 284/383] =?UTF-8?q?feat:=20=EC=9B=94=EB=B3=84=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=20=EC=9A=94=EC=95=BD=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20SUMMARY=5FRETRIEVED=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/statistics/exception/StatisticsSuccessCode.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index a6df17b2..b0563565 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/exception/StatisticsSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/exception/StatisticsSuccessCode.java @@ -11,7 +11,8 @@ @RequiredArgsConstructor public enum StatisticsSuccessCode implements BaseSuccessCode { - CALENDAR_RETRIEVED(HttpStatus.OK, "통계 캘린더를 조회했습니다."); + CALENDAR_RETRIEVED(HttpStatus.OK, "통계 캘린더를 조회했습니다."), + SUMMARY_RETRIEVED(HttpStatus.OK, "월별 통계 요약을 조회했습니다."); private final HttpStatus httpStatus; private final String message; From d3228f131e922dcbbf4f8b43de511bd21e99bd70 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 00:28:25 +0900 Subject: [PATCH 285/383] =?UTF-8?q?feat:=20=EC=9B=94=EB=B3=84=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=20=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EC=A7=81=EC=9D=84=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EA=B8=B0=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/StatisticsQueryRepository.java | 123 ------------------ .../repository/StatisticsSummary.java | 10 -- .../statistics/service/StatisticsService.java | 34 +++-- .../repository/TimerMonthlyRecordStats.java | 6 + .../repository/TimerRecordRepository.java | 19 +++ .../repository/TodoMonthlySummaryStats.java | 7 + .../todo/repository/TodoRepository.java | 18 +++ 7 files changed, 74 insertions(+), 143 deletions(-) delete mode 100644 src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java delete mode 100644 src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsSummary.java create mode 100644 src/main/java/com/Timo/Timo/domain/timer/repository/TimerMonthlyRecordStats.java create mode 100644 src/main/java/com/Timo/Timo/domain/todo/repository/TodoMonthlySummaryStats.java diff --git a/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java b/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java deleted file mode 100644 index 6c34906b..00000000 --- a/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.Timo.Timo.domain.statistics.repository; - -import java.time.LocalDateTime; - -import org.springframework.stereotype.Repository; - -import jakarta.persistence.EntityManager; -import jakarta.persistence.Query; -import lombok.RequiredArgsConstructor; - -@Repository -@RequiredArgsConstructor -public class StatisticsQueryRepository { - - private final EntityManager entityManager; - - public StatisticsSummary findSummary( - Long userId, - LocalDateTime fromInclusive, - LocalDateTime toExclusive - ) { - return new StatisticsSummary( - sumTimerRecordSeconds(userId, fromInclusive, toExclusive), - countTimerRecordedDays(userId, fromInclusive, toExclusive), - countActiveTodoCreatedDays(userId, fromInclusive, toExclusive), - countCompletedCreatedTodos(userId, fromInclusive, toExclusive), - countCreatedTodos(userId, fromInclusive, toExclusive) - ); - } - - private Long sumTimerRecordSeconds(Long userId, LocalDateTime fromInclusive, LocalDateTime toExclusive) { - Query query = entityManager.createNativeQuery(""" - select coalesce(sum(tr.actual_seconds), 0) - from timer_records tr - where tr.user_id = :userId - and tr.actual_seconds is not null - and coalesce(tr.ended_at, tr.started_at) >= :fromInclusive - and coalesce(tr.ended_at, tr.started_at) < :toExclusive - """) - .setParameter("userId", userId) - .setParameter("fromInclusive", fromInclusive) - .setParameter("toExclusive", toExclusive); - - return toLong(query.getSingleResult()); - } - - private Integer countTimerRecordedDays(Long userId, LocalDateTime fromInclusive, LocalDateTime toExclusive) { - Query query = entityManager.createNativeQuery(""" - select count(distinct date(coalesce(tr.ended_at, tr.started_at))) - from timer_records tr - where tr.user_id = :userId - and tr.actual_seconds is not null - and coalesce(tr.ended_at, tr.started_at) >= :fromInclusive - and coalesce(tr.ended_at, tr.started_at) < :toExclusive - """) - .setParameter("userId", userId) - .setParameter("fromInclusive", fromInclusive) - .setParameter("toExclusive", toExclusive); - - return toInteger(query.getSingleResult()); - } - - private Integer countActiveTodoCreatedDays(Long userId, LocalDateTime fromInclusive, LocalDateTime toExclusive) { - Query query = entityManager.createNativeQuery(""" - select count(distinct date(t.created_at)) - from todos t - where t.user_id = :userId - and t.created_at >= :fromInclusive - and t.created_at < :toExclusive - """) - .setParameter("userId", userId) - .setParameter("fromInclusive", fromInclusive) - .setParameter("toExclusive", toExclusive); - - return toInteger(query.getSingleResult()); - } - - private Integer countCompletedCreatedTodos(Long userId, LocalDateTime fromInclusive, LocalDateTime toExclusive) { - Query query = entityManager.createNativeQuery(""" - select count(distinct t.id) - from todos t - join todo_instances ti on ti.todo_id = t.id - where t.user_id = :userId - and t.created_at >= :fromInclusive - and t.created_at < :toExclusive - and ti.completed = true - """) - .setParameter("userId", userId) - .setParameter("fromInclusive", fromInclusive) - .setParameter("toExclusive", toExclusive); - - return toInteger(query.getSingleResult()); - } - - private Integer countCreatedTodos(Long userId, LocalDateTime fromInclusive, LocalDateTime toExclusive) { - Query query = entityManager.createNativeQuery(""" - select count(t.id) - from todos t - where t.user_id = :userId - and t.created_at >= :fromInclusive - and t.created_at < :toExclusive - """) - .setParameter("userId", userId) - .setParameter("fromInclusive", fromInclusive) - .setParameter("toExclusive", toExclusive); - - return toInteger(query.getSingleResult()); - } - - private Long toLong(Object value) { - if (value == null) { - return 0L; - } - return ((Number)value).longValue(); - } - - private Integer toInteger(Object value) { - if (value == null) { - return 0; - } - return ((Number)value).intValue(); - } -} diff --git a/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsSummary.java b/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsSummary.java deleted file mode 100644 index 9fa93f46..00000000 --- a/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsSummary.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.Timo.Timo.domain.statistics.repository; - -public record StatisticsSummary( - Long totalRecordSeconds, - Integer timerRecordedDayCount, - Integer activeDayCount, - Integer completedTodoCount, - Integer totalTodoCount -) { -} 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 index 4ce3f71d..735a1fbc 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/service/StatisticsService.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/service/StatisticsService.java @@ -16,9 +16,10 @@ 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.StatisticsSummaryResponse; -import com.Timo.Timo.domain.statistics.repository.StatisticsQueryRepository; -import com.Timo.Timo.domain.statistics.repository.StatisticsSummary; +import com.Timo.Timo.domain.timer.repository.TimerMonthlyRecordStats; +import com.Timo.Timo.domain.timer.repository.TimerRecordRepository; import com.Timo.Timo.domain.todo.repository.TodoDailyCompletionStats; +import com.Timo.Timo.domain.todo.repository.TodoMonthlySummaryStats; import com.Timo.Timo.domain.todo.repository.TodoRepository; import lombok.RequiredArgsConstructor; @@ -32,7 +33,7 @@ public class StatisticsService { private static final int SECONDS_PER_MINUTE = 60; private final TodoRepository todoRepository; - private final StatisticsQueryRepository statisticsQueryRepository; + private final TimerRecordRepository timerRecordRepository; public StatisticsCalendarResponse getCalendar(Long userId, YearMonth yearMonth) { LocalDate today = LocalDate.now(ZoneOffset.UTC); @@ -64,23 +65,29 @@ public StatisticsSummaryResponse getSummary(Long userId, YearMonth yearMonth) { LocalDate startDate = yearMonth.atDay(1); LocalDate nextMonthStartDate = yearMonth.plusMonths(1).atDay(1); - StatisticsSummary summary = statisticsQueryRepository.findSummary( + TimerMonthlyRecordStats timerStats = timerRecordRepository.findMonthlyRecordStats( + userId, + startDate.atStartOfDay(), + nextMonthStartDate.atStartOfDay() + ); + TodoMonthlySummaryStats todoStats = todoRepository.findMonthlySummaryStats( userId, startDate.atStartOfDay(), nextMonthStartDate.atStartOfDay() ); - long totalRecordSeconds = summary.totalRecordSeconds(); - long averageRecordedMinutes = summary.timerRecordedDayCount() == 0 + long totalRecordSeconds = timerStats.getTotalRecordSeconds(); + long timerRecordedDayCount = timerStats.getTimerRecordedDayCount(); + long averageRecordedMinutes = timerRecordedDayCount == 0 ? 0L - : totalRecordSeconds / summary.timerRecordedDayCount() / SECONDS_PER_MINUTE; + : totalRecordSeconds / timerRecordedDayCount / SECONDS_PER_MINUTE; return new StatisticsSummaryResponse( totalRecordSeconds / SECONDS_PER_MINUTE, - summary.activeDayCount(), + toInteger(todoStats.getActiveDayCount()), averageRecordedMinutes, - summary.completedTodoCount(), - summary.totalTodoCount() + toInteger(todoStats.getCompletedTodoCount()), + toInteger(todoStats.getTotalTodoCount()) ); } @@ -92,4 +99,11 @@ private int calculateCompletionRate(TodoDailyCompletionStats stats) { long completedCount = stats.getCompletedCount() == null ? 0 : stats.getCompletedCount(); return (int)Math.round(completedCount * 100.0 / stats.getTotalCount()); } + + private int toInteger(Long value) { + if (value == null) { + return 0; + } + return value.intValue(); + } } 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 index 54b12b69..fdc86d7a 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java @@ -2,13 +2,32 @@ import com.Timo.Timo.domain.timer.entity.TimerRecord; import com.Timo.Timo.domain.timer.enums.TimerStatus; +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; public interface TimerRecordRepository extends JpaRepository { Optional findByUserIdAndStatusIn(Long userId, List statuses); boolean existsByTodo_IdAndStatusIn(Long todoId, List statuses); + + @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 + ); } 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 index a304e4a5..2b346711 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java @@ -1,6 +1,7 @@ package com.Timo.Timo.domain.todo.repository; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -48,4 +49,21 @@ List findDailyCompletionStats( @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 + ); } From fffc8f238a967efe01c9877a1aa59c719f58ed4b Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 01:17:06 +0900 Subject: [PATCH 286/383] =?UTF-8?q?feat:=20=ED=86=B5=EA=B3=84=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EC=97=90=EC=84=9C=20yearMonth=20=ED=8C=8C=EC=8B=B1=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=9D=84=20StatisticsDateParser=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../statistics/controller/StatisticsController.java | 10 ++-------- .../domain/statistics/service/StatisticsService.java | 8 ++++++-- 2 files changed, 8 insertions(+), 10 deletions(-) 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 index b63ef043..cbaf2abc 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/controller/StatisticsController.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/controller/StatisticsController.java @@ -1,7 +1,5 @@ package com.Timo.Timo.domain.statistics.controller; -import java.time.YearMonth; - import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; @@ -15,7 +13,6 @@ 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.domain.statistics.support.StatisticsDateParser; import com.Timo.Timo.global.auth.principal.CustomUserDetails; import com.Timo.Timo.global.response.BaseResponse; @@ -29,7 +26,6 @@ public class StatisticsController implements StatisticsCalendarDocs, StatisticsSummaryDocs { private final StatisticsService statisticsService; - private final StatisticsDateParser statisticsDateParser; @Override @GetMapping("/calendar") @@ -37,10 +33,9 @@ public ResponseEntity> getCalendar( @AuthenticationPrincipal CustomUserDetails userDetails, @RequestParam(required = false) String yearMonth ) { - YearMonth parsedYearMonth = statisticsDateParser.parseYearMonth(yearMonth); StatisticsCalendarResponse response = statisticsService.getCalendar( userDetails.getUserId(), - parsedYearMonth + yearMonth ); return ResponseEntity.ok( @@ -54,10 +49,9 @@ public ResponseEntity> getSummary( @AuthenticationPrincipal CustomUserDetails userDetails, @RequestParam(required = false) String yearMonth ) { - YearMonth parsedYearMonth = statisticsDateParser.parseYearMonth(yearMonth); StatisticsSummaryResponse response = statisticsService.getSummary( userDetails.getUserId(), - parsedYearMonth + yearMonth ); return ResponseEntity.ok( 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 index 735a1fbc..4001392a 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/service/StatisticsService.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/service/StatisticsService.java @@ -16,6 +16,7 @@ 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.StatisticsSummaryResponse; +import com.Timo.Timo.domain.statistics.support.StatisticsDateParser; import com.Timo.Timo.domain.timer.repository.TimerMonthlyRecordStats; import com.Timo.Timo.domain.timer.repository.TimerRecordRepository; import com.Timo.Timo.domain.todo.repository.TodoDailyCompletionStats; @@ -34,8 +35,10 @@ public class StatisticsService { private final TodoRepository todoRepository; private final TimerRecordRepository timerRecordRepository; + private final StatisticsDateParser statisticsDateParser; - public StatisticsCalendarResponse getCalendar(Long userId, YearMonth yearMonth) { + public StatisticsCalendarResponse getCalendar(Long userId, String yearMonthValue) { + YearMonth yearMonth = statisticsDateParser.parseYearMonth(yearMonthValue); LocalDate today = LocalDate.now(ZoneOffset.UTC); LocalDate startDate = yearMonth.atDay(1); LocalDate endDate = yearMonth.atEndOfMonth(); @@ -61,7 +64,8 @@ public StatisticsCalendarResponse getCalendar(Long userId, YearMonth yearMonth) ); } - public StatisticsSummaryResponse getSummary(Long userId, YearMonth yearMonth) { + public StatisticsSummaryResponse getSummary(Long userId, String yearMonthValue) { + YearMonth yearMonth = statisticsDateParser.parseYearMonth(yearMonthValue); LocalDate startDate = yearMonth.atDay(1); LocalDate nextMonthStartDate = yearMonth.plusMonths(1).atDay(1); From 6c2681155540c14f64d9828be814d447eb6f0a45 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:46:21 +0900 Subject: [PATCH 287/383] =?UTF-8?q?refactor(todo):=20=EC=A1=B4=EC=9E=AC?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=ED=95=98=EC=9C=84=20?= =?UTF-8?q?=ED=83=9C=EC=8A=A4=ED=81=AC=EC=97=90=20=EB=8C=80=ED=95=9C=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java | 2 +- src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java | 2 +- .../java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) 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 index 5e1969b3..78b7819b 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java @@ -174,7 +174,7 @@ ResponseEntity> createTodo( ), @ApiResponse( responseCode = "404", - description = "존재하지 않는 TODO이거나 존재하지 않는 태그 ID를 전달한 경우", + description = "존재하지 않는 TODO이거나, 존재하지 않는 태그 ID 또는 하위 태스크 ID를 전달한 경우", content = @Content( mediaType = "application/json", schema = @Schema(implementation = ErrorDto.class) 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 index 3d0970fe..e6e0ce18 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java +++ b/src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java @@ -219,7 +219,7 @@ public void replaceSubtasks(List edits) { if (edit.subtaskId() != null) { Subtask existing = existingById.get(edit.subtaskId()); if (existing == null) { - throw new CustomException(TodoErrorCode.INVALID_REQUEST); + throw new CustomException(TodoErrorCode.SUBTASK_NOT_FOUND); } existing.update(edit.content(), edit.completed(), sortOrder); retained.add(existing); 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 index b84b642e..a4f13590 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java @@ -15,6 +15,7 @@ public enum TodoErrorCode implements BaseErrorCode { INVALID_TITLE(HttpStatus.BAD_REQUEST, "TODO_400", "투두명은 한국어 20자/영어 30자를 초과할 수 없습니다."), NO_UPDATE_FIELDS(HttpStatus.BAD_REQUEST, "COMMON_400", "수정할 필드가 없습니다."), TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_404", "존재하지 않는 투두입니다"), + SUBTASK_NOT_FOUND(HttpStatus.NOT_FOUND, "SUBTASK_404", "존재하지 않는 하위 태스크입니다."), MAX_COUNT_EXCEEDED(HttpStatus.CONFLICT, "TODO_409", "해당 날짜의 투두가 최대 개수(20개)를 초과했습니다."), TIMER_RUNNING(HttpStatus.CONFLICT, "TODO_409", "타이머가 실행 중인 TODO는 삭제할 수 없습니다. 타이머를 먼저 종료해주세요."), ; From 55436b29b8fe9874d020855b7a43a53c4effcf0f Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:53:17 +0900 Subject: [PATCH 288/383] =?UTF-8?q?fix(todo):=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=8B=9C=EC=97=90=EB=8F=84=20=EB=9D=BD=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/domain/todo/service/TodoService.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 index b005a077..f643ef31 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java @@ -133,8 +133,12 @@ private void applyScheduleChange(Todo todo, TodoUpdateRequest request) { 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(todo.getUser().getId(), todo.getId(), todoDates); + todoCapacityChecker.assertCapacity(userId, todo.getId(), todoDates); LocalDate endDate = resolveEndDate(startDate, repeatType); todo.changeSchedule(startDate, endDate, repeatType, repeatWeekdays, repeatDayOfMonth); From 21ab4e83c598eb9cea9b8a30a45083fecfe5ca2e Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:06:17 +0900 Subject: [PATCH 289/383] =?UTF-8?q?fix(todo):=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EC=8B=9C=20=ED=83=80=EC=9D=B4=EB=A8=B8=20=ED=99=9C=EC=84=B1?= =?UTF-8?q?=ED=99=94=20=EC=97=AC=EB=B6=80=EC=97=90=20=EB=94=B0=EB=9D=BC=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EA=B0=80=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/todo/docs/TodoControllerDocs.java | 8 ++++++++ .../Timo/domain/todo/service/TodoService.java | 15 ++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) 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 index 78b7819b..12ebaf4a 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java @@ -180,6 +180,14 @@ ResponseEntity> createTodo( 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 = "서버 내부 오류", 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 index f643ef31..39c5ea0b 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java @@ -83,6 +83,12 @@ public void updateTodo(Long userId, Long todoId, TodoUpdateRequest request) { 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( @@ -115,12 +121,15 @@ public void deleteTodo(Long userId, Long todoId) { todoRepository.delete(todo); } - private void applyScheduleChange(Todo todo, TodoUpdateRequest request) { - boolean scheduleChanged = request.date() != null + private boolean isScheduleChanged(TodoUpdateRequest request) { + return request.date() != null || request.repeatType() != null || request.repeatWeekdays() != null || request.repeatDayOfMonth() != null; - if (!scheduleChanged) { + } + + private void applyScheduleChange(Todo todo, TodoUpdateRequest request) { + if (!isScheduleChanged(request)) { return; } From 040355cdd84d8e553fa139a3d8a90387d4b12283 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 18:11:04 +0900 Subject: [PATCH 290/383] =?UTF-8?q?fix:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C/=EC=A2=85=EB=A3=8C=20=EC=8B=9C=20=ED=99=9C?= =?UTF-8?q?=EC=84=B1=20TimerSession=20=EB=8B=AB=EA=B8=B0=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/domain/timer/service/TimerService.java | 3 +++ 1 file changed, 3 insertions(+) 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 index 7bf3624f..76d7c57c 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -99,6 +99,9 @@ private TimerFinishResponse finishTimer(Long userId, Long timerId, TimerStatus t LocalDateTime now = LocalDateTime.now(); int actualSeconds = calculateElapsedSeconds(timerId, now); + timerSessionRepository.findByTimerRecordIdAndPausedAtIsNull(timerId) + .ifPresent(activeSession -> activeSession.pause(now)); + timerRecord.finish(targetStatus, now, actualSeconds, null); TodoInstance instance = getOrCreateInstance(timerRecord.getTodo(), timerRecord.getStartedAt().toLocalDate()); From 190cb5b46717fc792bad0d3d0767d801d6526117 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:14:27 +0900 Subject: [PATCH 291/383] =?UTF-8?q?fix(todo):=20=EB=B0=98=EB=B3=B5=20?= =?UTF-8?q?=EC=9C=A0=ED=98=95=20=EB=B3=80=EA=B2=BD=EC=8B=9C=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B4=88=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/domain/todo/entity/Todo.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 index e6e0ce18..9cf8dafd 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java +++ b/src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java @@ -118,8 +118,9 @@ private Todo( this.startDate = startDate; this.endDate = endDate; this.repeatType = repeatType; - this.repeatWeekdays = repeatWeekdays != null ? new ArrayList<>(repeatWeekdays) : new ArrayList<>(); - this.repeatDayOfMonth = repeatDayOfMonth; + 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; @@ -202,8 +203,9 @@ public void changeSchedule( this.startDate = startDate; this.endDate = endDate; this.repeatType = repeatType; - this.repeatWeekdays = repeatWeekdays != null ? new ArrayList<>(repeatWeekdays) : new ArrayList<>(); - this.repeatDayOfMonth = repeatDayOfMonth; + this.repeatWeekdays = repeatType == RepeatType.WEEKLY && repeatWeekdays != null + ? new ArrayList<>(repeatWeekdays) : new ArrayList<>(); + this.repeatDayOfMonth = repeatType == RepeatType.MONTHLY ? repeatDayOfMonth : null; } public void replaceSubtasks(List edits) { From 942d487cb59ee74c8e9e6abef305b431a1e05992 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 18:32:58 +0900 Subject: [PATCH 292/383] =?UTF-8?q?feat:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=97=B0=EC=9E=A5=EC=9A=A9=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/dto/request/TimerExtendRequest.java | 12 ++++++++++++ .../dto/response/TimerExtendResponse.java | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/timer/dto/request/TimerExtendRequest.java create mode 100644 src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerExtendResponse.java 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..6af6ef9d --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/dto/request/TimerExtendRequest.java @@ -0,0 +1,12 @@ +package com.Timo.Timo.domain.timer.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +public record TimerExtendRequest ( + @NotNull(message = "연장 시간은 필수입니다.") + @Min(value = 1, message = "연장 시간은 1분 이상이어야 합니다.") + @Schema(description = "연장할 시간 (분), 1 이상", example = "10") + Integer extendMinutes +) {} 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 + ); + } +} From 41d9ca630ef96f1363eacbe2ca45513ff3b00c3e Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 18:39:40 +0900 Subject: [PATCH 293/383] =?UTF-8?q?feat:=20TimerRecord=EC=97=90=20?= =?UTF-8?q?=EC=97=B0=EC=9E=A5=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/timer/entity/TimerRecord.java | 7 +++++++ .../Timo/Timo/domain/timer/exception/TimerErrorCode.java | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) 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 index c61f861c..1fb69bf9 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java +++ b/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java @@ -95,6 +95,13 @@ public void resume() { this.status = TimerStatus.RUNNING; } + 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; } 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 index 69549ed3..87eb7e89 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/exception/TimerErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/timer/exception/TimerErrorCode.java @@ -11,7 +11,8 @@ 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_INVALID_STATUS_TRANSITION(HttpStatus.CONFLICT, "TIMER_409", "요청을 처리할 수 없는 타이머 상태입니다."), + TIMER_ALREADY_FINISHED(HttpStatus.CONFLICT, "TIMER_409", "이미 종료된 타이머입니다."); private final HttpStatus httpStatus; private final String code; From ba7fd2c0a2936bdf591ce6747c19de9a08cf8d99 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 18:46:40 +0900 Subject: [PATCH 294/383] =?UTF-8?q?feat:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=97=B0=EC=9E=A5=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/exception/TimerSuccessCode.java | 3 ++- .../domain/timer/service/TimerService.java | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) 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 index 4ee0d0f8..71157b4d 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java @@ -11,7 +11,8 @@ public enum TimerSuccessCode implements BaseSuccessCode { TIMER_PAUSED(HttpStatus.OK, "TIMER_200", "타이머가 일시정지되었습니다."), TIMER_RESUMED(HttpStatus.OK, "TIMER_200", "타이머가 재개되었습니다."), - TIMER_STARTED(HttpStatus.CREATED, "TIMER_201", "타이머가 시작되었습니다."); + TIMER_STARTED(HttpStatus.CREATED, "TIMER_201", "타이머가 시작되었습니다."), + TIMER_EXTENDED(HttpStatus.OK, "TIMER_200_5", "타이머가 연장되었습니다."); private final HttpStatus httpStatus; private final String code; 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 index d5a58028..80eda840 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -1,5 +1,6 @@ package com.Timo.Timo.domain.timer.service; +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; @@ -114,6 +115,29 @@ public TimerStatusResponse changeStatus(Long userId, Long timerId, TimerAction a 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); + + } + private int calculateElapsedSeconds(Long timerRecordId, LocalDateTime now){ List sessions = timerSessionRepository.findByTimerRecordId(timerRecordId); long totalSeconds = 0; From eb4c77d362d2fa618bf0c8cd7b87f40380de848b Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 18:59:33 +0900 Subject: [PATCH 295/383] =?UTF-8?q?feat:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=97=B0=EC=9E=A5=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/controller/TimerController.java | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) 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 index 83e98d40..786b9819 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java +++ b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java @@ -1,11 +1,13 @@ package com.Timo.Timo.domain.timer.controller; +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.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.TimerStartResponse; import com.Timo.Timo.domain.timer.dto.response.TimerStatusResponse; -import com.Timo.Timo.domain.timer.enums.TimerAction; import com.Timo.Timo.domain.timer.exception.TimerSuccessCode; import com.Timo.Timo.domain.timer.service.TimerService; import com.Timo.Timo.global.auth.principal.CustomUserDetails; @@ -27,7 +29,8 @@ @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor -public class TimerController implements TimerStartControllerDocs, TimerStatusControllerDocs { +public class TimerController implements TimerStartControllerDocs, TimerStatusControllerDocs, + TimerExtendControllerDocs { private final TimerService timerService; @@ -48,10 +51,10 @@ public ResponseEntity> startTimer( @Override @PatchMapping("timers/{timerId}/status") public ResponseEntity> changeStatus( - @PathVariable Long timerId, - @Valid @RequestBody TimerActionRequest request, - @AuthenticationPrincipal CustomUserDetails userDetails - ){ + @PathVariable Long timerId, + @Valid @RequestBody TimerActionRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { Long userId = userDetails.getUserId(); TimerStatusResponse response = timerService.changeStatus(userId, timerId, request.action()); @@ -63,4 +66,18 @@ public ResponseEntity> changeStatus( 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)); + } } From abe1d98ae727e9ef2db0ae57d64d52aae60ad670 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 19:01:08 +0900 Subject: [PATCH 296/383] =?UTF-8?q?docs:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=97=B0=EC=9E=A5=20Swagger=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/docs/TimerExtendControllerDocs.java | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/timer/docs/TimerExtendControllerDocs.java 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..4e86fe5a --- /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 + ); +} From e9498ab122cee44a83e489044299ad380708b4a0 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 19:08:16 +0900 Subject: [PATCH 297/383] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=97=AC=EB=B0=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/timer/controller/TimerController.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index 786b9819..f5f2455b 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java +++ b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java @@ -30,7 +30,7 @@ @RequestMapping("/api/v1") @RequiredArgsConstructor public class TimerController implements TimerStartControllerDocs, TimerStatusControllerDocs, - TimerExtendControllerDocs { + TimerExtendControllerDocs { private final TimerService timerService; @@ -75,7 +75,8 @@ public ResponseEntity> extendTimer( @AuthenticationPrincipal CustomUserDetails userDetails ) { Long userId = userDetails.getUserId(); - TimerExtendResponse response = timerService.extendTimer(userId, timerId, request.extendMinutes()); + TimerExtendResponse response = timerService.extendTimer(userId, timerId, + request.extendMinutes()); return ResponseEntity.ok() .body(BaseResponse.onSuccess(TimerSuccessCode.TIMER_EXTENDED, response)); From 20a98ce1032b43460aa0b33b802ccf92915bb076 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 19:26:16 +0900 Subject: [PATCH 298/383] =?UTF-8?q?docs:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EB=82=B4=EC=9A=A9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/timer/docs/TimerExtendControllerDocs.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index 4e86fe5a..70765436 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerExtendControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerExtendControllerDocs.java @@ -21,9 +21,9 @@ public interface TimerExtendControllerDocs { @Operation( summary = "타이머 연장", description = """ - 사용자가 입력한 연장 시간을 현재 타이머에 반영합니다.
- 연장 시간(분)을 초로 변환하여 extended_seconds에 누적 - RUNNING, PAUSED 상태 모두에서 호출 가능하며, 타이머 상태(status)는 변경되지 않습니다. + 사용자가 입력한 연장 시간을 현재 타이머에 반영합니다. + 연장 시간(분)을 초로 변환하여 extended_seconds에 누적 + RUNNING, PAUSED 상태 모두에서 호출 가능하며, 타이머 상태(status)는 변경되지 않습니다. """ ) @ApiResponses({ From 0479f8aaf555d7ab9fccd4993ac3c4eb44a8f96d Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 00:30:05 +0900 Subject: [PATCH 299/383] =?UTF-8?q?feat:=20TimerAction=20enum=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/domain/timer/enums/TimerAction.java | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/timer/enums/TimerAction.java 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 +} From a4b3a566ce2c2bd3c7bf56f71714c8dcafb1766a Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 00:35:12 +0900 Subject: [PATCH 300/383] =?UTF-8?q?feat:=20TimerSessionRepository=EC=97=90?= =?UTF-8?q?=20findByTimerRecordId=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/timer/repository/TimerSessionRepository.java | 2 ++ 1 file changed, 2 insertions(+) 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 index fd811590..eb9a11e5 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java @@ -1,10 +1,12 @@ package com.Timo.Timo.domain.timer.repository; import com.Timo.Timo.domain.timer.entity.TimerSession; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface TimerSessionRepository extends JpaRepository { Optional findByTimerRecordIdAndPausedAtIsNull(Long timerRecordId); + List findByTimerRecordId(Long timerRecordId); } From e64080cf24506e8a3ce6e8148deb0ebef1356309 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 00:58:01 +0900 Subject: [PATCH 301/383] =?UTF-8?q?feat:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EB=B3=80=EA=B2=BD(=EC=9D=BC=EC=8B=9C?= =?UTF-8?q?=EC=A0=95=EC=A7=80/=EC=9E=AC=EA=B0=9C)=20Request/Response=20DTO?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/dto/request/TimerActionRequest.java | 11 ++++++++ .../dto/response/TimerStatusResponse.java | 26 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/timer/dto/request/TimerActionRequest.java create mode 100644 src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerStatusResponse.java 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/response/TimerStatusResponse.java b/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerStatusResponse.java new file mode 100644 index 00000000..bf5f440b --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerStatusResponse.java @@ -0,0 +1,26 @@ +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 + ); + } +} From 1cce3fea42709a4103606f775f9e82ff058da75d Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 01:20:37 +0900 Subject: [PATCH 302/383] =?UTF-8?q?feat:=20TimerService=EC=97=90=20?= =?UTF-8?q?=ED=83=80=EC=9D=B4=EB=A8=B8=20=EC=83=81=ED=83=9C=EB=B3=80?= =?UTF-8?q?=EA=B2=BD(=EC=9D=BC=EC=8B=9C=EC=A0=95=EC=A7=80/=EC=9E=AC?= =?UTF-8?q?=EA=B0=9C)=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/timer/service/TimerService.java | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) 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 index f3faef76..b63392da 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -1,8 +1,10 @@ package com.Timo.Timo.domain.timer.service; 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; @@ -15,6 +17,7 @@ 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.LocalDateTime; import java.util.List; import lombok.RequiredArgsConstructor; @@ -68,6 +71,47 @@ public TimerStartResponse startTimer(Long userId, Long todoId) { return TimerStartResponse.from(timerRecord); } + @Transactional + public TimerStatusResponse changeStatus(Long userId, Long timerId, TimerAction action){ + TimerRecord timerRecord = timerRecordRepository.findById(timerId) + .orElseThrow(() -> new CustomException(TimerErrorCode.TIMER_NOT_FOUND)); + + if (!timerRecord.getUser().getId().equals(userId)){ + throw new CustomException(ErrorCode.FORBIDDEN); + } + + LocalDateTime now = LocalDateTime.now(); + + if (action == TimerAction.PAUSE){ + timerRecord.pause(); + TimerSession activeSession = timerSessionRepository.findByTimerRecordIdAndPausedAtIsNull(timerId) + .orElseThrow(() -> new CustomException(TimerErrorCode.TIMER_INVALID_STATUS_TRANSITION)); + activeSession.pause(now); + } else { + timerRecord.resume(); + TimerSession newSession = TimerSession.builder() + .timerRecord(timerRecord) + .startedAt(now) + .build(); + timerSessionRepository.save(newSession); + } + + int elapsedSeconds = calculateElapsedSeconds(timerId, now); + + return TimerStatusResponse.of(timerRecord, elapsedSeconds); + } + + 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; + } + public boolean hasActiveTimer(Long todoId) { return timerRecordRepository.existsByTodo_IdAndStatusIn(todoId, ACTIVE_STATUS); } From 1421bf7208252afcfebd74ef7b653e437349820e Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 01:36:51 +0900 Subject: [PATCH 303/383] =?UTF-8?q?feat:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=9D=BC=EC=8B=9C=EC=A0=95=EC=A7=80/=EC=9E=AC=EA=B0=9C=20API?= =?UTF-8?q?=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...troller.java => TimerStartController.java} | 29 +++++++++++++++++-- .../timer/factory/TimerResponseFactory.java | 9 ++++++ 2 files changed, 36 insertions(+), 2 deletions(-) rename src/main/java/com/Timo/Timo/domain/timer/controller/{TimerController.java => TimerStartController.java} (52%) diff --git a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerStartController.java similarity index 52% rename from src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java rename to src/main/java/com/Timo/Timo/domain/timer/controller/TimerStartController.java index 3192830a..0ffa74c4 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java +++ b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerStartController.java @@ -1,17 +1,25 @@ package com.Timo.Timo.domain.timer.controller; -import com.Timo.Timo.domain.timer.docs.TimerControllerDocs; +import com.Timo.Timo.domain.timer.docs.TimerStartControllerDocs; +import com.Timo.Timo.domain.timer.docs.TimerStatusControllerDocs; +import com.Timo.Timo.domain.timer.dto.request.TimerActionRequest; import com.Timo.Timo.domain.timer.dto.response.TimerStartResponse; +import com.Timo.Timo.domain.timer.dto.response.TimerStatusResponse; +import com.Timo.Timo.domain.timer.enums.TimerAction; +import com.Timo.Timo.domain.timer.exception.TimerSuccessCode; import com.Timo.Timo.domain.timer.factory.TimerResponseFactory; 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.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -19,7 +27,7 @@ @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor -public class TimerController implements TimerControllerDocs { +public class TimerStartController implements TimerStartControllerDocs, TimerStatusControllerDocs { private final TimerService timerService; private final TimerResponseFactory timerResponseFactory; @@ -36,4 +44,21 @@ public ResponseEntity> startTimer( return timerResponseFactory.startResponse(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 = request.action() == TimerAction.PAUSE + ? TimerSuccessCode.TIMER_PAUSED + : TimerSuccessCode.TIMER_RESUMED; + + return timerResponseFactory.statusResponse(response, successCode); + } } diff --git a/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java b/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java index 4eb36503..d2afd7a4 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java +++ b/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java @@ -1,6 +1,7 @@ package com.Timo.Timo.domain.timer.factory; 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.global.response.BaseResponse; import org.springframework.http.HttpStatus; @@ -14,4 +15,12 @@ public ResponseEntity> startResponse(TimerStart return ResponseEntity.status(HttpStatus.CREATED) .body(BaseResponse.onSuccess(TimerSuccessCode.TIMER_STARTED, response)); } + + public ResponseEntity> statusResponse( + TimerStatusResponse response, + TimerSuccessCode successCode + ){ + return ResponseEntity.ok() + .body(BaseResponse.onSuccess(successCode, response)); + } } From 9c9accc2fbfc6c10200f6885c2314b3c8c5dc176 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 01:37:03 +0900 Subject: [PATCH 304/383] =?UTF-8?q?docs:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=9D=BC=EC=8B=9C=EC=A0=95=EC=A7=80/=EC=9E=AC=EA=B0=9C=20API?= =?UTF-8?q?=20Swagger=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ocs.java => TimerStartControllerDocs.java} | 2 +- .../timer/docs/TimerStatusControllerDocs.java | 82 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) rename src/main/java/com/Timo/Timo/domain/timer/docs/{TimerControllerDocs.java => TimerStartControllerDocs.java} (98%) create mode 100644 src/main/java/com/Timo/Timo/domain/timer/docs/TimerStatusControllerDocs.java diff --git a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStartControllerDocs.java similarity index 98% rename from src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java rename to src/main/java/com/Timo/Timo/domain/timer/docs/TimerStartControllerDocs.java index e5f22bd8..d7ec365c 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStartControllerDocs.java @@ -13,7 +13,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; -public interface TimerControllerDocs { +public interface TimerStartControllerDocs { @Operation( summary = "타이머 시작", 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..ff5fba17 --- /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 + ); +} \ No newline at end of file From 2dba0799c94845f9f16e605aabdfdb2d41486eeb Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 01:44:01 +0900 Subject: [PATCH 305/383] =?UTF-8?q?fix:=20TimerStartResponse=EC=9D=98=20st?= =?UTF-8?q?artedAt=20=EC=9D=91=EB=8B=B5=20=ED=8F=AC=EB=A7=B7=20=EC=A7=80?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/timer/docs/TimerStatusControllerDocs.java | 2 +- .../Timo/domain/timer/dto/response/TimerStartResponse.java | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) 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 index ff5fba17..5136c0c8 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStatusControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStatusControllerDocs.java @@ -22,7 +22,7 @@ public interface TimerStatusControllerDocs { summary = "타이머 일시정지/재개", description = """ 실행 중인 타이머의 일시정지 / 재개를 처리합니다.
- PAUSE: 현재 세션의 paused_at 기록, status → PAUSED + PAUSE: 현재 세션의 paused_at 기록, status → PAUSED
RESUME: 새 세션 생성, status → RUNNING """ ) 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 index 71c1b8f3..78ffca3a 100644 --- 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 @@ -1,6 +1,8 @@ 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( @@ -8,6 +10,8 @@ public record TimerStartResponse( 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) { From 4ccfd2290477a9f32dbd9e44bc90f18797c92ca1 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 12:24:50 +0900 Subject: [PATCH 306/383] =?UTF-8?q?chore:=20TimeController=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EB=AA=85=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{TimerStartController.java => TimerController.java} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/main/java/com/Timo/Timo/domain/timer/controller/{TimerStartController.java => TimerController.java} (96%) diff --git a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerStartController.java b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java similarity index 96% rename from src/main/java/com/Timo/Timo/domain/timer/controller/TimerStartController.java rename to src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java index 0ffa74c4..504819d7 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerStartController.java +++ b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java @@ -27,7 +27,7 @@ @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor -public class TimerStartController implements TimerStartControllerDocs, TimerStatusControllerDocs { +public class TimerController implements TimerStartControllerDocs, TimerStatusControllerDocs { private final TimerService timerService; private final TimerResponseFactory timerResponseFactory; From d8fd31a229f910a14ae1efdee65306f849e05cae Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:29:42 +0900 Subject: [PATCH 307/383] =?UTF-8?q?feat(subtask):=20=ED=95=98=EC=9C=84=20?= =?UTF-8?q?=ED=83=9C=EC=8A=A4=ED=81=AC=20=EC=99=84=EB=A3=8C=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=20=EB=B3=80=EA=B2=BD=20dto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/SubtaskStatusUpdateRequest.java | 8 ++++++++ .../dto/response/SubtaskStatusChangeResponse.java | 15 +++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/todo/dto/request/SubtaskStatusUpdateRequest.java create mode 100644 src/main/java/com/Timo/Timo/domain/todo/dto/response/SubtaskStatusChangeResponse.java 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..b99d9958 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/dto/request/SubtaskStatusUpdateRequest.java @@ -0,0 +1,8 @@ +package com.Timo.Timo.domain.todo.dto.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record SubtaskStatusUpdateRequest( + @JsonProperty("isCompleted") + Boolean isCompleted +) { } 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() + ); + } +} From a64656c74e763b9f2cbfc4ecaf460971f7365ae2 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:29:51 +0900 Subject: [PATCH 308/383] =?UTF-8?q?feat(subtask):=20=ED=95=98=EC=9C=84=20?= =?UTF-8?q?=ED=83=9C=EC=8A=A4=ED=81=AC=20=EC=99=84=EB=A3=8C=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/Timo/Timo/domain/todo/entity/Subtask.java | 4 ++++ 1 file changed, 4 insertions(+) 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 index ea51ba1a..1c8cc2b3 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/entity/Subtask.java +++ b/src/main/java/com/Timo/Timo/domain/todo/entity/Subtask.java @@ -50,4 +50,8 @@ public static Subtask of(String content, int sortOrder) { void assignTodo(Todo todo) { this.todo = todo; } + + public void updateCompleted(boolean completed) { + this.completed = completed; + } } From 78cbd7456770e6dc92c74afccb4375abc650387c Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:30:01 +0900 Subject: [PATCH 309/383] =?UTF-8?q?feat(subtask):=20=ED=95=98=EC=9C=84=20?= =?UTF-8?q?=ED=83=9C=EC=8A=A4=ED=81=AC=20=EC=99=84=EB=A3=8C=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=20=EA=B4=80=EB=A0=A8=20=EC=9D=91=EB=8B=B5=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/todo/exception/TodoErrorCode.java | 4 +++- .../com/Timo/Timo/domain/todo/exception/TodoSuccessCode.java | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) 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 index 1de82e79..4d8263ed 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java @@ -16,7 +16,9 @@ public enum TodoErrorCode implements BaseErrorCode { TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_404", "존재하지 않는 TODO입니다."), MAX_COUNT_EXCEEDED(HttpStatus.CONFLICT, "TODO_409", "해당 날짜의 투두가 최대 개수(20개)를 초과했습니다."), IS_COMPLETED_REQUIRED(HttpStatus.BAD_REQUEST, "COMMON_400", "isCompleted는 필수입니다."), - TIMER_RUNNING(HttpStatus.CONFLICT, "TODO_409", "타이머가 실행 중인 TODO는 변경할 수 없습니다. 타이머를 먼저 종료해주세요."); + SUBTASK_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_404", "존재하지 않는 하위 투두입니다."), + TIMER_RUNNING(HttpStatus.CONFLICT, "TODO_409", "타이머가 실행 중인 TODO는 변경할 수 없습니다. 타이머를 먼저 종료해주세요."), + ; private final HttpStatus httpStatus; private final String code; 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 index 96bdf57a..3c86c06f 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoSuccessCode.java @@ -12,7 +12,9 @@ public enum TodoSuccessCode implements BaseSuccessCode { CREATED(HttpStatus.CREATED, "TODO가 생성되었습니다."), - STATUS_CHANGED(HttpStatus.OK, "TODO 상태가 변경되었습니다."); + STATUS_CHANGED(HttpStatus.OK, "TODO 상태가 변경되었습니다."), + SUBTASK_STATUS_CHANGED(HttpStatus.OK, "하위 태스크 상태가 변경되었습니다."), + ; private final HttpStatus httpStatus; private final String message; From d14471164d76c30a6358899a0170eea3b434b739 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:30:09 +0900 Subject: [PATCH 310/383] =?UTF-8?q?feat(subtask):=20=ED=95=98=EC=9C=84=20?= =?UTF-8?q?=ED=83=9C=EC=8A=A4=ED=81=AC=20=EC=99=84=EB=A3=8C=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=20service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/todo/service/TodoService.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) 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 index 3beab77a..96f4aed4 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java @@ -10,10 +10,13 @@ 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.TodoStatusUpdateRequest; +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.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; @@ -96,6 +99,25 @@ public TodoStatusChangeResponse changeCompletion(Long userId, Long todoId, TodoS return TodoStatusChangeResponse.from(todoId, instance); } + @Transactional + public SubtaskStatusChangeResponse changeSubtaskCompletion( + Long userId, Long todoId, Long subtaskId, SubtaskStatusUpdateRequest request) { + if (request.isCompleted() == null) { + throw new CustomException(TodoErrorCode.IS_COMPLETED_REQUIRED); + } + + 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); + } + private LocalDate resolveDate(Long userId, LocalDate requestedDate) { if (requestedDate != null) { return requestedDate; From 9c483cc74ebddd11b5290d0da1437972e5112156 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:30:22 +0900 Subject: [PATCH 311/383] =?UTF-8?q?feat(subtask):=20=ED=95=98=EC=9C=84=20?= =?UTF-8?q?=ED=83=9C=EC=8A=A4=ED=81=AC=20=EC=99=84=EB=A3=8C=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=20controller=20=EB=B0=8F=20swagger=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../todo/controller/TodoController.java | 18 +++++ .../domain/todo/docs/TodoControllerDocs.java | 74 +++++++++++++++++++ 2 files changed, 92 insertions(+) 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 index 967b4901..a1bff7fc 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/controller/TodoController.java +++ b/src/main/java/com/Timo/Timo/domain/todo/controller/TodoController.java @@ -10,8 +10,10 @@ 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.TodoStatusUpdateRequest; +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.TodoStatusChangeResponse; import com.Timo.Timo.domain.todo.exception.TodoSuccessCode; @@ -57,4 +59,20 @@ public ResponseEntity> changeTodoStatus( .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)); + } } 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 index 3c008189..465b8ea7 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java @@ -2,8 +2,10 @@ 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.TodoStatusUpdateRequest; +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.TodoStatusChangeResponse; import com.Timo.Timo.global.auth.principal.CustomUserDetails; @@ -197,4 +199,76 @@ ResponseEntity> changeTodoStatus( @Parameter(description = "대상 TODO ID", example = "145") Long todoId, TodoStatusUpdateRequest 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 + ); } From 35a28957e9a1fea287d28788558709a81fb716e2 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 12:36:09 +0900 Subject: [PATCH 312/383] =?UTF-8?q?feat:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91/=EC=9D=BC=EC=8B=9C=EC=A0=95=EC=A7=80/?= =?UTF-8?q?=EC=9E=AC=EA=B0=9C=20=EC=8B=9C=20TodoInstance=20timerStatus=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/timer/service/TimerService.java | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) 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 index b63392da..9fd0d9c5 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -10,7 +10,9 @@ 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; @@ -18,6 +20,7 @@ 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; @@ -35,6 +38,7 @@ public class TimerService { private final TimerSessionRepository timerSessionRepository; private final TodoRepository todoRepository; private final UserRepository userRepository; + private final TodoInstanceRepository todoInstanceRepository; @Transactional public TimerStartResponse startTimer(Long userId, Long todoId) { @@ -68,6 +72,9 @@ public TimerStartResponse startTimer(Long userId, Long todoId) { .build(); timerSessionRepository.save(session); + TodoInstance instance = getOrCreateInstance(todo, now.toLocalDate()); + instance.startTimer(); + return TimerStartResponse.from(timerRecord); } @@ -82,11 +89,14 @@ public TimerStatusResponse changeStatus(Long userId, Long timerId, TimerAction a LocalDateTime now = LocalDateTime.now(); - if (action == TimerAction.PAUSE){ + 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 { timerRecord.resume(); TimerSession newSession = TimerSession.builder() @@ -94,6 +104,7 @@ public TimerStatusResponse changeStatus(Long userId, Long timerId, TimerAction a .startedAt(now) .build(); timerSessionRepository.save(newSession); + instance.startTimer(); } int elapsedSeconds = calculateElapsedSeconds(timerId, now); @@ -112,6 +123,11 @@ private int calculateElapsedSeconds(Long timerRecordId, LocalDateTime now){ 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); } From ed6a1a8a5602d8ab1bda2c54b0793407eae30644 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 15:53:15 +0900 Subject: [PATCH 313/383] =?UTF-8?q?refactor:=20TimerResponseFactory=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0,=20Controller=EB=A1=9C=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=20=EC=A1=B0=EB=A6=BD=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/controller/TimerController.java | 9 ++++--- .../timer/factory/TimerResponseFactory.java | 26 ------------------- 2 files changed, 5 insertions(+), 30 deletions(-) delete mode 100644 src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java 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 index 504819d7..edb21267 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java +++ b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java @@ -7,13 +7,13 @@ import com.Timo.Timo.domain.timer.dto.response.TimerStatusResponse; import com.Timo.Timo.domain.timer.enums.TimerAction; import com.Timo.Timo.domain.timer.exception.TimerSuccessCode; -import com.Timo.Timo.domain.timer.factory.TimerResponseFactory; 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.PatchMapping; @@ -30,7 +30,6 @@ public class TimerController implements TimerStartControllerDocs, TimerStatusControllerDocs { private final TimerService timerService; - private final TimerResponseFactory timerResponseFactory; @Override @PostMapping("/todos/{todoId}/timers/start") @@ -42,7 +41,8 @@ public ResponseEntity> startTimer( Long userId = userDetails.getUserId(); TimerStartResponse response = timerService.startTimer(userId, todoId); - return timerResponseFactory.startResponse(response); + return ResponseEntity.status(HttpStatus.CREATED) + .body(BaseResponse.onSuccess(TimerSuccessCode.TIMER_STARTED, response)); } @Override @@ -59,6 +59,7 @@ public ResponseEntity> changeStatus( ? TimerSuccessCode.TIMER_PAUSED : TimerSuccessCode.TIMER_RESUMED; - return timerResponseFactory.statusResponse(response, successCode); + return ResponseEntity.ok() + .body(BaseResponse.onSuccess(successCode, response)); } } diff --git a/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java b/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java deleted file mode 100644 index d2afd7a4..00000000 --- a/src/main/java/com/Timo/Timo/domain/timer/factory/TimerResponseFactory.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.Timo.Timo.domain.timer.factory; - -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.global.response.BaseResponse; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; - -@Component -public class TimerResponseFactory { - - public ResponseEntity> startResponse(TimerStartResponse response){ - return ResponseEntity.status(HttpStatus.CREATED) - .body(BaseResponse.onSuccess(TimerSuccessCode.TIMER_STARTED, response)); - } - - public ResponseEntity> statusResponse( - TimerStatusResponse response, - TimerSuccessCode successCode - ){ - return ResponseEntity.ok() - .body(BaseResponse.onSuccess(successCode, response)); - } -} From 15f4360f50a35f02cfd800370cad6a6862552c3a Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 16:00:25 +0900 Subject: [PATCH 314/383] =?UTF-8?q?fix:=20changeStatus=EC=97=90=20?= =?UTF-8?q?=EB=B9=84=EA=B4=80=EC=A0=81=20=EB=9D=BD=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/timer/repository/TimerRecordRepository.java | 6 ++++++ .../com/Timo/Timo/domain/timer/service/TimerService.java | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) 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 index fdc86d7a..b0fe55f9 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java @@ -2,10 +2,12 @@ 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.Query; import org.springframework.data.repository.query.Param; @@ -13,6 +15,10 @@ public interface TimerRecordRepository extends JpaRepository Optional findByUserIdAndStatusIn(Long userId, List statuses); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select t from TimerRecord t where t.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + boolean existsByTodo_IdAndStatusIn(Long todoId, List statuses); @Query(""" 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 index 9fd0d9c5..3e6828eb 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -80,7 +80,7 @@ public TimerStartResponse startTimer(Long userId, Long todoId) { @Transactional public TimerStatusResponse changeStatus(Long userId, Long timerId, TimerAction action){ - TimerRecord timerRecord = timerRecordRepository.findById(timerId) + TimerRecord timerRecord = timerRecordRepository.findByIdForUpdate(timerId) .orElseThrow(() -> new CustomException(TimerErrorCode.TIMER_NOT_FOUND)); if (!timerRecord.getUser().getId().equals(userId)){ From cef6df7b0f8859973b97ad7b3114678d8fa02fb1 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 16:10:22 +0900 Subject: [PATCH 315/383] =?UTF-8?q?refactor:=20changeStatus=EC=9D=98=20if/?= =?UTF-8?q?else=EB=A5=BC=20=EB=AA=85=EC=8B=9C=EC=A0=81=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/timer/controller/TimerController.java | 7 ++++--- .../com/Timo/Timo/domain/timer/service/TimerService.java | 4 +++- 2 files changed, 7 insertions(+), 4 deletions(-) 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 index edb21267..83e98d40 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java +++ b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java @@ -55,9 +55,10 @@ public ResponseEntity> changeStatus( Long userId = userDetails.getUserId(); TimerStatusResponse response = timerService.changeStatus(userId, timerId, request.action()); - TimerSuccessCode successCode = request.action() == TimerAction.PAUSE - ? TimerSuccessCode.TIMER_PAUSED - : TimerSuccessCode.TIMER_RESUMED; + TimerSuccessCode successCode = switch (request.action()) { + case PAUSE -> TimerSuccessCode.TIMER_PAUSED; + case RESUME -> TimerSuccessCode.TIMER_RESUMED; + }; return ResponseEntity.ok() .body(BaseResponse.onSuccess(successCode, response)); 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 index 3e6828eb..a6322a95 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -97,7 +97,7 @@ public TimerStatusResponse changeStatus(Long userId, Long timerId, TimerAction a .orElseThrow(() -> new CustomException(TimerErrorCode.TIMER_INVALID_STATUS_TRANSITION)); activeSession.pause(now); instance.pauseTimer(); - } else { + } else if (action == TimerAction.RESUME) { timerRecord.resume(); TimerSession newSession = TimerSession.builder() .timerRecord(timerRecord) @@ -105,6 +105,8 @@ public TimerStatusResponse changeStatus(Long userId, Long timerId, TimerAction a .build(); timerSessionRepository.save(newSession); instance.startTimer(); + } else { + throw new CustomException(TimerErrorCode.TIMER_INVALID_STATUS_TRANSITION); } int elapsedSeconds = calculateElapsedSeconds(timerId, now); From 6b770e72ae8fdb75d4e19cd13f4c611a2102efd7 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:34:23 +0900 Subject: [PATCH 316/383] =?UTF-8?q?feat(focus):=20=EC=A7=91=EC=A4=91=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20dto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/focus/dto/FocusTodoResult.java | 9 +++ .../focus/dto/response/FocusTodoResponse.java | 62 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/focus/dto/FocusTodoResult.java create mode 100644 src/main/java/com/Timo/Timo/domain/focus/dto/response/FocusTodoResponse.java 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() + ); + } + } +} From 2b82b94672e2e234198177ccdf8a0dd7e21ab242 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:34:35 +0900 Subject: [PATCH 317/383] =?UTF-8?q?feat(focus):=20=EC=A7=91=EC=A4=91=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=EC=84=B1=EA=B3=B5=20=EC=BD=94=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../focus/exception/FocusSuccessCode.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/focus/exception/FocusSuccessCode.java 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; +} From 02f7e1b1495645cb4a1948da6ea9d974224d5c69 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:34:41 +0900 Subject: [PATCH 318/383] =?UTF-8?q?feat(focus):=20=EC=A7=91=EC=A4=91=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/focus/service/FocusService.java | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/focus/service/FocusService.java 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)); + } +} From da74d8cc1061737b83ef0277fbd98b24f6d446c4 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:35:03 +0900 Subject: [PATCH 319/383] =?UTF-8?q?feat(focus):=20=EC=A7=91=EC=A4=91=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20controller=20=EB=B0=8F=20swagger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../focus/controller/FocusController.java | 38 ++++++++++++ .../focus/docs/FocusControllerDocs.java | 62 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/focus/controller/FocusController.java create mode 100644 src/main/java/com/Timo/Timo/domain/focus/docs/FocusControllerDocs.java 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 + ); +} From 71a3211bcef2e77e425f72636e56728bed5fc5d5 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 20:55:10 +0900 Subject: [PATCH 320/383] =?UTF-8?q?feat:=20TimerActiveResponse=20DTO=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/TimerActiveResponse.java | 36 +++++++++++++++++++ .../repository/TimerRecordRepository.java | 1 - 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerActiveResponse.java 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..7ced6af9 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timer/dto/response/TimerActiveResponse.java @@ -0,0 +1,36 @@ +package com.Timo.Timo.domain.timer.dto.response; + +import com.Timo.Timo.domain.timer.entity.TimerRecord; +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, + 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/repository/TimerRecordRepository.java b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java index fda45b34..b0fe55f9 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java @@ -3,7 +3,6 @@ import com.Timo.Timo.domain.timer.entity.TimerRecord; import com.Timo.Timo.domain.timer.enums.TimerStatus; import jakarta.persistence.LockModeType; -import jakarta.persistence.LockModeType; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; From a6a3b30d7ec0425a24c599d6d5f829ad3ccf5308 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 20:59:12 +0900 Subject: [PATCH 321/383] =?UTF-8?q?feat:=20=ED=98=84=EC=9E=AC=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EC=A4=91=EC=9D=B8=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/timer/service/TimerService.java | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 index d5a58028..3345cd13 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -1,5 +1,6 @@ package com.Timo.Timo.domain.timer.service; +import com.Timo.Timo.domain.timer.dto.response.TimerActiveResponse; 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; @@ -114,6 +115,15 @@ public TimerStatusResponse changeStatus(Long userId, Long timerId, TimerAction a return TimerStatusResponse.of(timerRecord, elapsedSeconds); } + 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; From afbc6de6ee80d44428980cc53aedd6e2f46efdd5 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 21:04:31 +0900 Subject: [PATCH 322/383] =?UTF-8?q?feat:=20=ED=98=84=EC=9E=AC=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EC=A4=91=EC=9D=B8=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/controller/TimerController.java | 18 ++++++++++++++++++ .../timer/exception/TimerSuccessCode.java | 2 ++ 2 files changed, 20 insertions(+) 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 index 83e98d40..c1cca58c 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java +++ b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java @@ -3,6 +3,7 @@ import com.Timo.Timo.domain.timer.docs.TimerStartControllerDocs; import com.Timo.Timo.domain.timer.docs.TimerStatusControllerDocs; import com.Timo.Timo.domain.timer.dto.request.TimerActionRequest; +import com.Timo.Timo.domain.timer.dto.response.TimerActiveResponse; import com.Timo.Timo.domain.timer.dto.response.TimerStartResponse; import com.Timo.Timo.domain.timer.dto.response.TimerStatusResponse; import com.Timo.Timo.domain.timer.enums.TimerAction; @@ -16,6 +17,7 @@ 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; @@ -63,4 +65,20 @@ public ResponseEntity> changeStatus( return ResponseEntity.ok() .body(BaseResponse.onSuccess(successCode, 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)); + } } 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 index 4ee0d0f8..a3ffaf3f 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java @@ -11,6 +11,8 @@ 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_STARTED(HttpStatus.CREATED, "TIMER_201", "타이머가 시작되었습니다."); private final HttpStatus httpStatus; From d4349e236ab7921ceebc540bc566f2275198f652 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 21:05:12 +0900 Subject: [PATCH 323/383] =?UTF-8?q?docs:=20=ED=98=84=EC=9E=AC=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EC=A4=91=EC=9D=B8=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20Swagger=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/controller/TimerController.java | 5 +- .../timer/docs/TimerActiveControllerDocs.java | 51 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/domain/timer/docs/TimerActiveControllerDocs.java 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 index c1cca58c..052584e5 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java +++ b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java @@ -1,12 +1,12 @@ package com.Timo.Timo.domain.timer.controller; +import com.Timo.Timo.domain.timer.docs.TimerActiveControllerDocs; import com.Timo.Timo.domain.timer.docs.TimerStartControllerDocs; import com.Timo.Timo.domain.timer.docs.TimerStatusControllerDocs; import com.Timo.Timo.domain.timer.dto.request.TimerActionRequest; import com.Timo.Timo.domain.timer.dto.response.TimerActiveResponse; import com.Timo.Timo.domain.timer.dto.response.TimerStartResponse; import com.Timo.Timo.domain.timer.dto.response.TimerStatusResponse; -import com.Timo.Timo.domain.timer.enums.TimerAction; import com.Timo.Timo.domain.timer.exception.TimerSuccessCode; import com.Timo.Timo.domain.timer.service.TimerService; import com.Timo.Timo.global.auth.principal.CustomUserDetails; @@ -29,7 +29,8 @@ @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor -public class TimerController implements TimerStartControllerDocs, TimerStatusControllerDocs { +public class TimerController implements TimerStartControllerDocs, TimerStatusControllerDocs, + TimerActiveControllerDocs { private final TimerService timerService; 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..f15359b8 --- /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 + ); +} From 8be6ac7708030cde9085ce594f8ce78c0380e52b Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:15:03 +0900 Subject: [PATCH 324/383] =?UTF-8?q?feat(tag):=20tag=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/tag/controller/TagController.java | 42 +++++++++++ .../domain/tag/docs/TagControllerDocs.java | 70 +++++++++++++++++++ .../tag/dto/request/TagCreateRequest.java | 10 +++ .../tag/dto/response/TagCreateResponse.java | 16 +++++ .../com/Timo/Timo/domain/tag/entity/Tag.java | 29 +++++++- .../domain/tag/exception/TagErrorCode.java | 5 +- .../tag/exception/TagExceptionHandler.java | 58 +++++++++++++++ .../domain/tag/exception/TagSuccessCode.java | 19 +++++ .../domain/tag/repository/TagRepository.java | 12 ++++ .../Timo/domain/tag/service/TagService.java | 42 +++++++++++ 10 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/domain/tag/controller/TagController.java create mode 100644 src/main/java/com/Timo/Timo/domain/tag/docs/TagControllerDocs.java create mode 100644 src/main/java/com/Timo/Timo/domain/tag/dto/request/TagCreateRequest.java create mode 100644 src/main/java/com/Timo/Timo/domain/tag/dto/response/TagCreateResponse.java create mode 100644 src/main/java/com/Timo/Timo/domain/tag/exception/TagExceptionHandler.java create mode 100644 src/main/java/com/Timo/Timo/domain/tag/exception/TagSuccessCode.java create mode 100644 src/main/java/com/Timo/Timo/domain/tag/service/TagService.java 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..ae2bd89d --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/tag/controller/TagController.java @@ -0,0 +1,42 @@ +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.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.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)); + } +} 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..a26cbeea --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/tag/docs/TagControllerDocs.java @@ -0,0 +1,70 @@ +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.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 + ); +} 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..a193e336 --- /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 = 20, message = "태그 이름은 20자를 초과할 수 없습니다.") + 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/entity/Tag.java b/src/main/java/com/Timo/Timo/domain/tag/entity/Tag.java index 5d41ecee..9053e691 100644 --- a/src/main/java/com/Timo/Timo/domain/tag/entity/Tag.java +++ b/src/main/java/com/Timo/Timo/domain/tag/entity/Tag.java @@ -1,17 +1,27 @@ 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") +@Table(name = "tags", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "name"}) + } +) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Tag { @@ -21,6 +31,23 @@ public class Tag { @Column(name = "id") private Long id; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + @Column(name = "name", nullable = false, length = 20) 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 index 700134cb..a2d5697b 100644 --- a/src/main/java/com/Timo/Timo/domain/tag/exception/TagErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/tag/exception/TagErrorCode.java @@ -11,7 +11,10 @@ @RequiredArgsConstructor public enum TagErrorCode implements BaseErrorCode { - TAG_NOT_FOUND(HttpStatus.NOT_FOUND, "TAG_404", "존재하지 않는 태그입니다."); + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "TAG_400", "태그 이름은 필수입니다."), + TAG_NOT_FOUND(HttpStatus.NOT_FOUND, "TAG_404", "존재하지 않는 태그입니다."), + DUPLICATE_TAG_NAME(HttpStatus.CONFLICT, "TAG_409", "이미 존재하는 태그명입니다."), + ; private final HttpStatus httpStatus; private final String code; 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..d3296dff --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/tag/exception/TagExceptionHandler.java @@ -0,0 +1,58 @@ +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 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); + } + + private ResponseEntity createInvalidRequestResponse(String message, HttpServletRequest request) { + TagErrorCode errorCode = TagErrorCode.INVALID_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..68b716fa --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/tag/exception/TagSuccessCode.java @@ -0,0 +1,19 @@ +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, "태그가 생성되었습니다."), + ; + + 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 index a3ffde1b..d7ea8d05 100644 --- a/src/main/java/com/Timo/Timo/domain/tag/repository/TagRepository.java +++ b/src/main/java/com/Timo/Timo/domain/tag/repository/TagRepository.java @@ -1,8 +1,20 @@ package com.Timo.Timo.domain.tag.repository; 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 + ); } 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..d9c9cb1f --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/tag/service/TagService.java @@ -0,0 +1,42 @@ +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.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); + } + } +} From d5e49d9808038513609740f64c180da84f0b4116 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:21:14 +0900 Subject: [PATCH 325/383] =?UTF-8?q?feat(tag):=20tag=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/tag/controller/TagController.java | 15 +++++ .../domain/tag/docs/TagControllerDocs.java | 59 +++++++++++++++++++ .../domain/tag/exception/TagErrorCode.java | 2 + .../tag/exception/TagExceptionHandler.java | 18 +++++- .../domain/tag/exception/TagSuccessCode.java | 1 + .../Timo/domain/tag/service/TagService.java | 23 ++++++++ 6 files changed, 117 insertions(+), 1 deletion(-) 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 index ae2bd89d..476cd7c6 100644 --- a/src/main/java/com/Timo/Timo/domain/tag/controller/TagController.java +++ b/src/main/java/com/Timo/Timo/domain/tag/controller/TagController.java @@ -2,6 +2,8 @@ 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.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -39,4 +41,17 @@ public ResponseEntity> createTag( .status(TagSuccessCode.CREATED.getHttpStatus()) .body(BaseResponse.onSuccess(TagSuccessCode.CREATED, 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 index a26cbeea..2b5311f8 100644 --- a/src/main/java/com/Timo/Timo/domain/tag/docs/TagControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/tag/docs/TagControllerDocs.java @@ -67,4 +67,63 @@ ResponseEntity> createTag( @Parameter(hidden = true) CustomUserDetails userDetails, TagCreateRequest request ); + + @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/exception/TagErrorCode.java b/src/main/java/com/Timo/Timo/domain/tag/exception/TagErrorCode.java index a2d5697b..8237da84 100644 --- a/src/main/java/com/Timo/Timo/domain/tag/exception/TagErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/tag/exception/TagErrorCode.java @@ -12,6 +12,8 @@ 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", "이미 존재하는 태그명입니다."), ; 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 index d3296dff..a16fee3b 100644 --- a/src/main/java/com/Timo/Timo/domain/tag/exception/TagExceptionHandler.java +++ b/src/main/java/com/Timo/Timo/domain/tag/exception/TagExceptionHandler.java @@ -10,6 +10,7 @@ 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; @@ -41,8 +42,23 @@ public ResponseEntity handleHttpMessageNotReadableException( 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) { - TagErrorCode errorCode = TagErrorCode.INVALID_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(), 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 index 68b716fa..2bfa4dcc 100644 --- a/src/main/java/com/Timo/Timo/domain/tag/exception/TagSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/tag/exception/TagSuccessCode.java @@ -12,6 +12,7 @@ public enum TagSuccessCode implements BaseSuccessCode { CREATED(HttpStatus.CREATED, "태그가 생성되었습니다."), + DELETED(HttpStatus.OK, "태그가 삭제되었습니다."), ; private final HttpStatus httpStatus; 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 index d9c9cb1f..8df91f28 100644 --- a/src/main/java/com/Timo/Timo/domain/tag/service/TagService.java +++ b/src/main/java/com/Timo/Timo/domain/tag/service/TagService.java @@ -39,4 +39,27 @@ public TagCreateResponse createTag(Long userId, TagCreateRequest request) { throw new CustomException(TagErrorCode.DUPLICATE_TAG_NAME); } } + + 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); + } } From 41e1654befda98a7b4aeb28d103560a5cd43c44c Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:25:30 +0900 Subject: [PATCH 326/383] =?UTF-8?q?fix(tag):=20=ED=83=9C=EA=B7=B8=EB=AA=85?= =?UTF-8?q?=20=EA=B8=80=EC=9E=90=EC=88=98=2010=EA=B0=9C=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/Timo/Timo/domain/tag/entity/Tag.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 9053e691..823df099 100644 --- a/src/main/java/com/Timo/Timo/domain/tag/entity/Tag.java +++ b/src/main/java/com/Timo/Timo/domain/tag/entity/Tag.java @@ -35,7 +35,7 @@ public class Tag { @JoinColumn(name = "user_id") private User user; - @Column(name = "name", nullable = false, length = 20) + @Column(name = "name", nullable = false, length = 10) private String name; @Column(name = "is_default", nullable = false) From 351615930e9d99fee26cd861de17eb85763b6404 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:39:14 +0900 Subject: [PATCH 327/383] =?UTF-8?q?feat(tag):=20=ED=83=9C=EA=B7=B8=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/tag/controller/TagController.java | 14 ++++++++ .../domain/tag/docs/TagControllerDocs.java | 35 +++++++++++++++++++ .../tag/dto/response/TagListResponse.java | 27 ++++++++++++++ .../domain/tag/exception/TagSuccessCode.java | 1 + .../domain/tag/repository/TagRepository.java | 9 +++++ .../Timo/domain/tag/service/TagService.java | 6 ++++ 6 files changed, 92 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/tag/dto/response/TagListResponse.java 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 index 476cd7c6..1f79f2eb 100644 --- a/src/main/java/com/Timo/Timo/domain/tag/controller/TagController.java +++ b/src/main/java/com/Timo/Timo/domain/tag/controller/TagController.java @@ -3,6 +3,7 @@ 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; @@ -12,6 +13,7 @@ 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; @@ -42,6 +44,18 @@ public ResponseEntity> createTag( .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( 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 index 2b5311f8..faac515d 100644 --- a/src/main/java/com/Timo/Timo/domain/tag/docs/TagControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/tag/docs/TagControllerDocs.java @@ -4,6 +4,7 @@ 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; @@ -68,6 +69,40 @@ ResponseEntity> createTag( 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 = """ 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/exception/TagSuccessCode.java b/src/main/java/com/Timo/Timo/domain/tag/exception/TagSuccessCode.java index 2bfa4dcc..b0da21f8 100644 --- a/src/main/java/com/Timo/Timo/domain/tag/exception/TagSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/tag/exception/TagSuccessCode.java @@ -13,6 +13,7 @@ public enum TagSuccessCode implements BaseSuccessCode { CREATED(HttpStatus.CREATED, "태그가 생성되었습니다."), DELETED(HttpStatus.OK, "태그가 삭제되었습니다."), + TAG_LIST_RETRIEVED(HttpStatus.OK, "태그 목록 조회 성공"), ; private final HttpStatus httpStatus; 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 index d7ea8d05..5c92a839 100644 --- a/src/main/java/com/Timo/Timo/domain/tag/repository/TagRepository.java +++ b/src/main/java/com/Timo/Timo/domain/tag/repository/TagRepository.java @@ -1,5 +1,7 @@ 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; @@ -17,4 +19,11 @@ 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 index 8df91f28..8a1c21bf 100644 --- a/src/main/java/com/Timo/Timo/domain/tag/service/TagService.java +++ b/src/main/java/com/Timo/Timo/domain/tag/service/TagService.java @@ -6,6 +6,7 @@ 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; @@ -40,6 +41,11 @@ public TagCreateResponse createTag(Long userId, TagCreateRequest request) { } } + @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); From c7f478d483183f89bcdd8ce44c34db22ad7d6fb7 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:39:32 +0900 Subject: [PATCH 328/383] =?UTF-8?q?feat(tag):=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=ED=83=9C=EA=B7=B8=EC=9A=A9=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...2026-07-09__tags_add_owner_and_default.sql | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 db/migration/2026-07-09__tags_add_owner_and_default.sql 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..0a9d67e6 --- /dev/null +++ b/db/migration/2026-07-09__tags_add_owner_and_default.sql @@ -0,0 +1,19 @@ +-- 태그 소유자(user_id)와 기본 태그 여부(is_default) 컬럼 추가. +-- 운영은 ddl-auto: validate 이므로 배포 전에 반드시 먼저 실행해야 한다. + +ALTER TABLE tags + ADD COLUMN user_id BIGINT NULL, + ADD COLUMN is_default BIT(1) NOT NULL DEFAULT b'0'; + +ALTER TABLE tags + ADD CONSTRAINT uk_tags_user_id_name UNIQUE (user_id, name); + +ALTER TABLE tags + ADD CONSTRAINT fk_tags_user FOREIGN KEY (user_id) REFERENCES users (id); + +-- 모든 사용자가 공유하는 기본 태그. 소유자가 없으므로 user_id는 NULL이다. +INSERT INTO tags (user_id, name, is_default) +VALUES (NULL, '일상', b'1'), + (NULL, '운동', b'1'), + (NULL, '업무', b'1'), + (NULL, '과제', b'1'); From b7c3b1e18dab8d29e667539eed3cec613f4eb886 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:44:52 +0900 Subject: [PATCH 329/383] =?UTF-8?q?refactor:=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EB=82=B4=EC=9A=A9=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-07-09__tags_add_owner_and_default.sql | 10 ---------- 1 file changed, 10 deletions(-) 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 index 0a9d67e6..0e5d6833 100644 --- a/db/migration/2026-07-09__tags_add_owner_and_default.sql +++ b/db/migration/2026-07-09__tags_add_owner_and_default.sql @@ -1,16 +1,6 @@ -- 태그 소유자(user_id)와 기본 태그 여부(is_default) 컬럼 추가. -- 운영은 ddl-auto: validate 이므로 배포 전에 반드시 먼저 실행해야 한다. -ALTER TABLE tags - ADD COLUMN user_id BIGINT NULL, - ADD COLUMN is_default BIT(1) NOT NULL DEFAULT b'0'; - -ALTER TABLE tags - ADD CONSTRAINT uk_tags_user_id_name UNIQUE (user_id, name); - -ALTER TABLE tags - ADD CONSTRAINT fk_tags_user FOREIGN KEY (user_id) REFERENCES users (id); - -- 모든 사용자가 공유하는 기본 태그. 소유자가 없으므로 user_id는 NULL이다. INSERT INTO tags (user_id, name, is_default) VALUES (NULL, '일상', b'1'), From 3c251528280dc0e9ece60b8809b28b91c7baf6d1 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 21:45:50 +0900 Subject: [PATCH 330/383] =?UTF-8?q?docs:=20=ED=98=84=EC=9E=AC=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EC=A4=91=EC=9D=B8=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20Swagger=20=EB=AC=B8=EC=84=9C=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9,=20=ED=98=95=EC=8B=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/timer/docs/TimerActiveControllerDocs.java | 6 +++--- .../Timo/domain/timer/dto/response/TimerActiveResponse.java | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) 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 index f15359b8..5e2809f2 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerActiveControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerActiveControllerDocs.java @@ -17,9 +17,9 @@ public interface TimerActiveControllerDocs { @Operation( summary = "현재 실행 중인 타이머 조회", description = """ - 로그인한 사용자의 현재 실행 중(RUNNING/PAUSED)인 타이머를 단건 조회합니다. - 한 사용자당 시작 이후 완료/종료되지 않은 타이머는 최대 1개만 존재할 수 있습니다. - 실행 중인 타이머가 없으면 data: null을 반환합니다. + 로그인한 사용자의 현재 실행 중(RUNNING/PAUSED)인 타이머를 단건 조회합니다. + 한 사용자당 시작 이후 완료/종료되지 않은 타이머는 최대 1개만 존재할 수 있습니다. + 실행 중인 타이머가 없으면 data: null을 반환합니다. """ ) @ApiResponses({ 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 index 7ced6af9..2a3d3281 100644 --- 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 @@ -1,6 +1,8 @@ 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 ( @@ -13,6 +15,8 @@ public record TimerActiveResponse ( 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){ From 3b4ae0a5035eec16fa9fb554698ad9aa9f50d66a Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:21:58 +0900 Subject: [PATCH 331/383] =?UTF-8?q?refactor(tag):=20=ED=83=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=A0=9C=EC=95=BD=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/tag/dto/request/TagCreateRequest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index a193e336..4fd3badd 100644 --- 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 @@ -5,6 +5,6 @@ public record TagCreateRequest( @NotBlank(message = "태그 이름은 필수입니다.") - @Size(max = 20, message = "태그 이름은 20자를 초과할 수 없습니다.") + @Size(max = 10, message = "태그 이름은 10자를 초과할 수 없습니다.") String name ) { } From e3a5ac128551324e29445940a6b3996312bc1c8a Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:01:36 +0900 Subject: [PATCH 332/383] =?UTF-8?q?feat(todo):=20todo=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20api=20=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../todo/controller/TodoController.java | 15 +++ .../domain/todo/docs/TodoControllerDocs.java | 48 ++++++++++ .../todo/dto/response/TodoDetailResponse.java | 96 +++++++++++++++++++ .../domain/todo/exception/TodoErrorCode.java | 4 +- .../todo/exception/TodoSuccessCode.java | 1 + .../Timo/domain/todo/service/TodoService.java | 19 ++++ 6 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/domain/todo/dto/response/TodoDetailResponse.java 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 index 967b4901..d05f2a1c 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/controller/TodoController.java +++ b/src/main/java/com/Timo/Timo/domain/todo/controller/TodoController.java @@ -3,6 +3,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -14,6 +15,7 @@ import com.Timo.Timo.domain.todo.dto.request.TodoStatusUpdateRequest; import com.Timo.Timo.domain.todo.dto.response.TodoCreateResponse; 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; @@ -44,6 +46,19 @@ public ResponseEntity> createTodo( .body(BaseResponse.onSuccess(TodoSuccessCode.CREATED, response)); } + @Override + @GetMapping("/{todoId}") + public ResponseEntity> getTodoDetail( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long todoId + ) { + TodoDetailResponse response = todoService.getTodoDetail(userDetails.getUserId(), todoId); + + return ResponseEntity + .status(TodoSuccessCode.GET_DETAIL.getHttpStatus()) + .body(BaseResponse.onSuccess(TodoSuccessCode.GET_DETAIL, response)); + } + @Override @PatchMapping("/{todoId}/status") public ResponseEntity> changeTodoStatus( 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 index 3c008189..ae8f11f5 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java @@ -5,6 +5,7 @@ import com.Timo.Timo.domain.todo.dto.request.TodoCreateRequest; import com.Timo.Timo.domain.todo.dto.request.TodoStatusUpdateRequest; 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.TodoStatusChangeResponse; import com.Timo.Timo.global.auth.principal.CustomUserDetails; import com.Timo.Timo.global.exception.dto.ErrorDto; @@ -197,4 +198,51 @@ ResponseEntity> changeTodoStatus( @Parameter(description = "대상 TODO ID", example = "145") Long todoId, TodoStatusUpdateRequest request ); + + @Operation( + summary = "TODO 상세 조회", + description = """ + todoId로 단일 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 = "존재하지 않는 투두인 경우", + 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 = "145") Long todoId + ); } 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..0392c0ea --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/dto/response/TodoDetailResponse.java @@ -0,0 +1,96 @@ +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, Tag tag) { + LocalDate date = todo.getStartDate(); + + 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, "subtask must not be null"); + return new SubtaskResponse( + subtask.getId(), + subtask.getContent(), + subtask.isCompleted() + ); + } + } +} 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 index 1de82e79..427dc5d3 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java @@ -13,8 +13,8 @@ public enum TodoErrorCode implements BaseErrorCode { INVALID_REQUEST(HttpStatus.BAD_REQUEST, "TODO_400", "필수 필드가 누락되었거나 형식이 올바르지 않습니다."), INVALID_TITLE(HttpStatus.BAD_REQUEST, "TODO_400", "투두명은 한국어 20자/영어 30자를 초과할 수 없습니다."), - TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_404", "존재하지 않는 TODO입니다."), - MAX_COUNT_EXCEEDED(HttpStatus.CONFLICT, "TODO_409", "해당 날짜의 투두가 최대 개수(20개)를 초과했습니다."), + TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_404", "존재하지 않는 투두입니다"), + MAX_COUNT_EXCEEDED(HttpStatus.CONFLICT, "TODO_409", "해당 날짜의 투두가 최대 개수(20개)를 초과했습니다."), IS_COMPLETED_REQUIRED(HttpStatus.BAD_REQUEST, "COMMON_400", "isCompleted는 필수입니다."), TIMER_RUNNING(HttpStatus.CONFLICT, "TODO_409", "타이머가 실행 중인 TODO는 변경할 수 없습니다. 타이머를 먼저 종료해주세요."); 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 index 96bdf57a..88a20403 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoSuccessCode.java @@ -12,6 +12,7 @@ public enum TodoSuccessCode implements BaseSuccessCode { CREATED(HttpStatus.CREATED, "TODO가 생성되었습니다."), + GET_DETAIL(HttpStatus.OK, "TODO 상세 조회 성공"); STATUS_CHANGED(HttpStatus.OK, "TODO 상태가 변경되었습니다."); private final HttpStatus httpStatus; 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 index 3beab77a..e8886c27 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java @@ -7,17 +7,20 @@ 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.TodoCreateRequest; import com.Timo.Timo.domain.todo.dto.request.TodoStatusUpdateRequest; 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.TodoStatusChangeResponse; 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.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; @@ -33,6 +36,7 @@ public class TodoService { private final TodoRepository todoRepository; + private final TodoInstanceRepository todoInstanceRepository; private final UserRepository userRepository; private final TagRepository tagRepository; private final TodoDateCalculator todoDateCalculator; @@ -74,6 +78,21 @@ public TodoCreateResponse createTodo(Long userId, TodoCreateRequest request) { return TodoCreateResponse.from(savedTodo); } + public TodoDetailResponse getTodoDetail(Long userId, Long todoId) { + Todo todo = todoRepository.findByIdAndUser_Id(todoId, userId) + .orElseThrow(() -> new CustomException(TodoErrorCode.TODO_NOT_FOUND)); + + TodoInstance instance = todoInstanceRepository + .findByTodo_IdAndDate(todo.getId(), todo.getStartDate()) + .orElse(null); + + Tag tag = todo.getTagId() != null + ? tagRepository.findById(todo.getTagId()).orElse(null) + : null; + + return TodoDetailResponse.of(todo, instance, tag); + } + @Transactional public TodoStatusChangeResponse changeCompletion(Long userId, Long todoId, TodoStatusUpdateRequest request) { if (request.isCompleted() == null) { From f7a8015312b7459b36565a39e59aa73bfc573e46 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:08:53 +0900 Subject: [PATCH 333/383] =?UTF-8?q?chore:=20swagger=20=EC=98=88=EC=8B=9C?= =?UTF-8?q?=20=EA=B0=92=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index ae8f11f5..ff8afd56 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java @@ -243,6 +243,6 @@ ResponseEntity> changeTodoStatus( }) ResponseEntity> getTodoDetail( @Parameter(hidden = true) CustomUserDetails userDetails, - @Parameter(description = "조회할 TODO ID", example = "145") Long todoId + @Parameter(description = "조회할 TODO ID", example = "3") Long todoId ); } From b1e77260d01191d2449fdf4c574458c1149cf5ff Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:41:28 +0900 Subject: [PATCH 334/383] =?UTF-8?q?fix(todo):=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EC=8B=9C=EC=9E=91=EC=9D=BC?= =?UTF-8?q?=EC=9D=B4=20=EC=95=84=EB=8B=8C=20=EC=9A=94=EC=B2=AD=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=EC=9D=98=20=EC=9D=B8=EC=8A=A4=ED=84=B4=EC=8A=A4?= =?UTF-8?q?=EB=A5=BC=20=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../todo/controller/TodoController.java | 6 +++-- .../domain/todo/docs/TodoControllerDocs.java | 18 +++++++++++--- .../todo/dto/response/TodoDetailResponse.java | 4 +--- .../domain/todo/exception/TodoErrorCode.java | 1 + .../Timo/domain/todo/service/TodoService.java | 24 ++++++++++++++++--- 5 files changed, 42 insertions(+), 11 deletions(-) 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 index d05f2a1c..925a7a5a 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/controller/TodoController.java +++ b/src/main/java/com/Timo/Timo/domain/todo/controller/TodoController.java @@ -8,6 +8,7 @@ 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; @@ -50,9 +51,10 @@ public ResponseEntity> createTodo( @GetMapping("/{todoId}") public ResponseEntity> getTodoDetail( @AuthenticationPrincipal CustomUserDetails userDetails, - @PathVariable Long todoId + @PathVariable Long todoId, + @RequestParam String date ) { - TodoDetailResponse response = todoService.getTodoDetail(userDetails.getUserId(), todoId); + TodoDetailResponse response = todoService.getTodoDetail(userDetails.getUserId(), todoId, date); return ResponseEntity .status(TodoSuccessCode.GET_DETAIL.getHttpStatus()) 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 index ff8afd56..bf1a7196 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java @@ -202,11 +202,14 @@ ResponseEntity> changeTodoStatus( @Operation( summary = "TODO 상세 조회", description = """ - todoId로 단일 TODO의 상세 정보를 조회합니다. + todoId와 조회 기준 날짜(date)로 단일 TODO의 상세 정보를 조회합니다. 아이콘, 제목, 완료 여부, 날짜/요일, 예상 소요 시간, 우선순위, 태그, 반복 설정, 타이머 상태, 메모, 정렬 순서, 하위 태스크 목록을 반환합니다. + 완료 여부·타이머 상태·정렬 순서는 date에 해당하는 인스턴스 기준으로 반환되므로, + 반복 TODO의 경우 조회하려는 날짜를 date로 전달해야 합니다. + Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. """ ) @@ -216,6 +219,14 @@ ResponseEntity> changeTodoStatus( 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이 없거나 만료되었거나 유효하지 않은 경우", @@ -226,7 +237,7 @@ ResponseEntity> changeTodoStatus( ), @ApiResponse( responseCode = "404", - description = "존재하지 않는 투두인 경우", + description = "존재하지 않는 투두이거나, 전달한 date에 해당 투두가 존재하지 않는 경우", content = @Content( mediaType = "application/json", schema = @Schema(implementation = ErrorDto.class) @@ -243,6 +254,7 @@ ResponseEntity> changeTodoStatus( }) ResponseEntity> getTodoDetail( @Parameter(hidden = true) CustomUserDetails userDetails, - @Parameter(description = "조회할 TODO ID", example = "3") Long todoId + @Parameter(description = "조회할 TODO ID", example = "3") Long todoId, + @Parameter(description = "조회 기준 날짜 (yyyy-MM-dd)", example = "2026-07-22") String date ); } 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 index 0392c0ea..16bc4c64 100644 --- 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 @@ -29,9 +29,7 @@ public record TodoDetailResponse( List subtasks ) { - public static TodoDetailResponse of(Todo todo, TodoInstance instance, Tag tag) { - LocalDate date = todo.getStartDate(); - + public static TodoDetailResponse of(Todo todo, TodoInstance instance, LocalDate date, Tag tag) { return new TodoDetailResponse( todo.getId(), todo.getIcon() != null ? todo.getIcon().name() : null, 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 index 427dc5d3..b908790e 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java @@ -14,6 +14,7 @@ public enum TodoErrorCode implements BaseErrorCode { INVALID_REQUEST(HttpStatus.BAD_REQUEST, "TODO_400", "필수 필드가 누락되었거나 형식이 올바르지 않습니다."), INVALID_TITLE(HttpStatus.BAD_REQUEST, "TODO_400", "투두명은 한국어 20자/영어 30자를 초과할 수 없습니다."), TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_404", "존재하지 않는 투두입니다"), + TODO_NOT_FOUND_ON_DATE(HttpStatus.NOT_FOUND, "TODO_404", "해당 날짜에 존재하지 않는 투두입니다."), MAX_COUNT_EXCEEDED(HttpStatus.CONFLICT, "TODO_409", "해당 날짜의 투두가 최대 개수(20개)를 초과했습니다."), IS_COMPLETED_REQUIRED(HttpStatus.BAD_REQUEST, "COMMON_400", "isCompleted는 필수입니다."), TIMER_RUNNING(HttpStatus.CONFLICT, "TODO_409", "타이머가 실행 중인 TODO는 변경할 수 없습니다. 타이머를 먼저 종료해주세요."); 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 index e8886c27..b6033fc4 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java @@ -1,6 +1,7 @@ package com.Timo.Timo.domain.todo.service; import java.time.LocalDate; +import java.time.format.DateTimeParseException; import java.time.ZoneId; import java.util.List; @@ -78,19 +79,24 @@ public TodoCreateResponse createTodo(Long userId, TodoCreateRequest request) { return TodoCreateResponse.from(savedTodo); } - public TodoDetailResponse getTodoDetail(Long userId, Long todoId) { + 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(), todo.getStartDate()) + .findByTodo_IdAndDate(todo.getId(), date) .orElse(null); Tag tag = todo.getTagId() != null ? tagRepository.findById(todo.getTagId()).orElse(null) : null; - return TodoDetailResponse.of(todo, instance, tag); + return TodoDetailResponse.of(todo, instance, date, tag); } @Transactional @@ -124,6 +130,18 @@ private LocalDate resolveDate(Long userId, LocalDate requestedDate) { 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 void validateTagExists(Long tagId) { if (tagId != null && !tagRepository.existsById(tagId)) { throw new CustomException(TagErrorCode.TAG_NOT_FOUND); From 5bf15981e588a21ae90173914c7674f05ac12f26 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:20:46 +0900 Subject: [PATCH 335/383] =?UTF-8?q?refactor(todo):=20=EA=B2=BD=EA=B3=A0?= =?UTF-8?q?=EB=AC=B8=EA=B5=AC=20=ED=95=9C=EA=B8=80=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/todo/dto/response/TodoDetailResponse.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 16bc4c64..ed808d64 100644 --- 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 @@ -83,7 +83,7 @@ public record SubtaskResponse( boolean completed ) { public static SubtaskResponse from(Subtask subtask) { - Objects.requireNonNull(subtask, "subtask must not be null"); + Objects.requireNonNull(subtask, "하위 태스크는 null일 수 없습니다."); return new SubtaskResponse( subtask.getId(), subtask.getContent(), From 8bf1c0083792ae2c9c2a8372e93b9042fe437d4e Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:18:04 +0900 Subject: [PATCH 336/383] =?UTF-8?q?chore:=20rebase=20=EA=B3=BC=EC=A0=95=20?= =?UTF-8?q?=EC=A4=91=20=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/todo/exception/TodoErrorCode.java | 4 ++-- .../com/Timo/Timo/domain/todo/exception/TodoSuccessCode.java | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) 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 index b908790e..f019fac7 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java @@ -16,8 +16,8 @@ public enum TodoErrorCode implements BaseErrorCode { TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_404", "존재하지 않는 투두입니다"), TODO_NOT_FOUND_ON_DATE(HttpStatus.NOT_FOUND, "TODO_404", "해당 날짜에 존재하지 않는 투두입니다."), MAX_COUNT_EXCEEDED(HttpStatus.CONFLICT, "TODO_409", "해당 날짜의 투두가 최대 개수(20개)를 초과했습니다."), - IS_COMPLETED_REQUIRED(HttpStatus.BAD_REQUEST, "COMMON_400", "isCompleted는 필수입니다."), - TIMER_RUNNING(HttpStatus.CONFLICT, "TODO_409", "타이머가 실행 중인 TODO는 변경할 수 없습니다. 타이머를 먼저 종료해주세요."); + IS_COMPLETED_REQUIRED(HttpStatus.BAD_REQUEST, "COMMON_400", "isCompleted는 필수입니다."), + TIMER_RUNNING(HttpStatus.CONFLICT, "TODO_409", "타이머가 실행 중인 TODO는 변경할 수 없습니다. 타이머를 먼저 종료해주세요."); private final HttpStatus httpStatus; private final String code; 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 index 88a20403..ccde13ba 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoSuccessCode.java @@ -12,8 +12,9 @@ public enum TodoSuccessCode implements BaseSuccessCode { CREATED(HttpStatus.CREATED, "TODO가 생성되었습니다."), - GET_DETAIL(HttpStatus.OK, "TODO 상세 조회 성공"); - STATUS_CHANGED(HttpStatus.OK, "TODO 상태가 변경되었습니다."); + GET_DETAIL(HttpStatus.OK, "TODO 상세 조회 성공"), + STATUS_CHANGED(HttpStatus.OK, "TODO 상태가 변경되었습니다."), + ; private final HttpStatus httpStatus; private final String message; From 6306ad284267838b96c1392e067621807fc591d0 Mon Sep 17 00:00:00 2001 From: jy000n Date: Fri, 10 Jul 2026 00:48:13 +0900 Subject: [PATCH 337/383] =?UTF-8?q?refactor:=20=ED=83=80=EC=9D=B4=EB=A8=B8?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/timer/exception/TimerSuccessCode.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index f2612ff1..68ac7f6b 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/timer/exception/TimerSuccessCode.java @@ -11,9 +11,10 @@ public enum TimerSuccessCode implements BaseSuccessCode { TIMER_PAUSED(HttpStatus.OK, "TIMER_200", "타이머가 일시정지되었습니다."), TIMER_RESUMED(HttpStatus.OK, "TIMER_200", "타이머가 재개되었습니다."), + TIMER_COMPLETED(HttpStatus.OK, "TIMER_200", "타이머가 완료되었습니다."), + TIMER_STOPPED(HttpStatus.OK, "TIMER_200", "타이머가 종료되었습니다."), TIMER_STARTED(HttpStatus.CREATED, "TIMER_201", "타이머가 시작되었습니다."), - TIMER_COMPLETED(HttpStatus.OK, "TIMER_200_3", "타이머가 완료되었습니다."), - TIMER_STOPPED(HttpStatus.OK, "TIMER_200_4", "타이머가 종료되었습니다."); + ; private final HttpStatus httpStatus; private final String code; From f3b1af815f521fbe393751ad24fce4faafb82aee Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:40:50 +0900 Subject: [PATCH 338/383] =?UTF-8?q?feat(todo):=20todo=20=EC=88=9C=EC=84=9C?= =?UTF-8?q?=20=EC=A0=95=EB=A0=AC=20dto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../todo/dto/request/TodoReorderRequest.java | 9 +++++++++ .../todo/dto/response/TodoReorderResponse.java | 16 ++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoReorderRequest.java create mode 100644 src/main/java/com/Timo/Timo/domain/todo/dto/response/TodoReorderResponse.java 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/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() + ); + } +} From 03564bc38aa2a229dabe190af4991e4f0c58824f Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:41:01 +0900 Subject: [PATCH 339/383] =?UTF-8?q?feat(todo):=20todo=20=EC=88=9C=EC=84=9C?= =?UTF-8?q?=20=EC=A0=95=EB=A0=AC=20=EC=9D=91=EB=8B=B5=20=EC=BD=94=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/todo/exception/TodoErrorCode.java | 12 +++++++----- .../Timo/domain/todo/exception/TodoSuccessCode.java | 1 + 2 files changed, 8 insertions(+), 5 deletions(-) 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 index f019fac7..744e86e8 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java @@ -13,11 +13,13 @@ public enum TodoErrorCode implements BaseErrorCode { INVALID_REQUEST(HttpStatus.BAD_REQUEST, "TODO_400", "필수 필드가 누락되었거나 형식이 올바르지 않습니다."), INVALID_TITLE(HttpStatus.BAD_REQUEST, "TODO_400", "투두명은 한국어 20자/영어 30자를 초과할 수 없습니다."), - TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_404", "존재하지 않는 투두입니다"), - TODO_NOT_FOUND_ON_DATE(HttpStatus.NOT_FOUND, "TODO_404", "해당 날짜에 존재하지 않는 투두입니다."), - MAX_COUNT_EXCEEDED(HttpStatus.CONFLICT, "TODO_409", "해당 날짜의 투두가 최대 개수(20개)를 초과했습니다."), - IS_COMPLETED_REQUIRED(HttpStatus.BAD_REQUEST, "COMMON_400", "isCompleted는 필수입니다."), - TIMER_RUNNING(HttpStatus.CONFLICT, "TODO_409", "타이머가 실행 중인 TODO는 변경할 수 없습니다. 타이머를 먼저 종료해주세요."); + TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_404", "존재하지 않는 TODO입니다."), + MAX_COUNT_EXCEEDED(HttpStatus.CONFLICT, "TODO_409", "해당 날짜의 투두가 최대 개수(20개)를 초과했습니다."), + IS_COMPLETED_REQUIRED(HttpStatus.BAD_REQUEST, "COMMON_400", "isCompleted는 필수입니다."), + INVALID_INDEX(HttpStatus.BAD_REQUEST, "TODO_400", "유효하지 않은 인덱스 값입니다."), + COMPLETED_CANNOT_REORDER(HttpStatus.CONFLICT, "TODO_409", "완료된 투두는 순서를 변경할 수 없습니다."), + TIMER_RUNNING(HttpStatus.CONFLICT, "TODO_409", "타이머가 실행 중인 TODO는 변경할 수 없습니다. 타이머를 먼저 종료해주세요."), + ; private final HttpStatus httpStatus; private final String code; 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 index ccde13ba..b8bc7481 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoSuccessCode.java @@ -14,6 +14,7 @@ public enum TodoSuccessCode implements BaseSuccessCode { CREATED(HttpStatus.CREATED, "TODO가 생성되었습니다."), GET_DETAIL(HttpStatus.OK, "TODO 상세 조회 성공"), STATUS_CHANGED(HttpStatus.OK, "TODO 상태가 변경되었습니다."), + REORDERED(HttpStatus.OK, "TODO 순서가 변경되었습니다."), ; private final HttpStatus httpStatus; From d67c2e19eece62ce08b0e2fe899c96d2498ff8d8 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:41:12 +0900 Subject: [PATCH 340/383] =?UTF-8?q?feat(todo):=20todo=20=EC=88=9C=EC=84=9C?= =?UTF-8?q?=20=EC=A0=95=EB=A0=AC=20service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../todo/service/TodoInstanceReorderer.java | 49 ++++++++++++++++++- .../Timo/domain/todo/service/TodoService.java | 16 ++++++ 2 files changed, 64 insertions(+), 1 deletion(-) 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 index 82189f1b..be2e230d 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/service/TodoInstanceReorderer.java +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoInstanceReorderer.java @@ -2,6 +2,7 @@ import java.time.LocalDate; import java.util.ArrayList; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -12,8 +13,10 @@ 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; @@ -27,7 +30,7 @@ public class TodoInstanceReorderer { public TodoInstance applyCompletion(Long userId, Todo targetRule, LocalDate date, boolean completed) { Map instancesByTodoId = materializeDayGroup(userId, date); - TodoInstance target = instancesByTodoId.get(targetRule.getId()); + TodoInstance target = requireTarget(instancesByTodoId, targetRule); if (completed) { moveToCompletedBottom(target, instancesByTodoId.values()); @@ -37,6 +40,50 @@ public TodoInstance applyCompletion(Long userId, Todo targetRule, LocalDate date 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)) 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 index b6033fc4..a8e75c6e 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java @@ -13,9 +13,11 @@ import com.Timo.Timo.domain.tag.repository.TagRepository; import com.Timo.Timo.domain.timer.service.TimerService; 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.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.Todo; import com.Timo.Timo.domain.todo.entity.TodoInstance; @@ -121,6 +123,20 @@ public TodoStatusChangeResponse changeCompletion(Long userId, Long todoId, TodoS return TodoStatusChangeResponse.from(todoId, instance); } + @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); + } + private LocalDate resolveDate(Long userId, LocalDate requestedDate) { if (requestedDate != null) { return requestedDate; From 019db5fbd2e0ac43099a3207de613cf75670e4f6 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:41:22 +0900 Subject: [PATCH 341/383] =?UTF-8?q?feat(todo):=20todo=20=EC=88=9C=EC=84=9C?= =?UTF-8?q?=20=EC=A0=95=EB=A0=AC=20controller=20=EB=B0=8F=20swagger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../todo/controller/TodoController.java | 16 ++++ .../domain/todo/docs/TodoControllerDocs.java | 86 +++++++++++++++++++ 2 files changed, 102 insertions(+) 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 index 925a7a5a..8da16add 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/controller/TodoController.java +++ b/src/main/java/com/Timo/Timo/domain/todo/controller/TodoController.java @@ -13,8 +13,10 @@ import com.Timo.Timo.domain.todo.docs.TodoControllerDocs; 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.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; @@ -74,4 +76,18 @@ public ResponseEntity> changeTodoStatus( .status(TodoSuccessCode.STATUS_CHANGED.getHttpStatus()) .body(BaseResponse.onSuccess(TodoSuccessCode.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)); + } } 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 index bf1a7196..44ae728c 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java @@ -3,9 +3,11 @@ import org.springframework.http.ResponseEntity; 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.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; @@ -257,4 +259,88 @@ ResponseEntity> getTodoDetail( @Parameter(description = "조회할 TODO ID", example = "3") Long todoId, @Parameter(description = "조회 기준 날짜 (yyyy-MM-dd)", example = "2026-07-22") String date ); + + @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 + ); } From 5cdf1ce02ae564ad90e476d09dcb0fac8089ec24 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Fri, 10 Jul 2026 01:02:10 +0900 Subject: [PATCH 342/383] =?UTF-8?q?chore:=20=EC=97=90=EB=9F=AC=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20code=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 744e86e8..be65de0a 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java @@ -15,7 +15,7 @@ public enum TodoErrorCode implements BaseErrorCode { INVALID_TITLE(HttpStatus.BAD_REQUEST, "TODO_400", "투두명은 한국어 20자/영어 30자를 초과할 수 없습니다."), TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_404", "존재하지 않는 TODO입니다."), MAX_COUNT_EXCEEDED(HttpStatus.CONFLICT, "TODO_409", "해당 날짜의 투두가 최대 개수(20개)를 초과했습니다."), - IS_COMPLETED_REQUIRED(HttpStatus.BAD_REQUEST, "COMMON_400", "isCompleted는 필수입니다."), + IS_COMPLETED_REQUIRED(HttpStatus.BAD_REQUEST, "TODO_400", "isCompleted는 필수입니다."), INVALID_INDEX(HttpStatus.BAD_REQUEST, "TODO_400", "유효하지 않은 인덱스 값입니다."), COMPLETED_CANNOT_REORDER(HttpStatus.CONFLICT, "TODO_409", "완료된 투두는 순서를 변경할 수 없습니다."), TIMER_RUNNING(HttpStatus.CONFLICT, "TODO_409", "타이머가 실행 중인 TODO는 변경할 수 없습니다. 타이머를 먼저 종료해주세요."), From c1874572aedc354f8e95e7cec7fc05ba86d19ef2 Mon Sep 17 00:00:00 2001 From: jy000n Date: Fri, 10 Jul 2026 01:07:28 +0900 Subject: [PATCH 343/383] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=97=AC=EB=B0=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/timer/docs/TimerStatusControllerDocs.java | 2 +- .../Timo/domain/timer/dto/response/TimerStatusResponse.java | 1 - .../java/com/Timo/Timo/domain/timer/service/TimerService.java | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) 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 index 5136c0c8..634a7633 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStatusControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStatusControllerDocs.java @@ -79,4 +79,4 @@ ResponseEntity> changeStatus( @Valid @RequestBody TimerActionRequest request, @Parameter(hidden = true) CustomUserDetails userDetails ); -} \ No newline at end of file +} 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 index bf5f440b..0254a8c2 100644 --- 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 @@ -8,7 +8,6 @@ public record TimerStatusResponse ( Integer elapsedSeconds, Integer remainingSeconds ){ - public static TimerStatusResponse of(TimerRecord timerRecord, int elapsedSeconds){ int remainingSeconds = Math.max( 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 index 80eda840..ba924e30 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -135,7 +135,6 @@ public TimerExtendResponse extendTimer(Long userId, Long timerId, int extendMinu ); return TimerExtendResponse.of(timerRecord, remainingSeconds); - } private int calculateElapsedSeconds(Long timerRecordId, LocalDateTime now){ From 06cb13c56677e7f3d3669f5c95ee46a614b6cb48 Mon Sep 17 00:00:00 2001 From: jy000n Date: Fri, 10 Jul 2026 01:08:18 +0900 Subject: [PATCH 344/383] =?UTF-8?q?fix:=20=EC=97=B0=EC=9E=A5=20=EC=B5=9C?= =?UTF-8?q?=EB=8C=80=20=EC=9E=85=EB=A0=A5=20=EC=8B=9C=EA=B0=84=EC=9D=84=20?= =?UTF-8?q?720=EB=B6=84=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/timer/dto/request/TimerExtendRequest.java | 2 ++ 1 file changed, 2 insertions(+) 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 index 6af6ef9d..68c65b69 100644 --- 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 @@ -1,12 +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 ) {} From 1524c1403c5845276c35f2c203d0e6decd32d6d4 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 21:35:08 +0900 Subject: [PATCH 345/383] =?UTF-8?q?feat:=20=ED=86=B5=EA=B3=84=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EB=B0=8F=20=EC=84=B1=EA=B3=B5=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EC=97=90=20=EB=82=A0=EC=A7=9C=20=EA=B4=80=EB=A0=A8=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/statistics/exception/StatisticsErrorCode.java | 7 +++++-- .../domain/statistics/exception/StatisticsSuccessCode.java | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) 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 index 250f5bdc..955fee97 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/exception/StatisticsErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/exception/StatisticsErrorCode.java @@ -13,9 +13,12 @@ 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", "유효하지 않은 연월입니다."); + 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 index b0563565..e4c15200 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/exception/StatisticsSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/exception/StatisticsSuccessCode.java @@ -12,7 +12,8 @@ public enum StatisticsSuccessCode implements BaseSuccessCode { CALENDAR_RETRIEVED(HttpStatus.OK, "통계 캘린더를 조회했습니다."), - SUMMARY_RETRIEVED(HttpStatus.OK, "월별 통계 요약을 조회했습니다."); + SUMMARY_RETRIEVED(HttpStatus.OK, "월별 통계 요약을 조회했습니다."), + DAILY_RETRIEVED(HttpStatus.OK, "일별 기록 조회에 성공했습니다."); private final HttpStatus httpStatus; private final String message; From cec85586865a5c775ed0b344fd30145bb2b353d1 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 21:35:23 +0900 Subject: [PATCH 346/383] =?UTF-8?q?feat:=20=EC=9D=BC=EB=B3=84=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?StatisticsDailyDocs=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../statistics/docs/StatisticsDailyDocs.java | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/statistics/docs/StatisticsDailyDocs.java 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..c19d9ef2 --- /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 + ); +} From 2ddc561039580762e0bebe423ed06d8c9173eacd Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 21:35:41 +0900 Subject: [PATCH 347/383] =?UTF-8?q?feat:=20=EC=9D=BC=EB=B3=84=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?StatisticsDailyResponse=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/StatisticsController.java | 20 +++- .../dto/response/StatisticsDailyResponse.java | 43 +++++++ .../repository/StatisticsDailyTodo.java | 10 ++ .../repository/StatisticsQueryRepository.java | 111 ++++++++++++++++++ .../statistics/service/StatisticsService.java | 48 ++++++++ .../support/StatisticsDateParser.java | 19 +++ 6 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/Timo/Timo/domain/statistics/dto/response/StatisticsDailyResponse.java create mode 100644 src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsDailyTodo.java create mode 100644 src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java 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 index cbaf2abc..81c60fa1 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/controller/StatisticsController.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/controller/StatisticsController.java @@ -8,8 +8,10 @@ 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; @@ -23,7 +25,7 @@ @RequestMapping("/api/v1/statistics") @RequiredArgsConstructor @Tag(name = "Statistics", description = "통계 API") -public class StatisticsController implements StatisticsCalendarDocs, StatisticsSummaryDocs { +public class StatisticsController implements StatisticsCalendarDocs, StatisticsSummaryDocs, StatisticsDailyDocs { private final StatisticsService statisticsService; @@ -58,4 +60,20 @@ public ResponseEntity> getSummary( 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) + ); + } } 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..69263645 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/statistics/dto/response/StatisticsDailyResponse.java @@ -0,0 +1,43 @@ +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 = "태그 ID", example = "1") + Long tagId + ) { + } +} diff --git a/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsDailyTodo.java b/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsDailyTodo.java new file mode 100644 index 00000000..fdc5c431 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsDailyTodo.java @@ -0,0 +1,10 @@ +package com.Timo.Timo.domain.statistics.repository; + +public record StatisticsDailyTodo( + Long todoId, + String title, + Long actualSeconds, + Integer estimatedSeconds, + Long tagId +) { +} diff --git a/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java b/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java new file mode 100644 index 00000000..760be94c --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java @@ -0,0 +1,111 @@ +package com.Timo.Timo.domain.statistics.repository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.stereotype.Repository; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class StatisticsQueryRepository { + + private final EntityManager entityManager; + + public Long sumDailyTimerRecordSeconds( + Long userId, + LocalDateTime fromInclusive, + LocalDateTime toExclusive + ) { + Query query = entityManager.createNativeQuery(""" + select coalesce(sum(tr.actual_seconds), 0) + from timer_records tr + where tr.user_id = :userId + and tr.actual_seconds is not null + and coalesce(tr.ended_at, tr.started_at) >= :fromInclusive + and coalesce(tr.ended_at, tr.started_at) < :toExclusive + """) + .setParameter("userId", userId) + .setParameter("fromInclusive", fromInclusive) + .setParameter("toExclusive", toExclusive); + + return toLong(query.getSingleResult()); + } + + public List findDailyTodos( + Long userId, + LocalDate date, + LocalDateTime fromInclusive, + LocalDateTime toExclusive + ) { + Query query = entityManager.createNativeQuery(""" + select + t.id as todo_id, + t.title as title, + coalesce(sum(tr.actual_seconds), 0) as actual_seconds, + t.duration_seconds as estimated_seconds, + t.tag_id as tag_id + from todo_instances ti + join todos t on t.id = ti.todo_id + left join timer_records tr + on tr.todo_id = t.id + and tr.user_id = :userId + and tr.actual_seconds is not null + and coalesce(tr.ended_at, tr.started_at) >= :fromInclusive + and coalesce(tr.ended_at, tr.started_at) < :toExclusive + where t.user_id = :userId + and ti.instance_date = :date + group by + t.id, + t.title, + t.duration_seconds, + t.tag_id, + ti.sort_order + order by ti.sort_order asc, t.id asc + """) + .setParameter("userId", userId) + .setParameter("date", date) + .setParameter("fromInclusive", fromInclusive) + .setParameter("toExclusive", toExclusive); + + return toDailyTodos(query.getResultList()); + } + + @SuppressWarnings("unchecked") + private List toDailyTodos(List rows) { + return ((List)rows).stream() + .map(row -> new StatisticsDailyTodo( + toLong(row[0]), + (String)row[1], + toLong(row[2]), + toInteger(row[3]), + toNullableLong(row[4]) + )) + .toList(); + } + + private Long toLong(Object value) { + if (value == null) { + return 0L; + } + return ((Number)value).longValue(); + } + + private Long toNullableLong(Object value) { + if (value == null) { + return null; + } + return ((Number)value).longValue(); + } + + private Integer toInteger(Object value) { + if (value == null) { + return 0; + } + return ((Number)value).intValue(); + } +} 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 index 4001392a..2e0f433f 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/service/StatisticsService.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/service/StatisticsService.java @@ -15,7 +15,12 @@ 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.repository.StatisticsDailyTodo; +import com.Timo.Timo.domain.statistics.repository.StatisticsQueryRepository; import com.Timo.Timo.domain.statistics.support.StatisticsDateParser; import com.Timo.Timo.domain.timer.repository.TimerMonthlyRecordStats; import com.Timo.Timo.domain.timer.repository.TimerRecordRepository; @@ -35,6 +40,7 @@ public class StatisticsService { private final TodoRepository todoRepository; private final TimerRecordRepository timerRecordRepository; + private final StatisticsQueryRepository statisticsQueryRepository; private final StatisticsDateParser statisticsDateParser; public StatisticsCalendarResponse getCalendar(Long userId, String yearMonthValue) { @@ -95,6 +101,27 @@ public StatisticsSummaryResponse getSummary(Long userId, String yearMonthValue) ); } + public StatisticsDailyResponse getDaily(Long userId, String dateValue) { + LocalDate date = statisticsDateParser.parseDate(dateValue); + LocalDate nextDate = date.plusDays(1); + long totalRecordSeconds = statisticsQueryRepository.sumDailyTimerRecordSeconds( + userId, + date.atStartOfDay(), + nextDate.atStartOfDay() + ); + + List todos = statisticsQueryRepository.findDailyTodos( + userId, + date, + date.atStartOfDay(), + nextDate.atStartOfDay() + ).stream() + .map(this::toDailyTodoResponse) + .toList(); + + return new StatisticsDailyResponse(date, toMinutes(totalRecordSeconds), todos); + } + private int calculateCompletionRate(TodoDailyCompletionStats stats) { if (stats == null || stats.getTotalCount() == null || stats.getTotalCount() == 0) { return 0; @@ -104,6 +131,27 @@ private int calculateCompletionRate(TodoDailyCompletionStats stats) { return (int)Math.round(completedCount * 100.0 / stats.getTotalCount()); } + private DailyTodoResponse toDailyTodoResponse(StatisticsDailyTodo todo) { + return new DailyTodoResponse( + todo.todoId(), + todo.title(), + toMinutes(todo.actualSeconds()), + toMinutes(todo.estimatedSeconds()), + toTagResponse(todo) + ); + } + + private TagResponse toTagResponse(StatisticsDailyTodo todo) { + if (todo.tagId() == null) { + return null; + } + return new TagResponse(todo.tagId()); + } + + private long toMinutes(long seconds) { + return seconds / SECONDS_PER_MINUTE; + } + private int toInteger(Long value) { if (value == null) { return 0; 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 index 8e0a217a..6291aae2 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/support/StatisticsDateParser.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/support/StatisticsDateParser.java @@ -1,5 +1,6 @@ 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; @@ -13,7 +14,9 @@ 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()) { @@ -30,4 +33,20 @@ public YearMonth parseYearMonth(String yearMonth) { 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); + } + } } From d17969206604251cd9a96cd7b5e5feb378be26f9 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 01:37:38 +0900 Subject: [PATCH 348/383] =?UTF-8?q?feat:=20=ED=86=B5=EA=B3=84=20=EC=9D=BC?= =?UTF-8?q?=EB=B3=84=20=ED=88=AC=EB=91=90=20=EC=9D=91=EB=8B=B5=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=ED=83=9C=EA=B7=B8=20ID=EB=A5=BC=20=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=EB=AA=85=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/StatisticsDailyResponse.java | 13 +++++-------- .../repository/StatisticsDailyTodo.java | 5 ++--- .../repository/StatisticsQueryRepository.java | 16 +++++----------- .../statistics/service/StatisticsService.java | 6 +++--- 4 files changed, 15 insertions(+), 25 deletions(-) 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 index 69263645..e5764748 100644 --- 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 @@ -16,7 +16,6 @@ public record StatisticsDailyResponse( @ArraySchema(schema = @Schema(description = "해당 날짜에 계획된 투두 목록")) List todos ) { - public record DailyTodoResponse( @Schema(description = "투두 ID", example = "101") Long todoId, @@ -32,12 +31,10 @@ public record DailyTodoResponse( @Schema(description = "투두에 설정된 태그", nullable = true) TagResponse tag - ) { - } + ) {} public record TagResponse( - @Schema(description = "태그 ID", example = "1") - Long tagId - ) { - } -} + @Schema(description = "태그명", example = "과제") + String name + ) {} +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsDailyTodo.java b/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsDailyTodo.java index fdc5c431..4371d7e5 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsDailyTodo.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsDailyTodo.java @@ -5,6 +5,5 @@ public record StatisticsDailyTodo( String title, Long actualSeconds, Integer estimatedSeconds, - Long tagId -) { -} + String tagName +) {} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java b/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java index 760be94c..e4cdea3e 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java @@ -48,9 +48,10 @@ public List findDailyTodos( t.title as title, coalesce(sum(tr.actual_seconds), 0) as actual_seconds, t.duration_seconds as estimated_seconds, - t.tag_id as tag_id + tag.name as tag_name from todo_instances ti join todos t on t.id = ti.todo_id + left join tags tag on tag.id = t.tag_id left join timer_records tr on tr.todo_id = t.id and tr.user_id = :userId @@ -63,7 +64,7 @@ and coalesce(tr.ended_at, tr.started_at) < :toExclusive t.id, t.title, t.duration_seconds, - t.tag_id, + tag.name, ti.sort_order order by ti.sort_order asc, t.id asc """) @@ -83,7 +84,7 @@ private List toDailyTodos(List rows) { (String)row[1], toLong(row[2]), toInteger(row[3]), - toNullableLong(row[4]) + (String)row[4] )) .toList(); } @@ -95,17 +96,10 @@ private Long toLong(Object value) { return ((Number)value).longValue(); } - private Long toNullableLong(Object value) { - if (value == null) { - return null; - } - return ((Number)value).longValue(); - } - private Integer toInteger(Object value) { if (value == null) { return 0; } return ((Number)value).intValue(); } -} +} \ 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 index 2e0f433f..d646981e 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/service/StatisticsService.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/service/StatisticsService.java @@ -142,10 +142,10 @@ private DailyTodoResponse toDailyTodoResponse(StatisticsDailyTodo todo) { } private TagResponse toTagResponse(StatisticsDailyTodo todo) { - if (todo.tagId() == null) { + if (todo.tagName() == null) { return null; } - return new TagResponse(todo.tagId()); + return new TagResponse(todo.tagName()); } private long toMinutes(long seconds) { @@ -158,4 +158,4 @@ private int toInteger(Long value) { } return value.intValue(); } -} +} \ No newline at end of file From 3684a1f498e16a1788e1cb779fb10771ac88f671 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 01:47:01 +0900 Subject: [PATCH 349/383] =?UTF-8?q?feat:=20=ED=86=B5=EA=B3=84=20=EC=9D=BC?= =?UTF-8?q?=EB=B3=84=20=EA=B8=B0=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20TimerRecordRepository=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20StatisticsService?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/StatisticsQueryRepository.java | 105 ------------------ .../statistics/service/StatisticsService.java | 6 +- .../repository/TimerRecordRepository.java | 16 ++- .../todo/repository/TodoRepository.java | 31 +++++- 4 files changed, 47 insertions(+), 111 deletions(-) delete mode 100644 src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java diff --git a/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java b/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java deleted file mode 100644 index e4cdea3e..00000000 --- a/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java +++ /dev/null @@ -1,105 +0,0 @@ -package com.Timo.Timo.domain.statistics.repository; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; - -import org.springframework.stereotype.Repository; - -import jakarta.persistence.EntityManager; -import jakarta.persistence.Query; -import lombok.RequiredArgsConstructor; - -@Repository -@RequiredArgsConstructor -public class StatisticsQueryRepository { - - private final EntityManager entityManager; - - public Long sumDailyTimerRecordSeconds( - Long userId, - LocalDateTime fromInclusive, - LocalDateTime toExclusive - ) { - Query query = entityManager.createNativeQuery(""" - select coalesce(sum(tr.actual_seconds), 0) - from timer_records tr - where tr.user_id = :userId - and tr.actual_seconds is not null - and coalesce(tr.ended_at, tr.started_at) >= :fromInclusive - and coalesce(tr.ended_at, tr.started_at) < :toExclusive - """) - .setParameter("userId", userId) - .setParameter("fromInclusive", fromInclusive) - .setParameter("toExclusive", toExclusive); - - return toLong(query.getSingleResult()); - } - - public List findDailyTodos( - Long userId, - LocalDate date, - LocalDateTime fromInclusive, - LocalDateTime toExclusive - ) { - Query query = entityManager.createNativeQuery(""" - select - t.id as todo_id, - t.title as title, - coalesce(sum(tr.actual_seconds), 0) as actual_seconds, - t.duration_seconds as estimated_seconds, - tag.name as tag_name - from todo_instances ti - join todos t on t.id = ti.todo_id - left join tags tag on tag.id = t.tag_id - left join timer_records tr - on tr.todo_id = t.id - and tr.user_id = :userId - and tr.actual_seconds is not null - and coalesce(tr.ended_at, tr.started_at) >= :fromInclusive - and coalesce(tr.ended_at, tr.started_at) < :toExclusive - where t.user_id = :userId - and ti.instance_date = :date - group by - t.id, - t.title, - t.duration_seconds, - tag.name, - ti.sort_order - order by ti.sort_order asc, t.id asc - """) - .setParameter("userId", userId) - .setParameter("date", date) - .setParameter("fromInclusive", fromInclusive) - .setParameter("toExclusive", toExclusive); - - return toDailyTodos(query.getResultList()); - } - - @SuppressWarnings("unchecked") - private List toDailyTodos(List rows) { - return ((List)rows).stream() - .map(row -> new StatisticsDailyTodo( - toLong(row[0]), - (String)row[1], - toLong(row[2]), - toInteger(row[3]), - (String)row[4] - )) - .toList(); - } - - private Long toLong(Object value) { - if (value == null) { - return 0L; - } - return ((Number)value).longValue(); - } - - private Integer toInteger(Object value) { - if (value == null) { - return 0; - } - return ((Number)value).intValue(); - } -} \ 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 index d646981e..0d76ba1e 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/service/StatisticsService.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/service/StatisticsService.java @@ -20,7 +20,6 @@ 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.repository.StatisticsDailyTodo; -import com.Timo.Timo.domain.statistics.repository.StatisticsQueryRepository; import com.Timo.Timo.domain.statistics.support.StatisticsDateParser; import com.Timo.Timo.domain.timer.repository.TimerMonthlyRecordStats; import com.Timo.Timo.domain.timer.repository.TimerRecordRepository; @@ -40,7 +39,6 @@ public class StatisticsService { private final TodoRepository todoRepository; private final TimerRecordRepository timerRecordRepository; - private final StatisticsQueryRepository statisticsQueryRepository; private final StatisticsDateParser statisticsDateParser; public StatisticsCalendarResponse getCalendar(Long userId, String yearMonthValue) { @@ -104,13 +102,13 @@ public StatisticsSummaryResponse getSummary(Long userId, String yearMonthValue) public StatisticsDailyResponse getDaily(Long userId, String dateValue) { LocalDate date = statisticsDateParser.parseDate(dateValue); LocalDate nextDate = date.plusDays(1); - long totalRecordSeconds = statisticsQueryRepository.sumDailyTimerRecordSeconds( + long totalRecordSeconds = timerRecordRepository.sumDailyRecordSeconds( userId, date.atStartOfDay(), nextDate.atStartOfDay() ); - List todos = statisticsQueryRepository.findDailyTodos( + List todos = todoRepository.findDailyTodos( userId, date, date.atStartOfDay(), 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 index b0fe55f9..b775f831 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java @@ -36,4 +36,18 @@ TimerMonthlyRecordStats findMonthlyRecordStats( @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 sumDailyRecordSeconds( + @Param("userId") Long userId, + @Param("fromInclusive") LocalDateTime fromInclusive, + @Param("toExclusive") LocalDateTime toExclusive + ); +} \ 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 index 2b346711..0f2a86d3 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java @@ -9,6 +9,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import com.Timo.Timo.domain.statistics.repository.StatisticsDailyTodo; import com.Timo.Timo.domain.todo.entity.Todo; public interface TodoRepository extends JpaRepository { @@ -66,4 +67,32 @@ TodoMonthlySummaryStats findMonthlySummaryStats( @Param("fromInclusive") LocalDateTime fromInclusive, @Param("toExclusive") LocalDateTime toExclusive ); -} + + @Query(""" + select new com.Timo.Timo.domain.statistics.repository.StatisticsDailyTodo( + t.id, + t.title, + coalesce(sum(tr.actualSeconds), 0), + t.durationSeconds, + tag.name + ) + from TodoInstance ti + join ti.todo t + left join Tag tag on tag.id = t.tagId + left join TimerRecord tr on tr.todo = t + and tr.user.id = :userId + and tr.actualSeconds is not null + and coalesce(tr.endedAt, tr.startedAt) >= :fromInclusive + and coalesce(tr.endedAt, tr.startedAt) < :toExclusive + where t.user.id = :userId + and ti.date = :date + group by t.id, t.title, t.durationSeconds, tag.name, ti.sortOrder + order by ti.sortOrder asc, t.id asc + """) + List findDailyTodos( + @Param("userId") Long userId, + @Param("date") LocalDate date, + @Param("fromInclusive") LocalDateTime fromInclusive, + @Param("toExclusive") LocalDateTime toExclusive + ); +} \ No newline at end of file From bae2e9a16aad13b29625c6162a892b5f2128a1ad Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 01:50:25 +0900 Subject: [PATCH 350/383] =?UTF-8?q?Revert=20"feat:=20=ED=86=B5=EA=B3=84=20?= =?UTF-8?q?=EC=9D=BC=EB=B3=84=20=EA=B8=B0=EB=A1=9D=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20TimerRecordRepository=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?StatisticsService=20=EC=88=98=EC=A0=95"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 9322de17eca52f5a17bf2150caa2b44cc0798822. --- .../repository/StatisticsQueryRepository.java | 105 ++++++++++++++++++ .../statistics/service/StatisticsService.java | 6 +- .../repository/TimerRecordRepository.java | 16 +-- .../todo/repository/TodoRepository.java | 31 +----- 4 files changed, 111 insertions(+), 47 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java diff --git a/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java b/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java new file mode 100644 index 00000000..e4cdea3e --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java @@ -0,0 +1,105 @@ +package com.Timo.Timo.domain.statistics.repository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.stereotype.Repository; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class StatisticsQueryRepository { + + private final EntityManager entityManager; + + public Long sumDailyTimerRecordSeconds( + Long userId, + LocalDateTime fromInclusive, + LocalDateTime toExclusive + ) { + Query query = entityManager.createNativeQuery(""" + select coalesce(sum(tr.actual_seconds), 0) + from timer_records tr + where tr.user_id = :userId + and tr.actual_seconds is not null + and coalesce(tr.ended_at, tr.started_at) >= :fromInclusive + and coalesce(tr.ended_at, tr.started_at) < :toExclusive + """) + .setParameter("userId", userId) + .setParameter("fromInclusive", fromInclusive) + .setParameter("toExclusive", toExclusive); + + return toLong(query.getSingleResult()); + } + + public List findDailyTodos( + Long userId, + LocalDate date, + LocalDateTime fromInclusive, + LocalDateTime toExclusive + ) { + Query query = entityManager.createNativeQuery(""" + select + t.id as todo_id, + t.title as title, + coalesce(sum(tr.actual_seconds), 0) as actual_seconds, + t.duration_seconds as estimated_seconds, + tag.name as tag_name + from todo_instances ti + join todos t on t.id = ti.todo_id + left join tags tag on tag.id = t.tag_id + left join timer_records tr + on tr.todo_id = t.id + and tr.user_id = :userId + and tr.actual_seconds is not null + and coalesce(tr.ended_at, tr.started_at) >= :fromInclusive + and coalesce(tr.ended_at, tr.started_at) < :toExclusive + where t.user_id = :userId + and ti.instance_date = :date + group by + t.id, + t.title, + t.duration_seconds, + tag.name, + ti.sort_order + order by ti.sort_order asc, t.id asc + """) + .setParameter("userId", userId) + .setParameter("date", date) + .setParameter("fromInclusive", fromInclusive) + .setParameter("toExclusive", toExclusive); + + return toDailyTodos(query.getResultList()); + } + + @SuppressWarnings("unchecked") + private List toDailyTodos(List rows) { + return ((List)rows).stream() + .map(row -> new StatisticsDailyTodo( + toLong(row[0]), + (String)row[1], + toLong(row[2]), + toInteger(row[3]), + (String)row[4] + )) + .toList(); + } + + private Long toLong(Object value) { + if (value == null) { + return 0L; + } + return ((Number)value).longValue(); + } + + private Integer toInteger(Object value) { + if (value == null) { + return 0; + } + return ((Number)value).intValue(); + } +} \ 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 index 0d76ba1e..d646981e 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/service/StatisticsService.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/service/StatisticsService.java @@ -20,6 +20,7 @@ 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.repository.StatisticsDailyTodo; +import com.Timo.Timo.domain.statistics.repository.StatisticsQueryRepository; import com.Timo.Timo.domain.statistics.support.StatisticsDateParser; import com.Timo.Timo.domain.timer.repository.TimerMonthlyRecordStats; import com.Timo.Timo.domain.timer.repository.TimerRecordRepository; @@ -39,6 +40,7 @@ public class StatisticsService { private final TodoRepository todoRepository; private final TimerRecordRepository timerRecordRepository; + private final StatisticsQueryRepository statisticsQueryRepository; private final StatisticsDateParser statisticsDateParser; public StatisticsCalendarResponse getCalendar(Long userId, String yearMonthValue) { @@ -102,13 +104,13 @@ public StatisticsSummaryResponse getSummary(Long userId, String yearMonthValue) public StatisticsDailyResponse getDaily(Long userId, String dateValue) { LocalDate date = statisticsDateParser.parseDate(dateValue); LocalDate nextDate = date.plusDays(1); - long totalRecordSeconds = timerRecordRepository.sumDailyRecordSeconds( + long totalRecordSeconds = statisticsQueryRepository.sumDailyTimerRecordSeconds( userId, date.atStartOfDay(), nextDate.atStartOfDay() ); - List todos = todoRepository.findDailyTodos( + List todos = statisticsQueryRepository.findDailyTodos( userId, date, date.atStartOfDay(), 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 index b775f831..b0fe55f9 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java @@ -36,18 +36,4 @@ TimerMonthlyRecordStats findMonthlyRecordStats( @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 sumDailyRecordSeconds( - @Param("userId") Long userId, - @Param("fromInclusive") LocalDateTime fromInclusive, - @Param("toExclusive") LocalDateTime toExclusive - ); -} \ 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 index 0f2a86d3..2b346711 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java @@ -9,7 +9,6 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import com.Timo.Timo.domain.statistics.repository.StatisticsDailyTodo; import com.Timo.Timo.domain.todo.entity.Todo; public interface TodoRepository extends JpaRepository { @@ -67,32 +66,4 @@ TodoMonthlySummaryStats findMonthlySummaryStats( @Param("fromInclusive") LocalDateTime fromInclusive, @Param("toExclusive") LocalDateTime toExclusive ); - - @Query(""" - select new com.Timo.Timo.domain.statistics.repository.StatisticsDailyTodo( - t.id, - t.title, - coalesce(sum(tr.actualSeconds), 0), - t.durationSeconds, - tag.name - ) - from TodoInstance ti - join ti.todo t - left join Tag tag on tag.id = t.tagId - left join TimerRecord tr on tr.todo = t - and tr.user.id = :userId - and tr.actualSeconds is not null - and coalesce(tr.endedAt, tr.startedAt) >= :fromInclusive - and coalesce(tr.endedAt, tr.startedAt) < :toExclusive - where t.user.id = :userId - and ti.date = :date - group by t.id, t.title, t.durationSeconds, tag.name, ti.sortOrder - order by ti.sortOrder asc, t.id asc - """) - List findDailyTodos( - @Param("userId") Long userId, - @Param("date") LocalDate date, - @Param("fromInclusive") LocalDateTime fromInclusive, - @Param("toExclusive") LocalDateTime toExclusive - ); -} \ No newline at end of file +} From 8dd10d67a7998a841a999f7eeb528e3fb4b4488c Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 01:53:11 +0900 Subject: [PATCH 351/383] =?UTF-8?q?fix:=20=EA=B3=B5=EB=B0=B1=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/statistics/controller/StatisticsController.java | 2 +- .../Timo/Timo/domain/statistics/docs/StatisticsDailyDocs.java | 2 +- .../Timo/domain/statistics/docs/StatisticsSummaryDocs.java | 2 +- .../statistics/dto/response/StatisticsSummaryResponse.java | 3 +-- .../Timo/domain/statistics/exception/StatisticsErrorCode.java | 2 +- .../domain/statistics/exception/StatisticsSuccessCode.java | 2 +- .../Timo/domain/statistics/support/StatisticsDateParser.java | 2 +- 7 files changed, 7 insertions(+), 8 deletions(-) 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 index 81c60fa1..dab78155 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/controller/StatisticsController.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/controller/StatisticsController.java @@ -76,4 +76,4 @@ public ResponseEntity> getDaily( BaseResponse.onSuccess(StatisticsSuccessCode.DAILY_RETRIEVED, response) ); } -} +} \ 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 index c19d9ef2..6b7c35a5 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/docs/StatisticsDailyDocs.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/docs/StatisticsDailyDocs.java @@ -73,4 +73,4 @@ ResponseEntity> getDaily( ) 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 index a7605ca7..98cf0f00 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/docs/StatisticsSummaryDocs.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/docs/StatisticsSummaryDocs.java @@ -67,4 +67,4 @@ ResponseEntity> getSummary( ) String yearMonth ); -} +} \ 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 index ecdc65c8..5e21c1c0 100644 --- 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 @@ -17,5 +17,4 @@ public record StatisticsSummaryResponse( @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 index 955fee97..d95d45e8 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/exception/StatisticsErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/exception/StatisticsErrorCode.java @@ -21,4 +21,4 @@ public enum StatisticsErrorCode implements BaseErrorCode { 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 index e4c15200..fa64e8c5 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/exception/StatisticsSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/exception/StatisticsSuccessCode.java @@ -17,4 +17,4 @@ public enum StatisticsSuccessCode implements BaseSuccessCode { 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/support/StatisticsDateParser.java b/src/main/java/com/Timo/Timo/domain/statistics/support/StatisticsDateParser.java index 6291aae2..64a30f77 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/support/StatisticsDateParser.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/support/StatisticsDateParser.java @@ -49,4 +49,4 @@ public LocalDate parseDate(String date) { throw new CustomException(StatisticsErrorCode.INVALID_DATE); } } -} +} \ No newline at end of file From e9193fe27533f9c5cfc5880bdb500c4526accdd6 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 18:37:13 +0900 Subject: [PATCH 352/383] =?UTF-8?q?feat:=20=ED=86=B5=EA=B3=84=20=EC=9D=BC?= =?UTF-8?q?=EB=B3=84=20=EA=B8=B0=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20TimerRecordRepository=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/StatisticsDailyTodo.java | 9 -- .../repository/StatisticsQueryRepository.java | 105 ------------------ .../statistics/service/StatisticsService.java | 59 +++++++--- .../timer/repository/TimerDailyTodoStats.java | 6 + .../repository/TimerRecordRepository.java | 31 ++++++ .../repository/TodoInstanceRepository.java | 15 ++- 6 files changed, 94 insertions(+), 131 deletions(-) delete mode 100644 src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsDailyTodo.java delete mode 100644 src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java create mode 100644 src/main/java/com/Timo/Timo/domain/timer/repository/TimerDailyTodoStats.java diff --git a/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsDailyTodo.java b/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsDailyTodo.java deleted file mode 100644 index 4371d7e5..00000000 --- a/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsDailyTodo.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.Timo.Timo.domain.statistics.repository; - -public record StatisticsDailyTodo( - Long todoId, - String title, - Long actualSeconds, - Integer estimatedSeconds, - String tagName -) {} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java b/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java deleted file mode 100644 index e4cdea3e..00000000 --- a/src/main/java/com/Timo/Timo/domain/statistics/repository/StatisticsQueryRepository.java +++ /dev/null @@ -1,105 +0,0 @@ -package com.Timo.Timo.domain.statistics.repository; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; - -import org.springframework.stereotype.Repository; - -import jakarta.persistence.EntityManager; -import jakarta.persistence.Query; -import lombok.RequiredArgsConstructor; - -@Repository -@RequiredArgsConstructor -public class StatisticsQueryRepository { - - private final EntityManager entityManager; - - public Long sumDailyTimerRecordSeconds( - Long userId, - LocalDateTime fromInclusive, - LocalDateTime toExclusive - ) { - Query query = entityManager.createNativeQuery(""" - select coalesce(sum(tr.actual_seconds), 0) - from timer_records tr - where tr.user_id = :userId - and tr.actual_seconds is not null - and coalesce(tr.ended_at, tr.started_at) >= :fromInclusive - and coalesce(tr.ended_at, tr.started_at) < :toExclusive - """) - .setParameter("userId", userId) - .setParameter("fromInclusive", fromInclusive) - .setParameter("toExclusive", toExclusive); - - return toLong(query.getSingleResult()); - } - - public List findDailyTodos( - Long userId, - LocalDate date, - LocalDateTime fromInclusive, - LocalDateTime toExclusive - ) { - Query query = entityManager.createNativeQuery(""" - select - t.id as todo_id, - t.title as title, - coalesce(sum(tr.actual_seconds), 0) as actual_seconds, - t.duration_seconds as estimated_seconds, - tag.name as tag_name - from todo_instances ti - join todos t on t.id = ti.todo_id - left join tags tag on tag.id = t.tag_id - left join timer_records tr - on tr.todo_id = t.id - and tr.user_id = :userId - and tr.actual_seconds is not null - and coalesce(tr.ended_at, tr.started_at) >= :fromInclusive - and coalesce(tr.ended_at, tr.started_at) < :toExclusive - where t.user_id = :userId - and ti.instance_date = :date - group by - t.id, - t.title, - t.duration_seconds, - tag.name, - ti.sort_order - order by ti.sort_order asc, t.id asc - """) - .setParameter("userId", userId) - .setParameter("date", date) - .setParameter("fromInclusive", fromInclusive) - .setParameter("toExclusive", toExclusive); - - return toDailyTodos(query.getResultList()); - } - - @SuppressWarnings("unchecked") - private List toDailyTodos(List rows) { - return ((List)rows).stream() - .map(row -> new StatisticsDailyTodo( - toLong(row[0]), - (String)row[1], - toLong(row[2]), - toInteger(row[3]), - (String)row[4] - )) - .toList(); - } - - private Long toLong(Object value) { - if (value == null) { - return 0L; - } - return ((Number)value).longValue(); - } - - private Integer toInteger(Object value) { - if (value == null) { - return 0; - } - return ((Number)value).intValue(); - } -} \ 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 index d646981e..513a3fa4 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/service/StatisticsService.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/service/StatisticsService.java @@ -6,6 +6,7 @@ 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; @@ -19,12 +20,16 @@ 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.repository.StatisticsDailyTodo; -import com.Timo.Timo.domain.statistics.repository.StatisticsQueryRepository; 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; @@ -39,8 +44,9 @@ public class StatisticsService { private static final int SECONDS_PER_MINUTE = 60; private final TodoRepository todoRepository; + private final TodoInstanceRepository todoInstanceRepository; private final TimerRecordRepository timerRecordRepository; - private final StatisticsQueryRepository statisticsQueryRepository; + private final TagRepository tagRepository; private final StatisticsDateParser statisticsDateParser; public StatisticsCalendarResponse getCalendar(Long userId, String yearMonthValue) { @@ -104,19 +110,23 @@ public StatisticsSummaryResponse getSummary(Long userId, String yearMonthValue) public StatisticsDailyResponse getDaily(Long userId, String dateValue) { LocalDate date = statisticsDateParser.parseDate(dateValue); LocalDate nextDate = date.plusDays(1); - long totalRecordSeconds = statisticsQueryRepository.sumDailyTimerRecordSeconds( + long totalRecordSeconds = timerRecordRepository.sumActualSeconds( userId, date.atStartOfDay(), nextDate.atStartOfDay() ); - List todos = statisticsQueryRepository.findDailyTodos( + Map actualSecondsByTodoId = timerRecordRepository.findDailyTodoStats( userId, - date, date.atStartOfDay(), nextDate.atStartOfDay() ).stream() - .map(this::toDailyTodoResponse) + .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); @@ -131,21 +141,38 @@ private int calculateCompletionRate(TodoDailyCompletionStats stats) { return (int)Math.round(completedCount * 100.0 / stats.getTotalCount()); } - private DailyTodoResponse toDailyTodoResponse(StatisticsDailyTodo todo) { + private DailyTodoResponse toDailyTodoResponse( + TodoInstance instance, + Map actualSecondsByTodoId, + Map tagNamesById + ) { + Todo todo = instance.getTodo(); return new DailyTodoResponse( - todo.todoId(), - todo.title(), - toMinutes(todo.actualSeconds()), - toMinutes(todo.estimatedSeconds()), - toTagResponse(todo) + todo.getId(), + todo.getTitle(), + toMinutes(actualSecondsByTodoId.getOrDefault(todo.getId(), 0L)), + toMinutes(todo.getDurationSeconds()), + toTagResponse(todo.getTagId(), tagNamesById) ); } - private TagResponse toTagResponse(StatisticsDailyTodo todo) { - if (todo.tagName() == null) { + 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(todo.tagName()); + return new TagResponse(tagNamesById.get(tagId)); } private long toMinutes(long seconds) { 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/TimerRecordRepository.java b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java index b0fe55f9..cca05d0c 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java @@ -36,4 +36,35 @@ TimerMonthlyRecordStats findMonthlyRecordStats( @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 + ); } 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 index 1a7f2fa5..1edfdb3c 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoInstanceRepository.java +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoInstanceRepository.java @@ -14,6 +14,19 @@ public interface TodoInstanceRepository extends JpaRepository findByTodo_IdAndDate(Long todoId, LocalDate date); + @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 @@ -24,4 +37,4 @@ List findByTodoIdsAndDateRange( @Param("from") LocalDate from, @Param("to") LocalDate to ); -} +} \ No newline at end of file From f958cc7569283c4eb26e8186b0431511b476fcf0 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 18:45:20 +0900 Subject: [PATCH 353/383] =?UTF-8?q?chore=20:=20=EA=B3=B5=EB=B0=B1=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/todo/repository/TodoDailyCompletionStats.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 index 492c2301..6511b7ef 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoDailyCompletionStats.java +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoDailyCompletionStats.java @@ -3,10 +3,7 @@ import java.time.LocalDate; public interface TodoDailyCompletionStats { - LocalDate getDate(); - Long getTotalCount(); - Long getCompletedCount(); -} +} \ No newline at end of file From be3ba02dbcefc387668351f040a8529d2cbe7302 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Fri, 10 Jul 2026 01:47:21 +0900 Subject: [PATCH 354/383] =?UTF-8?q?refactor:=20notnull=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../todo/dto/request/SubtaskStatusUpdateRequest.java | 3 +++ .../domain/todo/dto/request/TodoStatusUpdateRequest.java | 3 +++ .../Timo/Timo/domain/todo/exception/TodoErrorCode.java | 1 - .../com/Timo/Timo/domain/todo/service/TodoService.java | 8 -------- 4 files changed, 6 insertions(+), 9 deletions(-) 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 index b99d9958..0d87c51c 100644 --- 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 @@ -2,7 +2,10 @@ 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/TodoStatusUpdateRequest.java b/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoStatusUpdateRequest.java index cf359cbc..30be5632 100644 --- 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 @@ -4,7 +4,10 @@ import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotNull; + public record TodoStatusUpdateRequest( + @NotNull @JsonProperty("isCompleted") Boolean isCompleted, 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 index 4d8263ed..327ad3a0 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java @@ -15,7 +15,6 @@ public enum TodoErrorCode implements BaseErrorCode { INVALID_TITLE(HttpStatus.BAD_REQUEST, "TODO_400", "투두명은 한국어 20자/영어 30자를 초과할 수 없습니다."), TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_404", "존재하지 않는 TODO입니다."), MAX_COUNT_EXCEEDED(HttpStatus.CONFLICT, "TODO_409", "해당 날짜의 투두가 최대 개수(20개)를 초과했습니다."), - IS_COMPLETED_REQUIRED(HttpStatus.BAD_REQUEST, "COMMON_400", "isCompleted는 필수입니다."), SUBTASK_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_404", "존재하지 않는 하위 투두입니다."), TIMER_RUNNING(HttpStatus.CONFLICT, "TODO_409", "타이머가 실행 중인 TODO는 변경할 수 없습니다. 타이머를 먼저 종료해주세요."), ; 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 index 96f4aed4..b7ac695e 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java @@ -79,10 +79,6 @@ public TodoCreateResponse createTodo(Long userId, TodoCreateRequest request) { @Transactional public TodoStatusChangeResponse changeCompletion(Long userId, Long todoId, TodoStatusUpdateRequest request) { - if (request.isCompleted() == null) { - throw new CustomException(TodoErrorCode.IS_COMPLETED_REQUIRED); - } - Todo todo = todoRepository.findByIdAndUser_Id(todoId, userId) .orElseThrow(() -> new CustomException(TodoErrorCode.TODO_NOT_FOUND)); @@ -102,10 +98,6 @@ public TodoStatusChangeResponse changeCompletion(Long userId, Long todoId, TodoS @Transactional public SubtaskStatusChangeResponse changeSubtaskCompletion( Long userId, Long todoId, Long subtaskId, SubtaskStatusUpdateRequest request) { - if (request.isCompleted() == null) { - throw new CustomException(TodoErrorCode.IS_COMPLETED_REQUIRED); - } - Todo todo = todoRepository.findByIdAndUser_Id(todoId, userId) .orElseThrow(() -> new CustomException(TodoErrorCode.TODO_NOT_FOUND)); From 09eeb0f4eb770ff3e40696a067bbf0be135c7468 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Fri, 10 Jul 2026 01:31:49 +0900 Subject: [PATCH 355/383] =?UTF-8?q?fix(statistics):=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20=EA=B3=B5=EB=B0=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../statistics/dto/response/StatisticsCalendarResponse.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 index ad77a537..d90f521a 100644 --- 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 @@ -23,6 +23,5 @@ public record DayCompletionResponse( @Schema(description = "투두 완료율. 투두가 없으면 0", example = "50") Integer completionRate - ) { - } + ) { } } \ No newline at end of file From d5e36c0353ab8f228951638b5d40fc3e10110e1f Mon Sep 17 00:00:00 2001 From: aneykrap Date: Fri, 10 Jul 2026 01:32:29 +0900 Subject: [PATCH 356/383] =?UTF-8?q?fix(statistics):=ED=83=80=EC=9E=84?= =?UTF-8?q?=EC=A1=B4=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../statistics/service/StatisticsService.java | 67 +++++++++++++++---- .../repository/TimerRecordRepository.java | 14 ++++ .../todo/repository/TodoRepository.java | 13 ++++ 3 files changed, 82 insertions(+), 12 deletions(-) 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 index 513a3fa4..db2a47b9 100644 --- a/src/main/java/com/Timo/Timo/domain/statistics/service/StatisticsService.java +++ b/src/main/java/com/Timo/Timo/domain/statistics/service/StatisticsService.java @@ -1,7 +1,9 @@ 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; @@ -32,6 +34,10 @@ 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; @@ -48,10 +54,12 @@ public class StatisticsService { 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(ZoneOffset.UTC); + LocalDate today = LocalDate.now(userZone); LocalDate startDate = yearMonth.atDay(1); LocalDate endDate = yearMonth.atEndOfMonth(); @@ -77,30 +85,40 @@ public StatisticsCalendarResponse getCalendar(Long userId, String yearMonthValue } 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, - startDate.atStartOfDay(), - nextMonthStartDate.atStartOfDay() + fromInclusive, + toExclusive ); TodoMonthlySummaryStats todoStats = todoRepository.findMonthlySummaryStats( userId, - startDate.atStartOfDay(), - nextMonthStartDate.atStartOfDay() + fromInclusive, + toExclusive ); long totalRecordSeconds = timerStats.getTotalRecordSeconds(); - long timerRecordedDayCount = timerStats.getTimerRecordedDayCount(); + 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, - toInteger(todoStats.getActiveDayCount()), + activeDayCount, averageRecordedMinutes, toInteger(todoStats.getCompletedTodoCount()), toInteger(todoStats.getTotalTodoCount()) @@ -108,18 +126,21 @@ public StatisticsSummaryResponse getSummary(Long userId, String yearMonthValue) } 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, - date.atStartOfDay(), - nextDate.atStartOfDay() + fromInclusive, + toExclusive ); Map actualSecondsByTodoId = timerRecordRepository.findDailyTodoStats( userId, - date.atStartOfDay(), - nextDate.atStartOfDay() + fromInclusive, + toExclusive ).stream() .collect(Collectors.toMap(TimerDailyTodoStats::getTodoId, TimerDailyTodoStats::getActualSeconds)); @@ -132,6 +153,28 @@ public StatisticsDailyResponse getDaily(Long userId, String dateValue) { 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; @@ -185,4 +228,4 @@ private int toInteger(Long value) { } return value.intValue(); } -} \ 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 index cca05d0c..b5899290 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java @@ -67,4 +67,18 @@ List findDailyTodoStats( @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/todo/repository/TodoRepository.java b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java index 2b346711..b6cff949 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java @@ -66,4 +66,17 @@ TodoMonthlySummaryStats findMonthlySummaryStats( @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 + ); } From 4da12b6e1766bec8175ba5686657b75528efe7ee Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 9 Jul 2026 12:51:29 +0900 Subject: [PATCH 357/383] =?UTF-8?q?feat:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C/=EC=A2=85=EB=A3=8C=20API=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/timer/controller/TimerController.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 index f19e1fbe..c0ec6813 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java +++ b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java @@ -1,16 +1,16 @@ package com.Timo.Timo.domain.timer.controller; -import com.Timo.Timo.domain.timer.docs.TimerExtendControllerDocs; -import com.Timo.Timo.domain.timer.docs.TimerCompleteControllerDocs; -import com.Timo.Timo.domain.timer.docs.TimerStopControllerDocs; -import com.Timo.Timo.domain.timer.dto.response.TimerFinishResponse; 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; From f989816c67f13c0143997a6e6a8f1e959aebf9d6 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 17:29:30 +0900 Subject: [PATCH 358/383] chore: trigger CodeRabbit review From fa801801668c67387ae0b6ebd6a03b06e1130b74 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 12:58:54 +0900 Subject: [PATCH 359/383] =?UTF-8?q?feat:=20AI=20=EC=8A=A4=EC=9B=A8?= =?UTF-8?q?=EA=B1=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/ai/docs/AiFeedbackDocs.java | 93 +++++++++++++++++++ .../Timo/Timo/domain/ai/docs/AiTodoDocs.java | 1 - 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/Timo/Timo/domain/ai/docs/AiFeedbackDocs.java diff --git a/src/main/java/com/Timo/Timo/domain/ai/docs/AiFeedbackDocs.java b/src/main/java/com/Timo/Timo/domain/ai/docs/AiFeedbackDocs.java new file mode 100644 index 00000000..9b7c1324 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/docs/AiFeedbackDocs.java @@ -0,0 +1,93 @@ +package com.Timo.Timo.domain.ai.docs; + +import org.springframework.http.ResponseEntity; + +import com.Timo.Timo.domain.ai.dto.request.CreateTodoFeedbackRequest; +import com.Timo.Timo.domain.ai.dto.response.CreateTodoFeedbackResponse; +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 AiFeedbackDocs { + + @Operation( + tags = "AI Feedback", + summary = "AI 투두 수행 피드백 생성", + description = """ + 사용자가 수행한 투두의 예상 소요 시간과 실제 소요 시간을 비교해 짧은 피드백을 생성합니다. + + 서버는 투두 ID로 투두 정보와 가장 최근 실제 소요시간을 조회합니다. + 1. 이번 태스크의 예상 소요 시간과 실제 소요 시간 + 2. 현재 투두명과 비슷한 과거 투두의 실제 소요시간 기록 + 3. 같은 태그의 최근 실제 소요시간 기록 + + Gemini는 현재 결과 관찰, 과거 패턴 해석, 다음 행동 추천을 1~2문장으로 압축해 반환합니다. + 비슷한 투두명 기록을 우선 참고하고, 없으면 같은 태그 기록을 참고합니다. + 둘 다 없으면 이번 태스크의 연장 또는 조기 종료 여부를 기준으로 피드백합니다. + RPM, RPD, TPM 제한을 초과하면 Gemini 호출 전 429 응답을 반환합니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "AI 투두 수행 피드백 생성 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = "투두 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 = "투두가 존재하지 않거나 해당 사용자의 투두가 아닌 경우", + 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> createFeedback( + @Parameter(hidden = true) CustomUserDetails userDetails, + @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + description = "피드백을 생성할 투두 ID", + content = @Content(schema = @Schema(implementation = CreateTodoFeedbackRequest.class)) + ) + CreateTodoFeedbackRequest request + ); +} \ No newline at end of file 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 index 1972f93f..7dcf1673 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java +++ b/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java @@ -28,7 +28,6 @@ public interface AiTodoDocs { 3. 기록이 없으면 현재 투두명 기준 Gemini는 위 기록을 종합해 예상 소요 시간을 생성합니다. - 호출량과 토큰 사용량을 줄이기 위해 각 기록은 최근 순 최대 5개씩만 전달합니다. RPM, RPD, TPM 제한을 초과하면 Gemini 호출 전 429 응답을 반환합니다. """ ) From b08937b0bc136acc2d9b42d9833964047ad3eb2f Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 12:59:04 +0900 Subject: [PATCH 360/383] =?UTF-8?q?feat:=20AI=20=ED=88=AC=EB=91=90=20?= =?UTF-8?q?=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/ai/exception/AiSuccessCode.java | 5 +++-- .../com/Timo/Timo/domain/todo/exception/TodoErrorCode.java | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) 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 index 0be33414..9f12217f 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/exception/AiSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/ai/exception/AiSuccessCode.java @@ -11,8 +11,9 @@ @RequiredArgsConstructor public enum AiSuccessCode implements BaseSuccessCode { - DURATION_RECOMMENDED(HttpStatus.OK, "AI 예상 소요 시간을 추천했습니다."); + DURATION_RECOMMENDED(HttpStatus.OK, "AI 예상 소요 시간을 추천했습니다."), + TODO_FEEDBACK_CREATED(HttpStatus.OK, "AI 투두 피드백을 생성했습니다."); private final HttpStatus httpStatus; private final String message; -} +} \ No newline at end of file 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 index 8673ab4e..533d105d 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java @@ -16,7 +16,7 @@ public enum TodoErrorCode implements BaseErrorCode { NO_UPDATE_FIELDS(HttpStatus.BAD_REQUEST, "TODO_400", "수정할 필드가 없습니다."), IS_COMPLETED_REQUIRED(HttpStatus.BAD_REQUEST, "TODO_400", "isCompleted는 필수입니다."), INVALID_INDEX(HttpStatus.BAD_REQUEST, "TODO_400", "유효하지 않은 인덱스 값입니다."), - TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_404", "존재하지 않는 TODO입니다."), + 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개)를 초과했습니다."), From 836667fa18aa75a9b9ea0ad00ab312827e631643 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 13:00:03 +0900 Subject: [PATCH 361/383] =?UTF-8?q?feat:=20request&response=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/dto/request/CreateTodoFeedbackRequest.java | 12 ++++++++++++ .../ai/dto/response/CreateTodoFeedbackResponse.java | 8 ++++++++ 2 files changed, 20 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/ai/dto/request/CreateTodoFeedbackRequest.java create mode 100644 src/main/java/com/Timo/Timo/domain/ai/dto/response/CreateTodoFeedbackResponse.java diff --git a/src/main/java/com/Timo/Timo/domain/ai/dto/request/CreateTodoFeedbackRequest.java b/src/main/java/com/Timo/Timo/domain/ai/dto/request/CreateTodoFeedbackRequest.java new file mode 100644 index 00000000..d505db7a --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/dto/request/CreateTodoFeedbackRequest.java @@ -0,0 +1,12 @@ +package com.Timo.Timo.domain.ai.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +public record CreateTodoFeedbackRequest( + @NotNull + @Positive + @Schema(description = "피드백을 생성할 투두 ID") + Long todoId +) {} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/dto/response/CreateTodoFeedbackResponse.java b/src/main/java/com/Timo/Timo/domain/ai/dto/response/CreateTodoFeedbackResponse.java new file mode 100644 index 00000000..00a5abf0 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/dto/response/CreateTodoFeedbackResponse.java @@ -0,0 +1,8 @@ +package com.Timo.Timo.domain.ai.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record CreateTodoFeedbackResponse( + @Schema(description = "AI 투두 수행 피드백") + String feedback +) {} \ No newline at end of file From 373f094972e8454113015d7f65ca860590285b5f Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 13:00:36 +0900 Subject: [PATCH 362/383] feat: prompt --- .../ai/prompt/TodoFeedbackPromptBuilder.java | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/ai/prompt/TodoFeedbackPromptBuilder.java 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 From 72eb2b8f729e8c65e850e874fcec8f82a91db404 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 13:01:13 +0900 Subject: [PATCH 363/383] =?UTF-8?q?feat:=20AI=20feedback=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EB=B0=8F=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/controller/AiTodoController.java | 33 +++++++-- .../ai/repository/AiTodoQueryRepository.java | 42 +++++++++++- .../Timo/domain/ai/service/AiTodoService.java | 67 ++++++++++++++++++- 3 files changed, 136 insertions(+), 6 deletions(-) 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 index 661b4c23..ad0fa162 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java +++ b/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java @@ -7,8 +7,11 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.Timo.Timo.domain.ai.docs.AiFeedbackDocs; import com.Timo.Timo.domain.ai.docs.AiTodoDocs; +import com.Timo.Timo.domain.ai.dto.request.CreateTodoFeedbackRequest; import com.Timo.Timo.domain.ai.dto.request.RecommendDurationRequest; +import com.Timo.Timo.domain.ai.dto.response.CreateTodoFeedbackResponse; 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; @@ -22,15 +25,15 @@ @Slf4j @RestController -@RequestMapping("/api/v1/todos") +@RequestMapping("/api/v1") @RequiredArgsConstructor @Tag(name = "AI Todo", description = "투두 AI API") -public class AiTodoController implements AiTodoDocs { +public class AiTodoController implements AiTodoDocs, AiFeedbackDocs { private final AiTodoService aiTodoService; @Override - @PostMapping("/recommend-duration") + @PostMapping("/todos/recommend-duration") public ResponseEntity> recommendDuration( @AuthenticationPrincipal CustomUserDetails userDetails, @Valid @RequestBody RecommendDurationRequest request @@ -50,4 +53,26 @@ public ResponseEntity> recommendDuration BaseResponse.onSuccess(AiSuccessCode.DURATION_RECOMMENDED, response) ); } -} + + @Override + @PostMapping("/ai/feedback") + public ResponseEntity> createFeedback( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody CreateTodoFeedbackRequest request + ) { + log.info( + "AI todo feedback API called. userId={}, todoId={}", + userDetails.getUserId(), + request.todoId() + ); + + CreateTodoFeedbackResponse response = aiTodoService.createFeedback( + userDetails.getUserId(), + request + ); + + return ResponseEntity.ok( + BaseResponse.onSuccess(AiSuccessCode.TODO_FEEDBACK_CREATED, response) + ); + } +} \ 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 index 9f9cbfae..6002f009 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java +++ b/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java @@ -8,6 +8,7 @@ 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 jakarta.persistence.Query; @@ -19,6 +20,45 @@ public class AiTodoQueryRepository { private final EntityManager entityManager; + public TodoFeedbackSource findFeedbackSource(Long userId, Long todoId) { + List rows = entityManager.createNativeQuery(""" + select + t.title, + t.tag_id, + tag.name, + t.duration_seconds, + coalesce(( + select tr.actual_seconds + from timer_records tr + where tr.todo_id = t.id + and tr.user_id = :userId + and tr.actual_seconds is not null + order by coalesce(tr.ended_at, tr.started_at) desc, tr.id desc + limit 1 + ), 0) + from todos t + left join tags tag on tag.id = t.tag_id + where t.id = :todoId + and t.user_id = :userId + """) + .setParameter("userId", userId) + .setParameter("todoId", todoId) + .getResultList(); + + if (rows.isEmpty()) { + return null; + } + + Object[] row = (Object[])rows.get(0); + return new TodoFeedbackSource( + (String)row[0], + toLong(row[1]), + (String)row[2], + toInteger(row[3]), + toInteger(row[4]) + ); + } + public List findActualDurationHistoriesBySimilarTitle( Long userId, String title, @@ -123,4 +163,4 @@ private LocalDate toLocalDate(Object value) { } return LocalDate.parse(value.toString()); } -} +} \ No newline at end of file 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 index dd4a4152..c8acb068 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java @@ -5,10 +5,18 @@ import org.springframework.stereotype.Service; +import com.Timo.Timo.domain.ai.dto.request.CreateTodoFeedbackRequest; +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.CreateTodoFeedbackResponse; 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.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.global.exception.CustomException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; @@ -24,7 +32,9 @@ public class AiTodoService { private static final int TOKEN_ESTIMATE_CHAR_DIVISOR = 4; private final AiTodoHistoryService historyService; + private final AiTodoQueryRepository queryRepository; private final TodoDurationPromptBuilder promptBuilder; + private final TodoFeedbackPromptBuilder feedbackPromptBuilder; private final GeminiService geminiService; private final ObjectMapper objectMapper; private final AiRequestRateLimiter rateLimiter; @@ -58,6 +68,39 @@ public RecommendDurationResponse recommendDuration(Long userId, RecommendDuratio return validate(recommendation); } + public CreateTodoFeedbackResponse createFeedback(Long userId, CreateTodoFeedbackRequest request) { + LocalDate today = LocalDate.now(ZoneOffset.UTC); + TodoFeedbackSource source = queryRepository.findFeedbackSource(userId, request.todoId()); + if (source == null) { + throw new CustomException(TodoErrorCode.TODO_NOT_FOUND); + } + + AiTodoHistories histories = historyService.findHistories( + userId, + source.title(), + source.tagId(), + today, + 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 validate(feedback); + } + private GeminiDurationRecommendation parseRecommendation(String geminiJson) { try { return objectMapper.readValue( @@ -69,6 +112,17 @@ private GeminiDurationRecommendation parseRecommendation(String geminiJson) { } } + private GeminiTodoFeedback parseFeedback(String geminiJson) { + try { + return objectMapper.readValue( + stripMarkdownFence(geminiJson), + GeminiTodoFeedback.class + ); + } catch (Exception exception) { + throw new IllegalStateException("Failed to parse Gemini todo feedback.", exception); + } + } + private RecommendDurationResponse validate(GeminiDurationRecommendation recommendation) { if (recommendation == null || recommendation.recommendedMinutes() == null @@ -81,6 +135,17 @@ private RecommendDurationResponse validate(GeminiDurationRecommendation recommen return new RecommendDurationResponse(recommendedMinutes); } + private CreateTodoFeedbackResponse validate(GeminiTodoFeedback feedback) { + if (feedback == null + || feedback.feedback() == null + || feedback.feedback().isBlank() + ) { + throw new IllegalArgumentException("Gemini feedback has missing fields."); + } + + return new CreateTodoFeedbackResponse(feedback.feedback().trim()); + } + private int normalizeMinutes(int minutes) { return Math.max(1, minutes); } @@ -99,4 +164,4 @@ private String stripMarkdownFence(String value) { } return trimmed; } -} +} \ No newline at end of file From 34f894b1da7a14ec6ad42ed76b500808f7528642 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 13:01:30 +0900 Subject: [PATCH 364/383] =?UTF-8?q?feat:=20=EC=A0=9C=EB=AF=B8=EB=82=98?= =?UTF-8?q?=EC=9D=B4=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/ai/dto/TodoFeedbackSource.java | 9 +++++++++ .../Timo/domain/ai/dto/response/GeminiTodoFeedback.java | 3 +++ 2 files changed, 12 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/ai/dto/TodoFeedbackSource.java create mode 100644 src/main/java/com/Timo/Timo/domain/ai/dto/response/GeminiTodoFeedback.java 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/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 From d346ba28032051cce59671a9a6107edc5bdcd1a6 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 14:32:51 +0900 Subject: [PATCH 365/383] =?UTF-8?q?chore:=20=EA=B3=B5=EB=B0=B1=20=EB=B0=8F?= =?UTF-8?q?=20=EC=97=AC=EB=B0=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java | 2 +- .../Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java | 2 +- .../com/Timo/Timo/domain/ai/service/AiRequestRateLimiter.java | 2 +- .../java/com/Timo/Timo/domain/ai/service/AiTodoHistories.java | 3 +-- .../com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java | 2 +- .../java/com/Timo/Timo/domain/ai/service/GeminiService.java | 2 +- 6 files changed, 6 insertions(+), 7 deletions(-) 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 index 7dcf1673..9a49e488 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java +++ b/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java @@ -79,4 +79,4 @@ ResponseEntity> recommendDuration( ) RecommendDurationRequest request ); -} +} \ No newline at end of file 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 index 5d791f4b..b1a5fcba 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java +++ b/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java @@ -89,4 +89,4 @@ private String escapeJsonString(String value) { .replace("\r", " ") .replace("\t", " "); } -} +} \ 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 index edc5410f..e3a5a94e 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiRequestRateLimiter.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiRequestRateLimiter.java @@ -136,4 +136,4 @@ private long secondsUntilNextDay() { 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 index 8b3cb07a..8f90cc0f 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistories.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistories.java @@ -7,5 +7,4 @@ 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 index f608a430..7e5da687 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java @@ -43,4 +43,4 @@ public AiTodoHistories findHistories( return new AiTodoHistories(similarTitleHistories, recentTagHistories); } -} +} \ 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 index add42a84..20b95277 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java @@ -92,4 +92,4 @@ private String extractText(String response) { throw new IllegalStateException("Failed to parse Gemini response.", exception); } } -} +} \ No newline at end of file From 31455a9e17602ca82d8cc762192e0a716ced23da Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 19:35:44 +0900 Subject: [PATCH 366/383] =?UTF-8?q?feat:=20AI=20=ED=83=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=B0=8F=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/Timo/Timo/domain/ai/docs/AiFeedbackDocs.java | 2 +- src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/Timo/Timo/domain/ai/docs/AiFeedbackDocs.java b/src/main/java/com/Timo/Timo/domain/ai/docs/AiFeedbackDocs.java index 9b7c1324..d33b25cd 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/docs/AiFeedbackDocs.java +++ b/src/main/java/com/Timo/Timo/domain/ai/docs/AiFeedbackDocs.java @@ -18,7 +18,7 @@ public interface AiFeedbackDocs { @Operation( - tags = "AI Feedback", + tags = "AI", summary = "AI 투두 수행 피드백 생성", description = """ 사용자가 수행한 투두의 예상 소요 시간과 실제 소요 시간을 비교해 짧은 피드백을 생성합니다. 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 index 9a49e488..330d496b 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java +++ b/src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java @@ -18,6 +18,7 @@ public interface AiTodoDocs { @Operation( + tags = "AI", summary = "AI 예상 소요 시간 추천", description = """ 투두명과 태그를 기준으로 예상 소요 시간을 추천합니다. From 9c7811fbdc01d683be2fa2056f32880127de5f4a Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 19:36:30 +0900 Subject: [PATCH 367/383] =?UTF-8?q?docs:=20AI=20=ED=94=BC=EB=93=9C?= =?UTF-8?q?=EB=B0=B1=20=EB=B0=98=EC=98=81=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/timer/docs/TimerCompleteControllerDocs.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 7c83b01e..bb601ee1 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerCompleteControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerCompleteControllerDocs.java @@ -21,7 +21,7 @@ public interface TimerCompleteControllerDocs { 예상 소요 시간이 모두 경과하여 타이머를 자동 종료합니다. 종료 시각 기록, 실제 수행 시간 계산 (status → COMPLETED) 해당 날짜 TodoInstance 완료 처리 및 타이머 상태 초기화 - aiFeedback은 현재 null로 반환되며, 추후 AI 연동 예정 + 계획 시간과 실제 수행 기록을 분석한 AI 피드백 반환 """ ) @ApiResponses({ From 338226ef019021bae5843913c3cbc5036aff4370 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 19:37:11 +0900 Subject: [PATCH 368/383] =?UTF-8?q?feat:=20ai=20todo=20=EC=86=8C=EC=9A=94?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/ai/controller/AiTodoController.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 index ad0fa162..f59b0acd 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java +++ b/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java @@ -18,7 +18,6 @@ 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 lombok.extern.slf4j.Slf4j; @@ -27,13 +26,12 @@ @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor -@Tag(name = "AI Todo", description = "투두 AI API") public class AiTodoController implements AiTodoDocs, AiFeedbackDocs { private final AiTodoService aiTodoService; @Override - @PostMapping("/todos/recommend-duration") + @PostMapping("/ai/duration") public ResponseEntity> recommendDuration( @AuthenticationPrincipal CustomUserDetails userDetails, @Valid @RequestBody RecommendDurationRequest request From f186350c0203787ae69fcd27ff94b35ead3ee3b6 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 19:37:49 +0900 Subject: [PATCH 369/383] =?UTF-8?q?feat:=20AI=20=ED=94=BC=EB=93=9C?= =?UTF-8?q?=EB=B0=B1=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20response=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/timer/docs/TimerStopControllerDocs.java | 2 +- .../Timo/Timo/domain/timer/entity/TimerRecord.java | 5 ++++- .../Timo/domain/timer/service/TimerService.java | 13 ++++++++++++- 3 files changed, 17 insertions(+), 3 deletions(-) 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 index 37566810..ff6bf603 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStopControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStopControllerDocs.java @@ -21,7 +21,7 @@ public interface TimerStopControllerDocs { 사용자의 요청으로 타이머를 종료합니다. 종료 시각 기록, 실제 수행 시간 계산 (status → STOPPED) 해당 날짜 TodoInstance 완료 처리 및 타이머 상태 초기화 - aiFeedback은 현재 null로 반환되며, 추후 AI 연동 예정 + 계획 시간과 실제 수행 기록을 분석한 AI 피드백 반환 """ ) @ApiResponses({ 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 index e5d8ed4c..a83e71b0 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java +++ b/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java @@ -95,13 +95,16 @@ public void resume() { this.status = TimerStatus.RUNNING; } - public void finish(TimerStatus targetStatus, LocalDateTime endedAt, int actualSeconds, String aiFeedback) { + 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; } 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 index 967a5bae..7ce0077c 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -1,5 +1,8 @@ package com.Timo.Timo.domain.timer.service; +import com.Timo.Timo.domain.ai.dto.request.CreateTodoFeedbackRequest; +import com.Timo.Timo.domain.ai.dto.response.CreateTodoFeedbackResponse; +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; @@ -42,6 +45,7 @@ public class TimerService { private final TodoRepository todoRepository; private final UserRepository userRepository; private final TodoInstanceRepository todoInstanceRepository; + private final AiTodoService aiTodoService; @Transactional public TimerStartResponse startTimer(Long userId, Long todoId) { @@ -198,12 +202,19 @@ private TimerFinishResponse finishTimer(Long userId, Long timerId, TimerStatus t timerSessionRepository.findByTimerRecordIdAndPausedAtIsNull(timerId) .ifPresent(activeSession -> activeSession.pause(now)); - timerRecord.finish(targetStatus, now, actualSeconds, null); + timerRecord.finish(targetStatus, now, actualSeconds); TodoInstance instance = getOrCreateInstance(timerRecord.getTodo(), timerRecord.getStartedAt().toLocalDate()); instance.stopTimer(); instance.markCompleted(); + timerRecordRepository.flush(); + CreateTodoFeedbackResponse feedback = aiTodoService.createFeedback( + userId, + new CreateTodoFeedbackRequest(timerRecord.getTodo().getId()) + ); + timerRecord.updateAiFeedback(feedback.feedback()); + return TimerFinishResponse.of(timerRecord); } } \ No newline at end of file From c89ffebeccd534ceafe418faefee4838305902ee Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 19:41:22 +0900 Subject: [PATCH 370/383] =?UTF-8?q?chore:=20=EA=B3=B5=EB=B0=B1=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/domain/ai/dto/TodoDurationHistory.java | 2 +- .../Timo/domain/ai/dto/request/RecommendDurationRequest.java | 3 +-- .../java/com/Timo/Timo/domain/ai/exception/AiErrorCode.java | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) 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 index b8c697f6..aaa91337 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/dto/TodoDurationHistory.java +++ b/src/main/java/com/Timo/Timo/domain/ai/dto/TodoDurationHistory.java @@ -6,4 +6,4 @@ 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/request/RecommendDurationRequest.java b/src/main/java/com/Timo/Timo/domain/ai/dto/request/RecommendDurationRequest.java index 56ed02fc..a0cb3347 100644 --- 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 @@ -12,5 +12,4 @@ public record RecommendDurationRequest( @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/exception/AiErrorCode.java b/src/main/java/com/Timo/Timo/domain/ai/exception/AiErrorCode.java index 9ff309a2..ad97c783 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/exception/AiErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/ai/exception/AiErrorCode.java @@ -16,4 +16,4 @@ public enum AiErrorCode implements BaseErrorCode { private final HttpStatus httpStatus; private final String code; private final String message; -} +} \ No newline at end of file From 6513ed616a005e227bc1b3cdcbc46540fd6c0a2a Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 20:17:02 +0900 Subject: [PATCH 371/383] =?UTF-8?q?refactor:=20ai=20=ED=94=BC=EB=93=9C?= =?UTF-8?q?=EB=B0=B1=20=EC=A4=91=EB=B3=B5=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20api=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/controller/AiTodoController.java | 28 +----- .../Timo/domain/ai/docs/AiFeedbackDocs.java | 93 ------------------- .../request/CreateTodoFeedbackRequest.java | 12 --- .../response/CreateTodoFeedbackResponse.java | 8 -- .../domain/ai/exception/AiSuccessCode.java | 5 +- .../Timo/domain/ai/service/AiTodoService.java | 30 +++--- .../domain/timer/service/TimerService.java | 8 +- 7 files changed, 26 insertions(+), 158 deletions(-) delete mode 100644 src/main/java/com/Timo/Timo/domain/ai/docs/AiFeedbackDocs.java delete mode 100644 src/main/java/com/Timo/Timo/domain/ai/dto/request/CreateTodoFeedbackRequest.java delete mode 100644 src/main/java/com/Timo/Timo/domain/ai/dto/response/CreateTodoFeedbackResponse.java 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 index f59b0acd..d9d1b5b0 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java +++ b/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java @@ -7,11 +7,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import com.Timo.Timo.domain.ai.docs.AiFeedbackDocs; import com.Timo.Timo.domain.ai.docs.AiTodoDocs; -import com.Timo.Timo.domain.ai.dto.request.CreateTodoFeedbackRequest; import com.Timo.Timo.domain.ai.dto.request.RecommendDurationRequest; -import com.Timo.Timo.domain.ai.dto.response.CreateTodoFeedbackResponse; 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; @@ -26,7 +23,7 @@ @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor -public class AiTodoController implements AiTodoDocs, AiFeedbackDocs { +public class AiTodoController implements AiTodoDocs { private final AiTodoService aiTodoService; @@ -52,25 +49,4 @@ public ResponseEntity> recommendDuration ); } - @Override - @PostMapping("/ai/feedback") - public ResponseEntity> createFeedback( - @AuthenticationPrincipal CustomUserDetails userDetails, - @Valid @RequestBody CreateTodoFeedbackRequest request - ) { - log.info( - "AI todo feedback API called. userId={}, todoId={}", - userDetails.getUserId(), - request.todoId() - ); - - CreateTodoFeedbackResponse response = aiTodoService.createFeedback( - userDetails.getUserId(), - request - ); - - return ResponseEntity.ok( - BaseResponse.onSuccess(AiSuccessCode.TODO_FEEDBACK_CREATED, response) - ); - } -} \ No newline at end of file +} diff --git a/src/main/java/com/Timo/Timo/domain/ai/docs/AiFeedbackDocs.java b/src/main/java/com/Timo/Timo/domain/ai/docs/AiFeedbackDocs.java deleted file mode 100644 index d33b25cd..00000000 --- a/src/main/java/com/Timo/Timo/domain/ai/docs/AiFeedbackDocs.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.Timo.Timo.domain.ai.docs; - -import org.springframework.http.ResponseEntity; - -import com.Timo.Timo.domain.ai.dto.request.CreateTodoFeedbackRequest; -import com.Timo.Timo.domain.ai.dto.response.CreateTodoFeedbackResponse; -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 AiFeedbackDocs { - - @Operation( - tags = "AI", - summary = "AI 투두 수행 피드백 생성", - description = """ - 사용자가 수행한 투두의 예상 소요 시간과 실제 소요 시간을 비교해 짧은 피드백을 생성합니다. - - 서버는 투두 ID로 투두 정보와 가장 최근 실제 소요시간을 조회합니다. - 1. 이번 태스크의 예상 소요 시간과 실제 소요 시간 - 2. 현재 투두명과 비슷한 과거 투두의 실제 소요시간 기록 - 3. 같은 태그의 최근 실제 소요시간 기록 - - Gemini는 현재 결과 관찰, 과거 패턴 해석, 다음 행동 추천을 1~2문장으로 압축해 반환합니다. - 비슷한 투두명 기록을 우선 참고하고, 없으면 같은 태그 기록을 참고합니다. - 둘 다 없으면 이번 태스크의 연장 또는 조기 종료 여부를 기준으로 피드백합니다. - RPM, RPD, TPM 제한을 초과하면 Gemini 호출 전 429 응답을 반환합니다. - """ - ) - @ApiResponses({ - @ApiResponse( - responseCode = "200", - description = "AI 투두 수행 피드백 생성 성공", - useReturnTypeSchema = true - ), - @ApiResponse( - responseCode = "400", - description = "투두 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 = "투두가 존재하지 않거나 해당 사용자의 투두가 아닌 경우", - 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> createFeedback( - @Parameter(hidden = true) CustomUserDetails userDetails, - @io.swagger.v3.oas.annotations.parameters.RequestBody( - required = true, - description = "피드백을 생성할 투두 ID", - content = @Content(schema = @Schema(implementation = CreateTodoFeedbackRequest.class)) - ) - CreateTodoFeedbackRequest request - ); -} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/dto/request/CreateTodoFeedbackRequest.java b/src/main/java/com/Timo/Timo/domain/ai/dto/request/CreateTodoFeedbackRequest.java deleted file mode 100644 index d505db7a..00000000 --- a/src/main/java/com/Timo/Timo/domain/ai/dto/request/CreateTodoFeedbackRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.Timo.Timo.domain.ai.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Positive; - -public record CreateTodoFeedbackRequest( - @NotNull - @Positive - @Schema(description = "피드백을 생성할 투두 ID") - Long todoId -) {} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/dto/response/CreateTodoFeedbackResponse.java b/src/main/java/com/Timo/Timo/domain/ai/dto/response/CreateTodoFeedbackResponse.java deleted file mode 100644 index 00a5abf0..00000000 --- a/src/main/java/com/Timo/Timo/domain/ai/dto/response/CreateTodoFeedbackResponse.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.Timo.Timo.domain.ai.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; - -public record CreateTodoFeedbackResponse( - @Schema(description = "AI 투두 수행 피드백") - String feedback -) {} \ 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 index 9f12217f..0be33414 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/exception/AiSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/ai/exception/AiSuccessCode.java @@ -11,9 +11,8 @@ @RequiredArgsConstructor public enum AiSuccessCode implements BaseSuccessCode { - DURATION_RECOMMENDED(HttpStatus.OK, "AI 예상 소요 시간을 추천했습니다."), - TODO_FEEDBACK_CREATED(HttpStatus.OK, "AI 투두 피드백을 생성했습니다."); + DURATION_RECOMMENDED(HttpStatus.OK, "AI 예상 소요 시간을 추천했습니다."); private final HttpStatus httpStatus; private final String message; -} \ No newline at end of file +} 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 index c8acb068..c4550921 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java @@ -1,14 +1,12 @@ package com.Timo.Timo.domain.ai.service; import java.time.LocalDate; -import java.time.ZoneOffset; +import java.time.ZoneId; import org.springframework.stereotype.Service; -import com.Timo.Timo.domain.ai.dto.request.CreateTodoFeedbackRequest; 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.CreateTodoFeedbackResponse; 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; @@ -16,6 +14,9 @@ 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; @@ -33,6 +34,7 @@ public class AiTodoService { private final AiTodoHistoryService historyService; private final AiTodoQueryRepository queryRepository; + private final UserRepository userRepository; private final TodoDurationPromptBuilder promptBuilder; private final TodoFeedbackPromptBuilder feedbackPromptBuilder; private final GeminiService geminiService; @@ -40,7 +42,7 @@ public class AiTodoService { private final AiRequestRateLimiter rateLimiter; public RecommendDurationResponse recommendDuration(Long userId, RecommendDurationRequest request) { - LocalDate today = LocalDate.now(ZoneOffset.UTC); + LocalDate today = getCurrentDate(userId); AiTodoHistories histories = historyService.findHistories( userId, @@ -68,9 +70,9 @@ public RecommendDurationResponse recommendDuration(Long userId, RecommendDuratio return validate(recommendation); } - public CreateTodoFeedbackResponse createFeedback(Long userId, CreateTodoFeedbackRequest request) { - LocalDate today = LocalDate.now(ZoneOffset.UTC); - TodoFeedbackSource source = queryRepository.findFeedbackSource(userId, request.todoId()); + public String createFeedback(Long userId, Long todoId) { + LocalDate today = getCurrentDate(userId); + TodoFeedbackSource source = queryRepository.findFeedbackSource(userId, todoId); if (source == null) { throw new CustomException(TodoErrorCode.TODO_NOT_FOUND); } @@ -98,7 +100,13 @@ public CreateTodoFeedbackResponse createFeedback(Long userId, CreateTodoFeedback String geminiJson = geminiService.generateJson(prompt); GeminiTodoFeedback feedback = parseFeedback(geminiJson); - return validate(feedback); + return validateFeedback(feedback); + } + + private LocalDate getCurrentDate(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + return LocalDate.now(ZoneId.of(user.getZoneId())); } private GeminiDurationRecommendation parseRecommendation(String geminiJson) { @@ -135,7 +143,7 @@ private RecommendDurationResponse validate(GeminiDurationRecommendation recommen return new RecommendDurationResponse(recommendedMinutes); } - private CreateTodoFeedbackResponse validate(GeminiTodoFeedback feedback) { + private String validateFeedback(GeminiTodoFeedback feedback) { if (feedback == null || feedback.feedback() == null || feedback.feedback().isBlank() @@ -143,7 +151,7 @@ private CreateTodoFeedbackResponse validate(GeminiTodoFeedback feedback) { throw new IllegalArgumentException("Gemini feedback has missing fields."); } - return new CreateTodoFeedbackResponse(feedback.feedback().trim()); + return feedback.feedback().trim(); } private int normalizeMinutes(int minutes) { @@ -164,4 +172,4 @@ private String stripMarkdownFence(String value) { } return trimmed; } -} \ No newline at end of file +} 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 index 7ce0077c..edbf19ae 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -1,7 +1,5 @@ package com.Timo.Timo.domain.timer.service; -import com.Timo.Timo.domain.ai.dto.request.CreateTodoFeedbackRequest; -import com.Timo.Timo.domain.ai.dto.response.CreateTodoFeedbackResponse; 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; @@ -209,11 +207,11 @@ private TimerFinishResponse finishTimer(Long userId, Long timerId, TimerStatus t instance.markCompleted(); timerRecordRepository.flush(); - CreateTodoFeedbackResponse feedback = aiTodoService.createFeedback( + String feedback = aiTodoService.createFeedback( userId, - new CreateTodoFeedbackRequest(timerRecord.getTodo().getId()) + timerRecord.getTodo().getId() ); - timerRecord.updateAiFeedback(feedback.feedback()); + timerRecord.updateAiFeedback(feedback); return TimerFinishResponse.of(timerRecord); } From 56d289fe518ed119988d7c35e84794f0dd57892a Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 20:35:29 +0900 Subject: [PATCH 372/383] =?UTF-8?q?refactor:=20=ED=83=80=EC=9E=84=EC=A1=B4?= =?UTF-8?q?=20=EB=B0=98=EC=98=81=ED=95=98=EC=97=AC=20=EC=98=A4=EB=8A=98=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/repository/AiTodoQueryRepository.java | 49 ++++++++++++------- .../ai/service/AiTodoHistoryService.java | 14 ++++-- .../Timo/domain/ai/service/AiTodoService.java | 26 +++++++--- 3 files changed, 59 insertions(+), 30 deletions(-) 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 index 6002f009..03e1d779 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java +++ b/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java @@ -1,8 +1,10 @@ package com.Timo.Timo.domain.ai.repository; -import java.sql.Date; import java.sql.Timestamp; 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; @@ -62,20 +64,21 @@ order by coalesce(tr.ended_at, tr.started_at) desc, tr.id desc public List findActualDurationHistoriesBySimilarTitle( Long userId, String title, - LocalDate today, + LocalDateTime toExclusive, + ZoneId userZoneId, int limit ) { Query query = entityManager.createNativeQuery(""" select t.title, tr.actual_seconds, - date(coalesce(tr.ended_at, tr.started_at)) as recorded_date + coalesce(tr.ended_at, tr.started_at) as recorded_at from todos t join timer_records tr on tr.todo_id = t.id where t.user_id = :userId and tr.user_id = :userId and tr.actual_seconds is not null - and date(coalesce(tr.ended_at, tr.started_at)) <= :today + and coalesce(tr.ended_at, tr.started_at) < :toExclusive and ( lower(t.title) like lower(concat('%', :title, '%')) or lower(:title) like lower(concat('%', t.title, '%')) @@ -92,47 +95,48 @@ when lower(:title) like lower(concat('%', t.title, '%')) then 2 """) .setParameter("userId", userId) .setParameter("title", title) - .setParameter("today", today) + .setParameter("toExclusive", toExclusive) .setMaxResults(limit); - return toHistories(query.getResultList()); + return toHistories(query.getResultList(), userZoneId); } public List findActualDurationHistoriesByTagId( Long userId, Long tagId, - LocalDate today, + LocalDateTime toExclusive, + ZoneId userZoneId, int limit ) { Query query = entityManager.createNativeQuery(""" select t.title, tr.actual_seconds, - date(coalesce(tr.ended_at, tr.started_at)) as recorded_date + coalesce(tr.ended_at, tr.started_at) as recorded_at from todos t join timer_records tr on tr.todo_id = t.id where t.user_id = :userId and tr.user_id = :userId and tr.actual_seconds is not null - and date(coalesce(tr.ended_at, tr.started_at)) <= :today + and coalesce(tr.ended_at, tr.started_at) < :toExclusive and t.tag_id = :tagId order by coalesce(tr.ended_at, tr.started_at) desc, tr.id desc """) .setParameter("userId", userId) .setParameter("tagId", tagId) - .setParameter("today", today) + .setParameter("toExclusive", toExclusive) .setMaxResults(limit); - return toHistories(query.getResultList()); + return toHistories(query.getResultList(), userZoneId); } @SuppressWarnings("unchecked") - private List toHistories(List rows) { + private List toHistories(List rows, ZoneId userZoneId) { return ((List)rows).stream() .map(row -> new TodoDurationHistory( (String)row[0], toInteger(row[1]), - toLocalDate(row[2]) + toUserLocalDate(row[2], userZoneId) )) .toList(); } @@ -151,16 +155,23 @@ private Integer toInteger(Object value) { return ((Number)value).intValue(); } - private LocalDate toLocalDate(Object value) { + private LocalDate toUserLocalDate(Object value, ZoneId userZoneId) { if (value instanceof LocalDate localDate) { return localDate; } - if (value instanceof Date date) { - return date.toLocalDate(); + if (value instanceof LocalDateTime localDateTime) { + return toUserLocalDate(localDateTime, userZoneId); } if (value instanceof Timestamp timestamp) { - return timestamp.toLocalDateTime().toLocalDate(); + return toUserLocalDate(timestamp.toLocalDateTime(), userZoneId); } - return LocalDate.parse(value.toString()); + return toUserLocalDate(LocalDateTime.parse(value.toString()), userZoneId); } -} \ No newline at end of file + + private LocalDate toUserLocalDate(LocalDateTime utcDateTime, ZoneId userZoneId) { + return utcDateTime + .atZone(ZoneOffset.UTC) + .withZoneSameInstant(userZoneId) + .toLocalDate(); + } +} 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 index 7e5da687..ffdd3627 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java @@ -1,6 +1,7 @@ package com.Timo.Timo.domain.ai.service; -import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.List; import org.springframework.stereotype.Service; @@ -22,14 +23,16 @@ public AiTodoHistories findHistories( Long userId, String title, Long tagId, - LocalDate today, + LocalDateTime toExclusive, + ZoneId userZoneId, int limit ) { List similarTitleHistories = aiTodoQueryRepository.findActualDurationHistoriesBySimilarTitle( userId, title, - today, + toExclusive, + userZoneId, limit ); List recentTagHistories = tagId == null @@ -37,10 +40,11 @@ public AiTodoHistories findHistories( : aiTodoQueryRepository.findActualDurationHistoriesByTagId( userId, tagId, - today, + toExclusive, + userZoneId, limit ); return new AiTodoHistories(similarTitleHistories, recentTagHistories); } -} \ No newline at end of file +} 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 index c4550921..0bb21e56 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java @@ -1,7 +1,9 @@ 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; @@ -42,13 +44,15 @@ public class AiTodoService { private final AiRequestRateLimiter rateLimiter; public RecommendDurationResponse recommendDuration(Long userId, RecommendDurationRequest request) { - LocalDate today = getCurrentDate(userId); + ZoneId userZoneId = getUserZoneId(userId); + LocalDateTime toExclusive = getTodayToExclusiveUtc(userZoneId); AiTodoHistories histories = historyService.findHistories( userId, request.title(), request.tagId(), - today, + toExclusive, + userZoneId, HISTORY_LIMIT ); @@ -71,7 +75,8 @@ public RecommendDurationResponse recommendDuration(Long userId, RecommendDuratio } public String createFeedback(Long userId, Long todoId) { - LocalDate today = getCurrentDate(userId); + ZoneId userZoneId = getUserZoneId(userId); + LocalDateTime toExclusive = getTodayToExclusiveUtc(userZoneId); TodoFeedbackSource source = queryRepository.findFeedbackSource(userId, todoId); if (source == null) { throw new CustomException(TodoErrorCode.TODO_NOT_FOUND); @@ -81,7 +86,8 @@ public String createFeedback(Long userId, Long todoId) { userId, source.title(), source.tagId(), - today, + toExclusive, + userZoneId, HISTORY_LIMIT ); @@ -103,10 +109,18 @@ public String createFeedback(Long userId, Long todoId) { return validateFeedback(feedback); } - private LocalDate getCurrentDate(Long userId) { + private ZoneId getUserZoneId(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); - return LocalDate.now(ZoneId.of(user.getZoneId())); + 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) { From 6ee6090cf88026d09a30adfc00200f048c282b0c Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 20:45:40 +0900 Subject: [PATCH 373/383] =?UTF-8?q?feat:=20ai=20=ED=94=BC=EB=93=9C?= =?UTF-8?q?=EB=B0=B1=20=EC=8B=A4=ED=8C=A8=ED=95=98=EB=A9=B4=20null?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/TimerCompleteControllerDocs.java | 1 + .../dto/response/TimerFinishResponse.java | 4 +++- .../domain/timer/service/TimerService.java | 24 ++++++++++++++----- 3 files changed, 22 insertions(+), 7 deletions(-) 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 index bb601ee1..204bc21a 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerCompleteControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerCompleteControllerDocs.java @@ -22,6 +22,7 @@ public interface TimerCompleteControllerDocs { 종료 시각 기록, 실제 수행 시간 계산 (status → COMPLETED) 해당 날짜 TodoInstance 완료 처리 및 타이머 상태 초기화 계획 시간과 실제 수행 기록을 분석한 AI 피드백 반환 + AI 피드백 생성에 실패해도 타이머 완료는 정상 처리되며 aiFeedback은 null로 반환 """ ) @ApiResponses({ 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 index 893a983d..23a7cc4d 100644 --- 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 @@ -1,6 +1,7 @@ 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, @@ -8,6 +9,7 @@ public record TimerFinishResponse( String status, Integer plannedSeconds, Integer actualSeconds, + @Schema(description = "AI 피드백 문구. AI 호출 실패 시 null", nullable = true) String aiFeedback ) { public static TimerFinishResponse of(TimerRecord timerRecord) { @@ -20,4 +22,4 @@ public static TimerFinishResponse of(TimerRecord timerRecord) { timerRecord.getAiFeedback() ); } -} +} \ No newline at end of file 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 index edbf19ae..298e1525 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -28,9 +28,11 @@ 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.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -207,12 +209,22 @@ private TimerFinishResponse finishTimer(Long userId, Long timerId, TimerStatus t instance.markCompleted(); timerRecordRepository.flush(); - String feedback = aiTodoService.createFeedback( - userId, - timerRecord.getTodo().getId() - ); - timerRecord.updateAiFeedback(feedback); + generateAiFeedback(userId, timerRecord); return TimerFinishResponse.of(timerRecord); } -} \ No newline at end of file + + private void generateAiFeedback(Long userId, TimerRecord timerRecord) { + try { + String feedback = aiTodoService.createFeedback(userId, timerRecord.getTodo().getId()); + timerRecord.updateAiFeedback(feedback); + } catch (RuntimeException exception) { + log.warn( + "AI feedback generation failed. timerId={}, userId={}", + timerRecord.getId(), + userId, + exception + ); + } + } +} From 30ad42f4dfdd2079f05c62691c692fca85b4911a Mon Sep 17 00:00:00 2001 From: aneykrap Date: Fri, 10 Jul 2026 02:38:55 +0900 Subject: [PATCH 374/383] =?UTF-8?q?fix(ai):url=20=EC=A3=BC=EC=86=8C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/ai/controller/AiTodoController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index d9d1b5b0..6d5c84fe 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java +++ b/src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java @@ -21,14 +21,14 @@ @Slf4j @RestController -@RequestMapping("/api/v1") +@RequestMapping("/api/v1/ai") @RequiredArgsConstructor public class AiTodoController implements AiTodoDocs { private final AiTodoService aiTodoService; @Override - @PostMapping("/ai/duration") + @PostMapping("/duration") public ResponseEntity> recommendDuration( @AuthenticationPrincipal CustomUserDetails userDetails, @Valid @RequestBody RecommendDurationRequest request From e0b22dc1a5e39dfdc1618b5b6b0aedd01c18e3da Mon Sep 17 00:00:00 2001 From: aneykrap Date: Fri, 10 Jul 2026 02:39:54 +0900 Subject: [PATCH 375/383] =?UTF-8?q?fix(ai):=20ai=20=ED=94=BC=EB=93=9C?= =?UTF-8?q?=EB=B0=B1=EC=9D=80=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20?= =?UTF-8?q?=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/timer/service/TimerService.java | 66 ++++++++++++++++--- 1 file changed, 57 insertions(+), 9 deletions(-) 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 index 298e1525..3fde84bb 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -30,7 +30,9 @@ 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 @@ -46,6 +48,7 @@ public class TimerService { private final UserRepository userRepository; private final TodoInstanceRepository todoInstanceRepository; private final AiTodoService aiTodoService; + private final PlatformTransactionManager transactionManager; @Transactional public TimerStartResponse startTimer(Long userId, Long todoId) { @@ -178,17 +181,38 @@ public void deleteTimersByTodo(Long todoId) { timerRecordRepository.deleteByTodoId(todoId); } - @Transactional public TimerFinishResponse completeTimer(Long userId, Long timerId) { return finishTimer(userId, timerId, TimerStatus.COMPLETED); } - @Transactional 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)); @@ -209,22 +233,46 @@ private TimerFinishResponse finishTimer(Long userId, Long timerId, TimerStatus t instance.markCompleted(); timerRecordRepository.flush(); - generateAiFeedback(userId, timerRecord); - return TimerFinishResponse.of(timerRecord); + return FinishedTimer.from(timerRecord); } - private void generateAiFeedback(Long userId, TimerRecord timerRecord) { + private String generateAiFeedback(Long userId, Long todoId) { try { - String feedback = aiTodoService.createFeedback(userId, timerRecord.getTodo().getId()); - timerRecord.updateAiFeedback(feedback); + return aiTodoService.createFeedback(userId, todoId); } catch (RuntimeException exception) { log.warn( - "AI feedback generation failed. timerId={}, userId={}", - timerRecord.getId(), + "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() + ); } } } From 57269bc881c3aea0f2b16a9fdda2248e3ff8fbbc Mon Sep 17 00:00:00 2001 From: aneykrap Date: Fri, 10 Jul 2026 02:48:27 +0900 Subject: [PATCH 376/383] =?UTF-8?q?feat(ai):=20AI=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=98=88=EC=99=B8=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=ED=95=B4=EC=84=9C=20AiErrorCode=20=EA=B8=B0=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/ai/exception/AiErrorCode.java | 5 ++++- .../Timo/Timo/domain/ai/service/AiTodoService.java | 13 ++++++++----- .../Timo/Timo/domain/ai/service/GeminiService.java | 12 ++++++++---- 3 files changed, 20 insertions(+), 10 deletions(-) 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 index ad97c783..982cc1f6 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/exception/AiErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/ai/exception/AiErrorCode.java @@ -11,7 +11,10 @@ @RequiredArgsConstructor public enum AiErrorCode implements BaseErrorCode { - AI_RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "AI_429", "AI 추천 요청이 많습니다. 잠시 후 다시 시도해주세요."); + 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; 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 index 0bb21e56..f3f215bb 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java @@ -12,6 +12,7 @@ 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; @@ -130,7 +131,8 @@ private GeminiDurationRecommendation parseRecommendation(String geminiJson) { GeminiDurationRecommendation.class ); } catch (Exception exception) { - throw new IllegalStateException("Failed to parse Gemini duration recommendation.", exception); + log.warn("Failed to parse Gemini duration recommendation.", exception); + throw new CustomException(AiErrorCode.AI_RESPONSE_PARSE_FAILED); } } @@ -141,7 +143,8 @@ private GeminiTodoFeedback parseFeedback(String geminiJson) { GeminiTodoFeedback.class ); } catch (Exception exception) { - throw new IllegalStateException("Failed to parse Gemini todo feedback.", exception); + log.warn("Failed to parse Gemini todo feedback.", exception); + throw new CustomException(AiErrorCode.AI_RESPONSE_PARSE_FAILED); } } @@ -149,7 +152,7 @@ private RecommendDurationResponse validate(GeminiDurationRecommendation recommen if (recommendation == null || recommendation.recommendedMinutes() == null ) { - throw new IllegalArgumentException("Gemini recommendation has missing fields."); + throw new CustomException(AiErrorCode.AI_INVALID_RESPONSE); } int recommendedMinutes = normalizeMinutes(recommendation.recommendedMinutes()); @@ -162,7 +165,7 @@ private String validateFeedback(GeminiTodoFeedback feedback) { || feedback.feedback() == null || feedback.feedback().isBlank() ) { - throw new IllegalArgumentException("Gemini feedback has missing fields."); + throw new CustomException(AiErrorCode.AI_INVALID_RESPONSE); } return feedback.feedback().trim(); @@ -186,4 +189,4 @@ private String stripMarkdownFence(String value) { } 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 index 20b95277..5a31063f 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java @@ -10,6 +10,8 @@ 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; @@ -35,10 +37,10 @@ public class GeminiService { public String generateJson(String prompt) { if (apiKey == null || apiKey.isBlank()) { - throw new IllegalStateException("Gemini API key is not configured."); + throw new CustomException(AiErrorCode.AI_CONFIG_NOT_FOUND); } if (model == null || model.isBlank()) { - throw new IllegalStateException("Gemini model is not configured."); + throw new CustomException(AiErrorCode.AI_CONFIG_NOT_FOUND); } Map request = Map.of( @@ -84,12 +86,14 @@ private String extractText(String response) { .path("text"); if (textNode.isMissingNode() || textNode.asText().isBlank()) { - throw new IllegalStateException("Gemini response text is empty."); + throw new CustomException(AiErrorCode.AI_INVALID_RESPONSE); } return textNode.asText(); + } catch (CustomException exception) { + throw exception; } catch (Exception exception) { - throw new IllegalStateException("Failed to parse Gemini response.", exception); + throw new CustomException(AiErrorCode.AI_RESPONSE_PARSE_FAILED); } } } \ No newline at end of file From 91cd60f65631d6f4c056121d7b7513e43ee48f9f Mon Sep 17 00:00:00 2001 From: aneykrap Date: Fri, 10 Jul 2026 02:58:36 +0900 Subject: [PATCH 377/383] =?UTF-8?q?refactor(ai):=20=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=ED=8B=B0=EB=B8=8C=20=EC=BF=BC=EB=A6=AC=20JPQL=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/repository/AiTodoQueryRepository.java | 162 ++++++++---------- .../ai/repository/TodoDurationHistoryRow.java | 9 + 2 files changed, 82 insertions(+), 89 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/domain/ai/repository/TodoDurationHistoryRow.java 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 index 03e1d779..adf319dc 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java +++ b/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java @@ -1,6 +1,5 @@ package com.Timo.Timo.domain.ai.repository; -import java.sql.Timestamp; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneId; @@ -13,7 +12,6 @@ import com.Timo.Timo.domain.ai.dto.TodoFeedbackSource; import jakarta.persistence.EntityManager; -import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; @Repository @@ -23,41 +21,34 @@ public class AiTodoQueryRepository { private final EntityManager entityManager; public TodoFeedbackSource findFeedbackSource(Long userId, Long todoId) { - List rows = entityManager.createNativeQuery(""" - select + List sources = entityManager.createQuery(""" + select new com.Timo.Timo.domain.ai.dto.TodoFeedbackSource( t.title, - t.tag_id, + t.tagId, tag.name, - t.duration_seconds, - coalesce(( - select tr.actual_seconds - from timer_records tr - where tr.todo_id = t.id - and tr.user_id = :userId - and tr.actual_seconds is not null - order by coalesce(tr.ended_at, tr.started_at) desc, tr.id desc - limit 1 - ), 0) - from todos t - left join tags tag on tag.id = t.tag_id + t.durationSeconds, + 0 + ) + from Todo t + left join Tag tag on tag.id = t.tagId where t.id = :todoId - and t.user_id = :userId - """) + and t.user.id = :userId + """, TodoFeedbackSource.class) .setParameter("userId", userId) .setParameter("todoId", todoId) .getResultList(); - if (rows.isEmpty()) { + if (sources.isEmpty()) { return null; } - Object[] row = (Object[])rows.get(0); + TodoFeedbackSource source = sources.get(0); return new TodoFeedbackSource( - (String)row[0], - toLong(row[1]), - (String)row[2], - toInteger(row[3]), - toInteger(row[4]) + source.title(), + source.tagId(), + source.tagName(), + source.estimatedSeconds(), + findLatestActualSeconds(userId, todoId) ); } @@ -68,17 +59,18 @@ public List findActualDurationHistoriesBySimilarTitle( ZoneId userZoneId, int limit ) { - Query query = entityManager.createNativeQuery(""" - select + List rows = entityManager.createQuery(""" + select new com.Timo.Timo.domain.ai.repository.TodoDurationHistoryRow( t.title, - tr.actual_seconds, - coalesce(tr.ended_at, tr.started_at) as recorded_at - from todos t - join timer_records tr on tr.todo_id = t.id - where t.user_id = :userId - and tr.user_id = :userId - and tr.actual_seconds is not null - and coalesce(tr.ended_at, tr.started_at) < :toExclusive + 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, '%')) @@ -90,15 +82,16 @@ 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.ended_at, tr.started_at) desc, + coalesce(tr.endedAt, tr.startedAt) desc, tr.id desc - """) + """, TodoDurationHistoryRow.class) .setParameter("userId", userId) .setParameter("title", title) .setParameter("toExclusive", toExclusive) - .setMaxResults(limit); + .setMaxResults(limit) + .getResultList(); - return toHistories(query.getResultList(), userZoneId); + return toHistories(rows, userZoneId); } public List findActualDurationHistoriesByTagId( @@ -108,70 +101,61 @@ public List findActualDurationHistoriesByTagId( ZoneId userZoneId, int limit ) { - Query query = entityManager.createNativeQuery(""" - select + List rows = entityManager.createQuery(""" + select new com.Timo.Timo.domain.ai.repository.TodoDurationHistoryRow( t.title, - tr.actual_seconds, - coalesce(tr.ended_at, tr.started_at) as recorded_at - from todos t - join timer_records tr on tr.todo_id = t.id - where t.user_id = :userId - and tr.user_id = :userId - and tr.actual_seconds is not null - and coalesce(tr.ended_at, tr.started_at) < :toExclusive - and t.tag_id = :tagId - order by coalesce(tr.ended_at, tr.started_at) desc, tr.id desc - """) + 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); + .setMaxResults(limit) + .getResultList(); - return toHistories(query.getResultList(), userZoneId); + 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); } - @SuppressWarnings("unchecked") - private List toHistories(List rows, ZoneId userZoneId) { - return ((List)rows).stream() + private List toHistories(List rows, ZoneId userZoneId) { + return rows.stream() .map(row -> new TodoDurationHistory( - (String)row[0], - toInteger(row[1]), - toUserLocalDate(row[2], userZoneId) + row.title(), + row.actualSeconds(), + toUserLocalDate(row.recordedAt(), userZoneId) )) .toList(); } - private Long toLong(Object value) { - if (value == null) { - return null; - } - return ((Number)value).longValue(); - } - - private Integer toInteger(Object value) { - if (value == null) { - return null; - } - return ((Number)value).intValue(); - } - - private LocalDate toUserLocalDate(Object value, ZoneId userZoneId) { - if (value instanceof LocalDate localDate) { - return localDate; - } - if (value instanceof LocalDateTime localDateTime) { - return toUserLocalDate(localDateTime, userZoneId); - } - if (value instanceof Timestamp timestamp) { - return toUserLocalDate(timestamp.toLocalDateTime(), userZoneId); - } - return toUserLocalDate(LocalDateTime.parse(value.toString()), userZoneId); - } - 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 From 58d414c5dfd685ded89483e7f279d085d2a74853 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 8 Jul 2026 17:29:30 +0900 Subject: [PATCH 378/383] chore: trigger CodeRabbit review From 95a6c68ef2ec857dbbd61cf4298decfc982f31c6 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 23:44:32 +0900 Subject: [PATCH 379/383] =?UTF-8?q?feat(TimeBox):=20TimeBoxAction=20enum?= =?UTF-8?q?=20&=20error/success=20codes=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/timebox/enums/TimeBoxAction.java | 8 +++++++ .../timebox/exception/TimeBoxErrorCode.java | 21 +++++++++++++++++++ .../timebox/exception/TimeBoxSuccessCode.java | 18 ++++++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/timebox/enums/TimeBoxAction.java create mode 100644 src/main/java/com/Timo/Timo/domain/timebox/exception/TimeBoxErrorCode.java create mode 100644 src/main/java/com/Timo/Timo/domain/timebox/exception/TimeBoxSuccessCode.java 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 From fbe576626b5a8c2ffb70f5856b3a930390fcbf82 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 23:47:30 +0900 Subject: [PATCH 380/383] =?UTF-8?q?feat(TimeBox):=20=ED=83=80=EC=9E=84?= =?UTF-8?q?=EB=B0=95=EC=8A=A4=20=EC=84=B8=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timebox/docs/TimeBoxControllerDocs.java | 74 +++++++++++++++++++ .../repository/TimerSessionRepository.java | 18 +++++ 2 files changed, 92 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/timebox/docs/TimeBoxControllerDocs.java 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/timer/repository/TimerSessionRepository.java b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java index 96012e5b..a00c18ec 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java @@ -1,6 +1,7 @@ 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; @@ -17,4 +18,21 @@ public interface TimerSessionRepository extends JpaRepository :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 + ); } From 33bcf5adbb36669ef61e8c21dcee48bd3f971940 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 23:48:20 +0900 Subject: [PATCH 381/383] =?UTF-8?q?feat(TimeBox):=20=ED=83=80=EC=9E=84?= =?UTF-8?q?=EB=B0=95=EC=8A=A4=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timebox/controller/TimeBoxController.java | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/timebox/controller/TimeBoxController.java 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 From 657b1b67cdf1a5408d32b45292c5800b2faf6ef4 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 9 Jul 2026 23:50:21 +0900 Subject: [PATCH 382/383] =?UTF-8?q?feat(TimeBox):=20=ED=83=80=EC=9E=84?= =?UTF-8?q?=EB=B0=95=EC=8A=A4=20=EB=82=A0=EC=A7=9C=20=ED=8C=8C=EC=84=9C=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9D=91=EB=8B=B5=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timebox/dto/response/TimeBoxResponse.java | 53 ++++++ .../timebox/service/TimeBoxService.java | 159 ++++++++++++++++++ .../timebox/support/TimeBoxDateParser.java | 34 ++++ 3 files changed, 246 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/timebox/dto/response/TimeBoxResponse.java create mode 100644 src/main/java/com/Timo/Timo/domain/timebox/service/TimeBoxService.java create mode 100644 src/main/java/com/Timo/Timo/domain/timebox/support/TimeBoxDateParser.java 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/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 From 57939d93627852ee37836592cd2e34d1c9f70178 Mon Sep 17 00:00:00 2001 From: Yoona <166522604+laura-jung@users.noreply.github.com> Date: Fri, 10 Jul 2026 03:29:20 +0900 Subject: [PATCH 383/383] =?UTF-8?q?[fix]=20=EB=B0=B0=ED=8F=AC=20db?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=B4=20ddl-auto=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 15315a0c..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