From fc6b2b4dddd6c3e5f5b88a8e3bafac4a0d7bca38 Mon Sep 17 00:00:00 2001 From: JBGeum Date: Thu, 8 Jan 2026 11:29:14 +0900 Subject: [PATCH 1/9] =?UTF-8?q?JWT=20=EA=B8=B0=EB=B0=98=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EB=B0=8F=20=EB=B3=B4=EC=95=88=20=EC=84=A4=EC=A0=95?= =?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 --- .../infrastructure/config/SecurityConfig.java | 71 ++++++++++++++++--- .../security/JwtAccessDeniedHandler.java | 43 +++++++++++ .../security/JwtAuthenticationEntryPoint.java | 43 +++++++++++ .../security/SecurityContextUtil.java | 41 +++++++++++ .../security/jwt/JwtAuthenticationFilter.java | 51 +++++++++++++ 5 files changed, 238 insertions(+), 11 deletions(-) create mode 100644 infrastructure/src/main/java/com/btg/infrastructure/security/JwtAccessDeniedHandler.java create mode 100644 infrastructure/src/main/java/com/btg/infrastructure/security/JwtAuthenticationEntryPoint.java create mode 100644 infrastructure/src/main/java/com/btg/infrastructure/security/SecurityContextUtil.java create mode 100644 infrastructure/src/main/java/com/btg/infrastructure/security/jwt/JwtAuthenticationFilter.java diff --git a/infrastructure/src/main/java/com/btg/infrastructure/config/SecurityConfig.java b/infrastructure/src/main/java/com/btg/infrastructure/config/SecurityConfig.java index fc15724..eb69449 100644 --- a/infrastructure/src/main/java/com/btg/infrastructure/config/SecurityConfig.java +++ b/infrastructure/src/main/java/com/btg/infrastructure/config/SecurityConfig.java @@ -1,34 +1,83 @@ package com.btg.infrastructure.config; +import com.btg.infrastructure.security.JwtAccessDeniedHandler; +import com.btg.infrastructure.security.JwtAuthenticationEntryPoint; +import com.btg.infrastructure.security.jwt.JwtAuthenticationFilter; +import com.btg.infrastructure.security.jwt.JwtTokenProvider; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; -/** - * Security Configuration - * - * Currently configured for testing purposes: - * - All endpoints are public (permitAll) - * - CSRF disabled - * - No authentication required - * - * TODO: Implement JWT-based authentication when ready - */ @Configuration @EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { + private final JwtTokenProvider jwtTokenProvider; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .headers(headers -> headers + .frameOptions(frame -> frame.sameOrigin()) // H2 Console uses iframe + ) .authorizeHttpRequests(auth -> auth - .anyRequest().permitAll() + // 인증 불필요 + .requestMatchers("/auth/**").permitAll() + .requestMatchers("/h2-console/**").permitAll() // H2 Console (dev only) + .requestMatchers(HttpMethod.GET, "/groups/search").permitAll() + .requestMatchers(HttpMethod.GET, "/groups/{groupId}").permitAll() + + // 인증 필요 + .requestMatchers("/users/**").authenticated() + .requestMatchers(HttpMethod.POST, "/groups").authenticated() + .requestMatchers(HttpMethod.GET, "/groups").authenticated() + .requestMatchers(HttpMethod.PUT, "/groups/**").authenticated() + .requestMatchers(HttpMethod.DELETE, "/groups/**").authenticated() + + .anyRequest().authenticated() + ) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(jwtAccessDeniedHandler) + ) + .addFilterBefore( + new JwtAuthenticationFilter(jwtTokenProvider), + UsernamePasswordAuthenticationFilter.class ); return http.build(); } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + //configuration.setAllowedOrigins(List.of("http://localhost:3000")) - 프론트엔드용; + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } } diff --git a/infrastructure/src/main/java/com/btg/infrastructure/security/JwtAccessDeniedHandler.java b/infrastructure/src/main/java/com/btg/infrastructure/security/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..dad28e8 --- /dev/null +++ b/infrastructure/src/main/java/com/btg/infrastructure/security/JwtAccessDeniedHandler.java @@ -0,0 +1,43 @@ +package com.btg.infrastructure.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.time.LocalDateTime; + +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + public JwtAccessDeniedHandler() { + this.objectMapper = new ObjectMapper(); + this.objectMapper.registerModule(new JavaTimeModule()); + } + + @Override + public void handle(HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + ErrorResponse errorResponse = new ErrorResponse( + HttpStatus.FORBIDDEN.value(), + "접근 권한이 없습니다.", + LocalDateTime.now() + ); + + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } + + private record ErrorResponse(int status, String message, LocalDateTime timestamp) {} +} diff --git a/infrastructure/src/main/java/com/btg/infrastructure/security/JwtAuthenticationEntryPoint.java b/infrastructure/src/main/java/com/btg/infrastructure/security/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..8f21b7b --- /dev/null +++ b/infrastructure/src/main/java/com/btg/infrastructure/security/JwtAuthenticationEntryPoint.java @@ -0,0 +1,43 @@ +package com.btg.infrastructure.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.time.LocalDateTime; + +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + public JwtAuthenticationEntryPoint() { + this.objectMapper = new ObjectMapper(); + this.objectMapper.registerModule(new JavaTimeModule()); + } + + @Override + public void commence(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) throws IOException { + ErrorResponse errorResponse = new ErrorResponse( + HttpStatus.UNAUTHORIZED.value(), + "인증이 필요합니다. 로그인 후 다시 시도해주세요.", + LocalDateTime.now() + ); + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } + + private record ErrorResponse(int status, String message, LocalDateTime timestamp) {} +} diff --git a/infrastructure/src/main/java/com/btg/infrastructure/security/SecurityContextUtil.java b/infrastructure/src/main/java/com/btg/infrastructure/security/SecurityContextUtil.java new file mode 100644 index 0000000..1f89ba1 --- /dev/null +++ b/infrastructure/src/main/java/com/btg/infrastructure/security/SecurityContextUtil.java @@ -0,0 +1,41 @@ +package com.btg.infrastructure.security; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +public final class SecurityContextUtil { + + private SecurityContextUtil() { + throw new UnsupportedOperationException("Utility class"); + } + + public static Long getCurrentUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated()) { + throw new IllegalStateException("인증되지 않은 사용자입니다."); + } + + Object principal = authentication.getPrincipal(); + if (principal instanceof Long userId) { + return userId; + } + + throw new IllegalStateException("인증 정보가 올바르지 않습니다."); + } + + public static Long getCurrentUserIdOrNull() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated()) { + return null; + } + + Object principal = authentication.getPrincipal(); + if (principal instanceof Long userId) { + return userId; + } + + return null; + } +} diff --git a/infrastructure/src/main/java/com/btg/infrastructure/security/jwt/JwtAuthenticationFilter.java b/infrastructure/src/main/java/com/btg/infrastructure/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..770b7cc --- /dev/null +++ b/infrastructure/src/main/java/com/btg/infrastructure/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,51 @@ +package com.btg.infrastructure.security.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String token = extractTokenFromHeader(request); + + if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) { + Long userId = jwtTokenProvider.getUserIdFromToken(token); + String email = jwtTokenProvider.getEmailFromToken(token); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList()); + authentication.setDetails(email); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } + + private String extractTokenFromHeader(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { + return bearerToken.substring(BEARER_PREFIX.length()); + } + return null; + } +} From 2527381aea29f06e4a88e891bff6a5617166ae45 Mon Sep 17 00:00:00 2001 From: JBGeum Date: Thu, 8 Jan 2026 11:37:58 +0900 Subject: [PATCH 2/9] =?UTF-8?q?Feat(task):=20Task=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20Port=20=EB=B0=8F=20UseCase=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../port/in/task/GetTaskProgressUseCase.java | 14 ++++++ .../port/in/task/JoinTaskUseCase.java | 31 ++++++++++++ .../port/in/task/LeaveTaskUseCase.java | 20 ++++++++ .../port/in/task/ListTaskMembersUseCase.java | 33 ++++++++++++ .../port/in/task/UpdateTaskStatusUseCase.java | 50 +++++++++++++++++++ .../port/out/task/DeleteTaskMemberPort.java | 8 +++ .../port/out/task/DeleteTaskPort.java | 5 ++ .../port/out/task/LoadTaskMemberPort.java | 22 ++++++++ .../port/out/task/LoadTaskPort.java | 34 +++++++++++++ .../port/out/task/SaveTaskMemberPort.java | 13 +++++ .../port/out/task/SaveTaskPort.java | 30 +++++++++++ .../port/out/task/UpdateTaskPort.java | 23 +++++++++ 12 files changed, 283 insertions(+) create mode 100644 domain/src/main/java/com/btg/core/application/port/in/task/GetTaskProgressUseCase.java create mode 100644 domain/src/main/java/com/btg/core/application/port/in/task/JoinTaskUseCase.java create mode 100644 domain/src/main/java/com/btg/core/application/port/in/task/LeaveTaskUseCase.java create mode 100644 domain/src/main/java/com/btg/core/application/port/in/task/ListTaskMembersUseCase.java create mode 100644 domain/src/main/java/com/btg/core/application/port/in/task/UpdateTaskStatusUseCase.java create mode 100644 domain/src/main/java/com/btg/core/application/port/out/task/DeleteTaskMemberPort.java create mode 100644 domain/src/main/java/com/btg/core/application/port/out/task/DeleteTaskPort.java create mode 100644 domain/src/main/java/com/btg/core/application/port/out/task/LoadTaskMemberPort.java create mode 100644 domain/src/main/java/com/btg/core/application/port/out/task/LoadTaskPort.java create mode 100644 domain/src/main/java/com/btg/core/application/port/out/task/SaveTaskMemberPort.java create mode 100644 domain/src/main/java/com/btg/core/application/port/out/task/SaveTaskPort.java create mode 100644 domain/src/main/java/com/btg/core/application/port/out/task/UpdateTaskPort.java diff --git a/domain/src/main/java/com/btg/core/application/port/in/task/GetTaskProgressUseCase.java b/domain/src/main/java/com/btg/core/application/port/in/task/GetTaskProgressUseCase.java new file mode 100644 index 0000000..04f3306 --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/port/in/task/GetTaskProgressUseCase.java @@ -0,0 +1,14 @@ +package com.btg.core.application.port.in.task; + +public interface GetTaskProgressUseCase { + + TaskProgressResult getTaskProgress(Long taskId); + + record TaskProgressResult( + Long taskId, + Integer totalParticipants, + Integer totalDays, + Double overallCompletionRate, + Double averageCompletionRate + ) {} +} diff --git a/domain/src/main/java/com/btg/core/application/port/in/task/JoinTaskUseCase.java b/domain/src/main/java/com/btg/core/application/port/in/task/JoinTaskUseCase.java new file mode 100644 index 0000000..f8398cd --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/port/in/task/JoinTaskUseCase.java @@ -0,0 +1,31 @@ +package com.btg.core.application.port.in.task; + +public interface JoinTaskUseCase { + + TaskMemberResult joinTask(JoinTaskCommand command); + + record JoinTaskCommand( + Long taskId, + Long userId + ) { + public JoinTaskCommand { + if (taskId == null || taskId <= 0) { + throw new IllegalArgumentException("Task ID is required"); + } + if (userId == null || userId <= 0) { + throw new IllegalArgumentException("User ID is required"); + } + } + } + + record TaskMemberResult( + Long id, + UserInfo user, + Double completionRate, + Integer completedDays, + Integer totalDays, + Long joinedAt + ) {} + + record UserInfo(Long id, String email, String name) {} +} diff --git a/domain/src/main/java/com/btg/core/application/port/in/task/LeaveTaskUseCase.java b/domain/src/main/java/com/btg/core/application/port/in/task/LeaveTaskUseCase.java new file mode 100644 index 0000000..35fe8e0 --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/port/in/task/LeaveTaskUseCase.java @@ -0,0 +1,20 @@ +package com.btg.core.application.port.in.task; + +public interface LeaveTaskUseCase { + + void leaveTask(LeaveTaskCommand command); + + record LeaveTaskCommand( + Long taskId, + Long userId + ) { + public LeaveTaskCommand { + if (taskId == null || taskId <= 0) { + throw new IllegalArgumentException("Task ID is required"); + } + if (userId == null || userId <= 0) { + throw new IllegalArgumentException("User ID is required"); + } + } + } +} diff --git a/domain/src/main/java/com/btg/core/application/port/in/task/ListTaskMembersUseCase.java b/domain/src/main/java/com/btg/core/application/port/in/task/ListTaskMembersUseCase.java new file mode 100644 index 0000000..a8adea9 --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/port/in/task/ListTaskMembersUseCase.java @@ -0,0 +1,33 @@ +package com.btg.core.application.port.in.task; + +import java.util.List; + +public interface ListTaskMembersUseCase { + + TaskMemberListResult listTaskMembers(ListTaskMembersQuery query); + + record ListTaskMembersQuery(Long taskId) { + public ListTaskMembersQuery { + if (taskId == null || taskId <= 0) { + throw new IllegalArgumentException("Task ID is required"); + } + } + } + + record TaskMemberListResult( + List members, + Integer totalCount, + Double averageCompletionRate + ) {} + + record TaskMemberInfo( + Long id, + UserInfo user, + Double completionRate, + Integer completedDays, + Integer totalDays, + Long joinedAt + ) {} + + record UserInfo(Long id, String email, String name) {} +} diff --git a/domain/src/main/java/com/btg/core/application/port/in/task/UpdateTaskStatusUseCase.java b/domain/src/main/java/com/btg/core/application/port/in/task/UpdateTaskStatusUseCase.java new file mode 100644 index 0000000..f935262 --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/port/in/task/UpdateTaskStatusUseCase.java @@ -0,0 +1,50 @@ +package com.btg.core.application.port.in.task; + +import java.util.List; + +public interface UpdateTaskStatusUseCase { + + TaskResult updateStatus(UpdateStatusCommand command); + + record UpdateStatusCommand( + Long taskId, + Long userId, + String status + ) { + private static final List VALID_STATUSES = List.of("IN_PROGRESS", "COMPLETED", "CANCELLED"); + + public UpdateStatusCommand { + if (taskId == null || taskId <= 0) { + throw new IllegalArgumentException("Task ID is required"); + } + if (userId == null || userId <= 0) { + throw new IllegalArgumentException("User ID is required"); + } + if (status == null || status.isBlank()) { + throw new IllegalArgumentException("Status is required"); + } + if (!VALID_STATUSES.contains(status)) { + throw new IllegalArgumentException("Invalid status: " + status + ". Must be one of: " + VALID_STATUSES); + } + } + } + + record TaskResult( + Long id, + Long groupId, + String title, + String description, + String status, + String startDate, + String endDate, + Integer totalDays, + Integer participantCount, + Integer maxParticipants, + Double overallCompletionRate, + UserInfo createdBy, + String createdAt, + String updatedAt + ) {} + + record UserInfo(Long id, String email, String name) {} +} diff --git a/domain/src/main/java/com/btg/core/application/port/out/task/DeleteTaskMemberPort.java b/domain/src/main/java/com/btg/core/application/port/out/task/DeleteTaskMemberPort.java new file mode 100644 index 0000000..40b5d1d --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/port/out/task/DeleteTaskMemberPort.java @@ -0,0 +1,8 @@ +package com.btg.core.application.port.out.task; + +public interface DeleteTaskMemberPort { + + void deleteByTaskIdAndUserId(Long taskId, Long userId); + + void deleteAllByTaskId(Long taskId); +} diff --git a/domain/src/main/java/com/btg/core/application/port/out/task/DeleteTaskPort.java b/domain/src/main/java/com/btg/core/application/port/out/task/DeleteTaskPort.java new file mode 100644 index 0000000..9d0929d --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/port/out/task/DeleteTaskPort.java @@ -0,0 +1,5 @@ +package com.btg.core.application.port.out.task; + +public interface DeleteTaskPort { + void deleteById(Long taskId); +} diff --git a/domain/src/main/java/com/btg/core/application/port/out/task/LoadTaskMemberPort.java b/domain/src/main/java/com/btg/core/application/port/out/task/LoadTaskMemberPort.java new file mode 100644 index 0000000..6aa8e61 --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/port/out/task/LoadTaskMemberPort.java @@ -0,0 +1,22 @@ +package com.btg.core.application.port.out.task; + +import java.util.List; +import java.util.Optional; + +public interface LoadTaskMemberPort { + + Optional loadByTaskIdAndUserId(Long taskId, Long userId); + + List loadByTaskId(Long taskId); + + int countByTaskId(Long taskId); + + boolean existsByTaskIdAndUserId(Long taskId, Long userId); + + record TaskMember( + Long id, + Long taskId, + Long userId, + Long joinedAt + ) {} +} diff --git a/domain/src/main/java/com/btg/core/application/port/out/task/LoadTaskPort.java b/domain/src/main/java/com/btg/core/application/port/out/task/LoadTaskPort.java new file mode 100644 index 0000000..dabe1b0 --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/port/out/task/LoadTaskPort.java @@ -0,0 +1,34 @@ +package com.btg.core.application.port.out.task; + +import java.util.List; +import java.util.Optional; + +public interface LoadTaskPort { + + Optional loadById(Long taskId); + + PagedTask loadByGroupId(Long groupId, String status, int page, int size); + + record Task( + Long id, + Long groupId, + Long createdByUserId, + String title, + String description, + String status, + String startDate, + String endDate, + Integer totalDays, + Integer maxParticipants, + Long createdAt, + Long updatedAt + ) {} + + record PagedTask( + List content, + int totalElements, + int totalPages, + int page, + int size + ) {} +} diff --git a/domain/src/main/java/com/btg/core/application/port/out/task/SaveTaskMemberPort.java b/domain/src/main/java/com/btg/core/application/port/out/task/SaveTaskMemberPort.java new file mode 100644 index 0000000..49e5d6d --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/port/out/task/SaveTaskMemberPort.java @@ -0,0 +1,13 @@ +package com.btg.core.application.port.out.task; + +public interface SaveTaskMemberPort { + + TaskMember save(Long taskId, Long userId); + + record TaskMember( + Long id, + Long taskId, + Long userId, + Long joinedAt + ) {} +} diff --git a/domain/src/main/java/com/btg/core/application/port/out/task/SaveTaskPort.java b/domain/src/main/java/com/btg/core/application/port/out/task/SaveTaskPort.java new file mode 100644 index 0000000..e2e3569 --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/port/out/task/SaveTaskPort.java @@ -0,0 +1,30 @@ +package com.btg.core.application.port.out.task; + +public interface SaveTaskPort { + Task save( + Long groupId, + Long createdByUserId, + String title, + String description, + String status, + String startDate, + String endDate, + Integer totalDays, + Integer maxParticipants + ); + + record Task( + Long id, + Long groupId, + Long createdByUserId, + String title, + String description, + String status, + String startDate, + String endDate, + Integer totalDays, + Integer maxParticipants, + Long createdAt, + Long updatedAt + ) {} +} \ No newline at end of file diff --git a/domain/src/main/java/com/btg/core/application/port/out/task/UpdateTaskPort.java b/domain/src/main/java/com/btg/core/application/port/out/task/UpdateTaskPort.java new file mode 100644 index 0000000..7c17948 --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/port/out/task/UpdateTaskPort.java @@ -0,0 +1,23 @@ +package com.btg.core.application.port.out.task; + +public interface UpdateTaskPort { + + Task updateStatus(Long taskId, String newStatus); + + Task update(Long taskId, String title, String description, Integer maxParticipants); + + record Task( + Long id, + Long groupId, + Long createdByUserId, + String title, + String description, + String status, + String startDate, + String endDate, + Integer totalDays, + Integer maxParticipants, + Long createdAt, + Long updatedAt + ) {} +} From e4f56e4ca03e9ef27105ee19dc9615096c32a53f Mon Sep 17 00:00:00 2001 From: JBGeum Date: Thu, 8 Jan 2026 13:58:13 +0900 Subject: [PATCH 3/9] =?UTF-8?q?Feat(task):=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/task/CreateTaskService.java | 93 ++++++++++++ .../service/task/DeleteTaskService.java | 57 ++++++++ .../service/task/GetTaskProgressService.java | 52 +++++++ .../service/task/GetTaskService.java | 132 ++++++++++++++++++ .../service/task/JoinTaskService.java | 86 ++++++++++++ .../service/task/LeaveTaskService.java | 34 +++++ .../service/task/ListTaskMembersService.java | 64 +++++++++ .../service/task/ListTasksService.java | 95 +++++++++++++ .../service/task/UpdateTaskService.java | 101 ++++++++++++++ .../service/task/UpdateTaskStatusService.java | 111 +++++++++++++++ 10 files changed, 825 insertions(+) create mode 100644 domain/src/main/java/com/btg/core/application/service/task/CreateTaskService.java create mode 100644 domain/src/main/java/com/btg/core/application/service/task/DeleteTaskService.java create mode 100644 domain/src/main/java/com/btg/core/application/service/task/GetTaskProgressService.java create mode 100644 domain/src/main/java/com/btg/core/application/service/task/GetTaskService.java create mode 100644 domain/src/main/java/com/btg/core/application/service/task/JoinTaskService.java create mode 100644 domain/src/main/java/com/btg/core/application/service/task/LeaveTaskService.java create mode 100644 domain/src/main/java/com/btg/core/application/service/task/ListTaskMembersService.java create mode 100644 domain/src/main/java/com/btg/core/application/service/task/ListTasksService.java create mode 100644 domain/src/main/java/com/btg/core/application/service/task/UpdateTaskService.java create mode 100644 domain/src/main/java/com/btg/core/application/service/task/UpdateTaskStatusService.java diff --git a/domain/src/main/java/com/btg/core/application/service/task/CreateTaskService.java b/domain/src/main/java/com/btg/core/application/service/task/CreateTaskService.java new file mode 100644 index 0000000..3728316 --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/service/task/CreateTaskService.java @@ -0,0 +1,93 @@ +package com.btg.core.application.service.task; + +import com.btg.core.application.port.in.task.CreateTaskUseCase; +import com.btg.core.application.port.out.group.LoadGroupMemberPort; +import com.btg.core.application.port.out.group.LoadGroupPort; +import com.btg.core.application.port.out.task.SaveTaskMemberPort; +import com.btg.core.application.port.out.task.SaveTaskPort; +import com.btg.core.application.port.out.user.LoadUserPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; + +@Service +@RequiredArgsConstructor +@Transactional +public class CreateTaskService implements CreateTaskUseCase { + + private static final String TASK_STATUS_RECRUITING = "RECRUITING"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE; + + private final LoadUserPort loadUserPort; + private final LoadGroupPort loadGroupPort; + private final LoadGroupMemberPort loadGroupMemberPort; + private final SaveTaskPort saveTaskPort; + private final SaveTaskMemberPort saveTaskMemberPort; + + @Override + public TaskResult createTask(CreateTaskCommand command) { + LoadUserPort.User user = loadUserPort.loadById(command.userId()) + .orElseThrow(() -> new IllegalArgumentException("User not found: " + command.userId())); + + loadGroupPort.loadById(command.groupId()) + .orElseThrow(() -> new IllegalArgumentException("Group not found: " + command.groupId())); + + boolean isMember = loadGroupMemberPort.existsByGroupIdAndUserId(command.groupId(), command.userId()); + if (!isMember) { + throw new IllegalArgumentException("User is not a member of the group"); + } + + LocalDate startDate = parseDate(command.startDate(), "Invalid start date format"); + LocalDate endDate = parseDate(command.endDate(), "Invalid end date format"); + + if (startDate.isAfter(endDate)) { + throw new IllegalArgumentException("Start date must be before or equal to end date"); + } + + int totalDays = (int) ChronoUnit.DAYS.between(startDate, endDate) + 1; + + SaveTaskPort.Task savedTask = saveTaskPort.save( + command.groupId(), + command.userId(), + command.title(), + command.description(), + TASK_STATUS_RECRUITING, + command.startDate(), + command.endDate(), + totalDays, + command.maxParticipants() + ); + + saveTaskMemberPort.save(savedTask.id(), command.userId()); + + return new TaskResult( + savedTask.id(), + savedTask.groupId(), + savedTask.title(), + savedTask.description(), + savedTask.status(), + savedTask.startDate(), + savedTask.endDate(), + savedTask.totalDays(), + 1, + savedTask.maxParticipants(), + 0.0, + new UserInfo(user.id(), user.email(), user.name()), + String.valueOf(savedTask.createdAt()), + String.valueOf(savedTask.updatedAt()) + ); + } + + private LocalDate parseDate(String dateStr, String errorMessage) { + try { + return LocalDate.parse(dateStr, DATE_FORMATTER); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException(errorMessage + ": " + dateStr); + } + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/btg/core/application/service/task/DeleteTaskService.java b/domain/src/main/java/com/btg/core/application/service/task/DeleteTaskService.java new file mode 100644 index 0000000..361de48 --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/service/task/DeleteTaskService.java @@ -0,0 +1,57 @@ +package com.btg.core.application.service.task; + +import com.btg.core.application.port.in.task.DeleteTaskUseCase; +import com.btg.core.application.port.out.dailyprogress.DeleteDailyProgressPort; +import com.btg.core.application.port.out.group.LoadGroupMemberPort; +import com.btg.core.application.port.out.task.DeleteTaskMemberPort; +import com.btg.core.application.port.out.task.DeleteTaskPort; +import com.btg.core.application.port.out.task.LoadTaskPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class DeleteTaskService implements DeleteTaskUseCase { + + private static final String TASK_STATUS_RECRUITING = "RECRUITING"; + private static final String GROUP_ROLE_ADMIN = "ADMIN"; + + private final LoadTaskPort loadTaskPort; + private final LoadGroupMemberPort loadGroupMemberPort; + private final DeleteDailyProgressPort deleteDailyProgressPort; + private final DeleteTaskMemberPort deleteTaskMemberPort; + private final DeleteTaskPort deleteTaskPort; + + @Override + public void deleteTask(DeleteTaskCommand command) { + // 1. Task 조회 및 존재 확인 + LoadTaskPort.Task task = loadTaskPort.loadById(command.taskId()) + .orElseThrow(() -> new IllegalArgumentException("Task not found: " + command.taskId())); + + // 2. Task 상태가 RECRUITING인지 확인 + if (!TASK_STATUS_RECRUITING.equals(task.status())) { + throw new IllegalStateException("Only RECRUITING tasks can be deleted. Current status: " + task.status()); + } + + // 3. 권한 확인: Task 생성자이거나 Group ADMIN + boolean isCreator = task.createdByUserId().equals(command.userId()); + boolean isGroupAdmin = loadGroupMemberPort.loadByGroupIdAndUserId(task.groupId(), command.userId()) + .map(member -> GROUP_ROLE_ADMIN.equals(member.role())) + .orElse(false); + + if (!isCreator && !isGroupAdmin) { + throw new IllegalArgumentException("Only task creator or group admin can delete the task"); + } + + // 4. DailyProgress 삭제 (TaskMember에 종속) + deleteDailyProgressPort.deleteAllByTaskId(command.taskId()); + + // 5. TaskMember 삭제 + deleteTaskMemberPort.deleteAllByTaskId(command.taskId()); + + // 6. Task 삭제 + deleteTaskPort.deleteById(command.taskId()); + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/btg/core/application/service/task/GetTaskProgressService.java b/domain/src/main/java/com/btg/core/application/service/task/GetTaskProgressService.java new file mode 100644 index 0000000..4771759 --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/service/task/GetTaskProgressService.java @@ -0,0 +1,52 @@ +package com.btg.core.application.service.task; + +import com.btg.core.application.port.in.task.GetTaskProgressUseCase; +import com.btg.core.application.port.out.dailyprogress.LoadDailyProgressPort; +import com.btg.core.application.port.out.task.LoadTaskMemberPort; +import com.btg.core.application.port.out.task.LoadTaskPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GetTaskProgressService implements GetTaskProgressUseCase { + + private final LoadTaskPort loadTaskPort; + private final LoadTaskMemberPort loadTaskMemberPort; + private final LoadDailyProgressPort loadDailyProgressPort; + + @Override + public TaskProgressResult getTaskProgress(Long taskId) { + LoadTaskPort.Task task = loadTaskPort.loadById(taskId) + .orElseThrow(() -> new IllegalArgumentException("Task not found")); + + int totalParticipants = loadTaskMemberPort.countByTaskId(taskId); + + int totalCompleted = loadDailyProgressPort.countCompletedByTaskId(taskId); + int totalRecords = loadDailyProgressPort.countTotalByTaskId(taskId); + + double overallCompletionRate = totalRecords > 0 + ? (double) totalCompleted / totalRecords * 100 : 0.0; + + List members = loadTaskMemberPort.loadByTaskId(taskId); + double avgRate = members.stream() + .mapToDouble(m -> { + int completed = loadDailyProgressPort.countCompletedByTaskMemberId(m.id()); + return task.totalDays() > 0 ? (double) completed / task.totalDays() * 100 : 0.0; + }) + .average() + .orElse(0.0); + + return new TaskProgressResult( + taskId, + totalParticipants, + task.totalDays(), + overallCompletionRate, + avgRate + ); + } +} diff --git a/domain/src/main/java/com/btg/core/application/service/task/GetTaskService.java b/domain/src/main/java/com/btg/core/application/service/task/GetTaskService.java new file mode 100644 index 0000000..daaf98b --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/service/task/GetTaskService.java @@ -0,0 +1,132 @@ +package com.btg.core.application.service.task; + +import com.btg.core.application.port.in.task.GetTaskUseCase; +import com.btg.core.application.port.out.dailyprogress.LoadDailyProgressPort; +import com.btg.core.application.port.out.task.LoadTaskMemberPort; +import com.btg.core.application.port.out.task.LoadTaskPort; +import com.btg.core.application.port.out.user.LoadUserPort; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@Transactional(readOnly = true) +public class GetTaskService implements GetTaskUseCase { + + private final LoadTaskPort loadTaskPort; + private final LoadTaskMemberPort loadTaskMemberPort; + private final LoadDailyProgressPort loadDailyProgressPort; + private final LoadUserPort loadUserPort; + private final Executor executor; + + public GetTaskService( + LoadTaskPort loadTaskPort, + LoadTaskMemberPort loadTaskMemberPort, + LoadDailyProgressPort loadDailyProgressPort, + LoadUserPort loadUserPort, + @Qualifier("taskDetailExecutor") Executor executor) { + this.loadTaskPort = loadTaskPort; + this.loadTaskMemberPort = loadTaskMemberPort; + this.loadDailyProgressPort = loadDailyProgressPort; + this.loadUserPort = loadUserPort; + this.executor = executor; + } + + @Override + public TaskDetailResult getTask(Long taskId, Long userId) { + // 1. Task 기본 정보 조회 (필수, 다른 조회에 의존성 없음) + CompletableFuture taskFuture = CompletableFuture + .supplyAsync(() -> loadTaskPort.loadById(taskId) + .orElseThrow(() -> new IllegalArgumentException("Task not found: " + taskId)), executor); + + // 2. 참가자 수 조회 + CompletableFuture participantCountFuture = CompletableFuture + .supplyAsync(() -> loadTaskMemberPort.countByTaskId(taskId), executor); + + // 3. 전체 완료 수 조회 (overallCompletionRate 계산용) + CompletableFuture completedCountFuture = CompletableFuture + .supplyAsync(() -> loadDailyProgressPort.countCompletedByTaskId(taskId), executor); + + // 4. 전체 진행 레코드 수 조회 (overallCompletionRate 계산용) + CompletableFuture totalCountFuture = CompletableFuture + .supplyAsync(() -> loadDailyProgressPort.countTotalByTaskId(taskId), executor); + + // 5. 사용자 참가 여부 조회 + CompletableFuture isParticipatingFuture = CompletableFuture + .supplyAsync(() -> loadTaskMemberPort.existsByTaskIdAndUserId(taskId, userId), executor); + + // 6. 사용자의 TaskMember 조회 (myCompletionRate 계산용) + CompletableFuture taskMemberFuture = CompletableFuture + .supplyAsync(() -> loadTaskMemberPort.loadByTaskIdAndUserId(taskId, userId).orElse(null), executor); + + // 7. Task 조회 완료 후 생성자 정보 조회 (의존성: taskFuture) + CompletableFuture createdByUserFuture = taskFuture + .thenApplyAsync(task -> loadUserPort.loadById(task.createdByUserId()) + .orElseThrow(() -> new IllegalArgumentException("User not found: " + task.createdByUserId())), executor); + + // 8. TaskMember 조회 완료 후 myCompletionRate 계산 (의존성: taskMemberFuture, taskFuture) + CompletableFuture myCompletionRateFuture = taskMemberFuture + .thenCombineAsync(taskFuture, (taskMember, task) -> { + if (taskMember == null) { + return null; + } + int myCompletedCount = loadDailyProgressPort.countCompletedByTaskMemberId(taskMember.id()); + int totalDays = task.totalDays(); + return totalDays > 0 ? (double) myCompletedCount / totalDays * 100 : 0.0; + }, executor); + + // 모든 Future 완료 대기 및 결과 조합 + try { + CompletableFuture.allOf( + taskFuture, + participantCountFuture, + completedCountFuture, + totalCountFuture, + isParticipatingFuture, + createdByUserFuture, + myCompletionRateFuture + ).orTimeout(5, TimeUnit.SECONDS).join(); + + LoadTaskPort.Task task = taskFuture.join(); + Integer participantCount = participantCountFuture.join(); + Integer completedCount = completedCountFuture.join(); + Integer totalCount = totalCountFuture.join(); + Boolean isParticipating = isParticipatingFuture.join(); + LoadUserPort.User createdByUser = createdByUserFuture.join(); + Double myCompletionRate = myCompletionRateFuture.join(); + + double overallCompletionRate = totalCount > 0 ? (double) completedCount / totalCount * 100 : 0.0; + + return new TaskDetailResult( + task.id(), + task.groupId(), + task.title(), + task.description(), + task.status(), + task.startDate(), + task.endDate(), + task.totalDays(), + participantCount, + task.maxParticipants(), + Math.round(overallCompletionRate * 100.0) / 100.0, + new UserInfo(createdByUser.id(), createdByUser.email(), createdByUser.name()), + String.valueOf(task.createdAt()), + String.valueOf(task.updatedAt()), + isParticipating, + myCompletionRate != null ? Math.round(myCompletionRate * 100.0) / 100.0 : null + ); + } catch (Exception e) { + log.error("Failed to get task detail: taskId={}, userId={}", taskId, userId, e); + if (e.getCause() instanceof IllegalArgumentException) { + throw (IllegalArgumentException) e.getCause(); + } + throw new RuntimeException("Failed to get task detail", e); + } + } +} diff --git a/domain/src/main/java/com/btg/core/application/service/task/JoinTaskService.java b/domain/src/main/java/com/btg/core/application/service/task/JoinTaskService.java new file mode 100644 index 0000000..fb14612 --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/service/task/JoinTaskService.java @@ -0,0 +1,86 @@ +package com.btg.core.application.service.task; + +import com.btg.core.application.port.in.task.JoinTaskUseCase; +import com.btg.core.application.port.out.dailyprogress.SaveDailyProgressPort; +import com.btg.core.application.port.out.group.LoadGroupMemberPort; +import com.btg.core.application.port.out.task.LoadTaskMemberPort; +import com.btg.core.application.port.out.task.LoadTaskPort; +import com.btg.core.application.port.out.task.SaveTaskMemberPort; +import com.btg.core.application.port.out.user.LoadUserPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class JoinTaskService implements JoinTaskUseCase { + + private final LoadTaskPort loadTaskPort; + private final LoadGroupMemberPort loadGroupMemberPort; + private final LoadTaskMemberPort loadTaskMemberPort; + private final SaveTaskMemberPort saveTaskMemberPort; + private final SaveDailyProgressPort saveDailyProgressPort; + private final LoadUserPort loadUserPort; + + @Override + public TaskMemberResult joinTask(JoinTaskCommand command) { + LoadTaskPort.Task task = loadTaskPort.loadById(command.taskId()) + .orElseThrow(() -> new IllegalArgumentException("Task not found")); + + if (!loadGroupMemberPort.existsByGroupIdAndUserId(task.groupId(), command.userId())) { + throw new IllegalArgumentException("User is not a member of the group"); + } + + if (loadTaskMemberPort.existsByTaskIdAndUserId(command.taskId(), command.userId())) { + throw new IllegalArgumentException("Already participating in this task"); + } + + if (!"RECRUITING".equals(task.status())) { + throw new IllegalArgumentException("Cannot join task in " + task.status() + " status"); + } + + if (task.maxParticipants() != null) { + int currentCount = loadTaskMemberPort.countByTaskId(command.taskId()); + if (currentCount >= task.maxParticipants()) { + throw new IllegalArgumentException("Task is full"); + } + } + + SaveTaskMemberPort.TaskMember savedMember = saveTaskMemberPort.save( + command.taskId(), command.userId()); + + List entries = generateDailyProgressEntries( + task.startDate(), task.endDate()); + saveDailyProgressPort.saveAll(savedMember.id(), entries); + + LoadUserPort.User user = loadUserPort.loadById(command.userId()) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + return new TaskMemberResult( + savedMember.id(), + new UserInfo(user.id(), user.email(), user.name()), + 0.0, + 0, + task.totalDays(), + savedMember.joinedAt() + ); + } + + private List generateDailyProgressEntries( + String startDate, String endDate) { + LocalDate start = LocalDate.parse(startDate); + LocalDate end = LocalDate.parse(endDate); + List entries = new ArrayList<>(); + + for (LocalDate date = start; !date.isAfter(end); date = date.plusDays(1)) { + entries.add(new SaveDailyProgressPort.DailyProgressEntry(date.toString(), false)); + } + + return entries; + } +} diff --git a/domain/src/main/java/com/btg/core/application/service/task/LeaveTaskService.java b/domain/src/main/java/com/btg/core/application/service/task/LeaveTaskService.java new file mode 100644 index 0000000..b8db2ff --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/service/task/LeaveTaskService.java @@ -0,0 +1,34 @@ +package com.btg.core.application.service.task; + +import com.btg.core.application.port.in.task.LeaveTaskUseCase; +import com.btg.core.application.port.out.task.DeleteTaskMemberPort; +import com.btg.core.application.port.out.task.LoadTaskMemberPort; +import com.btg.core.application.port.out.task.LoadTaskPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class LeaveTaskService implements LeaveTaskUseCase { + + private final LoadTaskPort loadTaskPort; + private final LoadTaskMemberPort loadTaskMemberPort; + private final DeleteTaskMemberPort deleteTaskMemberPort; + + @Override + public void leaveTask(LeaveTaskCommand command) { + LoadTaskPort.Task task = loadTaskPort.loadById(command.taskId()) + .orElseThrow(() -> new IllegalArgumentException("Task not found")); + + loadTaskMemberPort.loadByTaskIdAndUserId(command.taskId(), command.userId()) + .orElseThrow(() -> new IllegalArgumentException("User is not participating in this task")); + + if ("IN_PROGRESS".equals(task.status())) { + throw new IllegalArgumentException("Cannot leave task in IN_PROGRESS status"); + } + + deleteTaskMemberPort.deleteByTaskIdAndUserId(command.taskId(), command.userId()); + } +} diff --git a/domain/src/main/java/com/btg/core/application/service/task/ListTaskMembersService.java b/domain/src/main/java/com/btg/core/application/service/task/ListTaskMembersService.java new file mode 100644 index 0000000..bf58860 --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/service/task/ListTaskMembersService.java @@ -0,0 +1,64 @@ +package com.btg.core.application.service.task; + +import com.btg.core.application.port.in.task.ListTaskMembersUseCase; +import com.btg.core.application.port.out.dailyprogress.LoadDailyProgressPort; +import com.btg.core.application.port.out.task.LoadTaskMemberPort; +import com.btg.core.application.port.out.task.LoadTaskPort; +import com.btg.core.application.port.out.user.LoadUserPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ListTaskMembersService implements ListTaskMembersUseCase { + + private final LoadTaskPort loadTaskPort; + private final LoadTaskMemberPort loadTaskMemberPort; + private final LoadDailyProgressPort loadDailyProgressPort; + private final LoadUserPort loadUserPort; + + @Override + public TaskMemberListResult listTaskMembers(ListTaskMembersQuery query) { + LoadTaskPort.Task task = loadTaskPort.loadById(query.taskId()) + .orElseThrow(() -> new IllegalArgumentException("Task not found")); + + List members = loadTaskMemberPort.loadByTaskId(query.taskId()); + + List memberInfos = members.stream() + .map(member -> buildTaskMemberInfo(member, task.totalDays())) + .collect(Collectors.toList()); + + double avgRate = memberInfos.stream() + .mapToDouble(TaskMemberInfo::completionRate) + .average() + .orElse(0.0); + + return new TaskMemberListResult(memberInfos, memberInfos.size(), avgRate); + } + + private TaskMemberInfo buildTaskMemberInfo(LoadTaskMemberPort.TaskMember member, Integer totalDays) { + LoadUserPort.User user = loadUserPort.loadById(member.userId()).orElse(null); + + int completedDays = loadDailyProgressPort.countCompletedByTaskMemberId(member.id()); + double completionRate = totalDays > 0 + ? (double) completedDays / totalDays * 100 : 0.0; + + UserInfo userInfo = user != null + ? new UserInfo(user.id(), user.email(), user.name()) + : new UserInfo(member.userId(), "unknown", "Unknown"); + + return new TaskMemberInfo( + member.id(), + userInfo, + completionRate, + completedDays, + totalDays, + member.joinedAt() + ); + } +} diff --git a/domain/src/main/java/com/btg/core/application/service/task/ListTasksService.java b/domain/src/main/java/com/btg/core/application/service/task/ListTasksService.java new file mode 100644 index 0000000..ccbe5e8 --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/service/task/ListTasksService.java @@ -0,0 +1,95 @@ +package com.btg.core.application.service.task; + +import com.btg.core.application.port.in.task.ListTasksUseCase; +import com.btg.core.application.port.out.dailyprogress.LoadDailyProgressPort; +import com.btg.core.application.port.out.task.LoadTaskMemberPort; +import com.btg.core.application.port.out.task.LoadTaskPort; +import com.btg.core.application.port.out.user.LoadUserPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ListTasksService implements ListTasksUseCase { + + private static final int DEFAULT_PAGE = 0; + private static final int DEFAULT_SIZE = 20; + private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_INSTANT; + + private final LoadTaskPort loadTaskPort; + private final LoadTaskMemberPort loadTaskMemberPort; + private final LoadDailyProgressPort loadDailyProgressPort; + private final LoadUserPort loadUserPort; + + @Override + public PagedTaskResult listTasks(ListTasksQuery query) { + int page = query.page() != null ? query.page() : DEFAULT_PAGE; + int size = query.size() != null ? query.size() : DEFAULT_SIZE; + String status = query.status() != null ? query.status() : "ALL"; + + if (query.groupId() == null) { + throw new IllegalArgumentException("Group ID is required for listing tasks"); + } + + LoadTaskPort.PagedTask pagedTask = loadTaskPort.loadByGroupId( + query.groupId(), + status, + page, + size + ); + + List taskSummaries = pagedTask.content().stream() + .map(this::toTaskSummary) + .toList(); + + return new PagedTaskResult( + taskSummaries, + pagedTask.totalElements(), + pagedTask.totalPages(), + pagedTask.page(), + pagedTask.size() + ); + } + + private TaskSummary toTaskSummary(LoadTaskPort.Task task) { + int participantCount = loadTaskMemberPort.countByTaskId(task.id()); + + int completedCount = loadDailyProgressPort.countCompletedByTaskId(task.id()); + int totalCount = loadDailyProgressPort.countTotalByTaskId(task.id()); + double overallCompletionRate = totalCount > 0 ? (double) completedCount / totalCount * 100 : 0.0; + + UserInfo createdBy = loadUserPort.loadById(task.createdByUserId()) + .map(user -> new UserInfo(user.id(), user.email(), user.name())) + .orElse(new UserInfo(task.createdByUserId(), "unknown", "Unknown User")); + + return new TaskSummary( + task.id(), + task.groupId(), + task.title(), + task.description(), + task.status(), + task.startDate(), + task.endDate(), + task.totalDays(), + participantCount, + task.maxParticipants(), + Math.round(overallCompletionRate * 100.0) / 100.0, + createdBy, + formatTimestamp(task.createdAt()), + formatTimestamp(task.updatedAt()) + ); + } + + private String formatTimestamp(Long epochMilli) { + return Instant.ofEpochMilli(epochMilli) + .atOffset(ZoneOffset.UTC) + .format(ISO_FORMATTER); + } +} diff --git a/domain/src/main/java/com/btg/core/application/service/task/UpdateTaskService.java b/domain/src/main/java/com/btg/core/application/service/task/UpdateTaskService.java new file mode 100644 index 0000000..44e3918 --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/service/task/UpdateTaskService.java @@ -0,0 +1,101 @@ +package com.btg.core.application.service.task; + +import com.btg.core.application.port.in.task.UpdateTaskUseCase; +import com.btg.core.application.port.out.dailyprogress.LoadDailyProgressPort; +import com.btg.core.application.port.out.group.LoadGroupMemberPort; +import com.btg.core.application.port.out.task.LoadTaskMemberPort; +import com.btg.core.application.port.out.task.LoadTaskPort; +import com.btg.core.application.port.out.task.UpdateTaskPort; +import com.btg.core.application.port.out.user.LoadUserPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +@Service +@RequiredArgsConstructor +@Transactional +public class UpdateTaskService implements UpdateTaskUseCase { + + private static final String GROUP_ROLE_ADMIN = "ADMIN"; + private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_INSTANT; + + private final LoadTaskPort loadTaskPort; + private final UpdateTaskPort updateTaskPort; + private final LoadGroupMemberPort loadGroupMemberPort; + private final LoadTaskMemberPort loadTaskMemberPort; + private final LoadDailyProgressPort loadDailyProgressPort; + private final LoadUserPort loadUserPort; + + @Override + public TaskResult updateTask(UpdateTaskCommand command) { + // 1. Task 조회 및 존재 확인 + LoadTaskPort.Task task = loadTaskPort.loadById(command.taskId()) + .orElseThrow(() -> new IllegalArgumentException("Task not found: " + command.taskId())); + + // 2. 권한 확인: Task 생성자이거나 Group ADMIN + boolean isCreator = task.createdByUserId().equals(command.userId()); + boolean isGroupAdmin = loadGroupMemberPort.loadByGroupIdAndUserId(task.groupId(), command.userId()) + .map(member -> GROUP_ROLE_ADMIN.equals(member.role())) + .orElse(false); + + if (!isCreator && !isGroupAdmin) { + throw new IllegalArgumentException("Only task creator or group admin can update the task"); + } + + // 3. maxParticipants 변경 시 현재 참가자 수 확인 + if (command.maxParticipants() != null) { + int currentParticipants = loadTaskMemberPort.countByTaskId(command.taskId()); + if (command.maxParticipants() < currentParticipants) { + throw new IllegalStateException( + "Cannot reduce maxParticipants below current participant count: " + currentParticipants + ); + } + } + + // 4. Task 업데이트 + UpdateTaskPort.Task updatedTask = updateTaskPort.update( + command.taskId(), + command.title(), + command.description(), + command.maxParticipants() + ); + + // 5. 통계 계산 + int participantCount = loadTaskMemberPort.countByTaskId(command.taskId()); + int completedCount = loadDailyProgressPort.countCompletedByTaskId(command.taskId()); + int totalCount = loadDailyProgressPort.countTotalByTaskId(command.taskId()); + double overallCompletionRate = totalCount > 0 ? (double) completedCount / totalCount * 100 : 0.0; + + // 6. 생성자 정보 조회 + UserInfo createdBy = loadUserPort.loadById(updatedTask.createdByUserId()) + .map(user -> new UserInfo(user.id(), user.email(), user.name())) + .orElse(new UserInfo(updatedTask.createdByUserId(), "unknown", "Unknown User")); + + return new TaskResult( + updatedTask.id(), + updatedTask.groupId(), + updatedTask.title(), + updatedTask.description(), + updatedTask.status(), + updatedTask.startDate(), + updatedTask.endDate(), + updatedTask.totalDays(), + participantCount, + updatedTask.maxParticipants(), + Math.round(overallCompletionRate * 100.0) / 100.0, + createdBy, + formatTimestamp(updatedTask.createdAt()), + formatTimestamp(updatedTask.updatedAt()) + ); + } + + private String formatTimestamp(Long epochMilli) { + return Instant.ofEpochMilli(epochMilli) + .atOffset(ZoneOffset.UTC) + .format(ISO_FORMATTER); + } +} diff --git a/domain/src/main/java/com/btg/core/application/service/task/UpdateTaskStatusService.java b/domain/src/main/java/com/btg/core/application/service/task/UpdateTaskStatusService.java new file mode 100644 index 0000000..2bef092 --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/service/task/UpdateTaskStatusService.java @@ -0,0 +1,111 @@ +package com.btg.core.application.service.task; + +import com.btg.core.application.port.in.task.UpdateTaskStatusUseCase; +import com.btg.core.application.port.out.dailyprogress.LoadDailyProgressPort; +import com.btg.core.application.port.out.group.LoadGroupMemberPort; +import com.btg.core.application.port.out.task.LoadTaskMemberPort; +import com.btg.core.application.port.out.task.LoadTaskPort; +import com.btg.core.application.port.out.task.UpdateTaskPort; +import com.btg.core.application.port.out.user.LoadUserPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Map; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Transactional +public class UpdateTaskStatusService implements UpdateTaskStatusUseCase { + + private final LoadTaskPort loadTaskPort; + private final UpdateTaskPort updateTaskPort; + private final LoadGroupMemberPort loadGroupMemberPort; + private final LoadTaskMemberPort loadTaskMemberPort; + private final LoadDailyProgressPort loadDailyProgressPort; + private final LoadUserPort loadUserPort; + + private static final Map> VALID_TRANSITIONS = Map.of( + "RECRUITING", Set.of("IN_PROGRESS", "CANCELLED"), + "IN_PROGRESS", Set.of("COMPLETED", "CANCELLED") + ); + + @Override + public TaskResult updateStatus(UpdateStatusCommand command) { + LoadTaskPort.Task task = loadTaskPort.loadById(command.taskId()) + .orElseThrow(() -> new IllegalArgumentException("Task not found")); + + validatePermission(task, command.userId()); + validateStateTransition(task.status(), command.status()); + + UpdateTaskPort.Task updated = updateTaskPort.updateStatus(command.taskId(), command.status()); + + return buildTaskResult(updated); + } + + private void validatePermission(LoadTaskPort.Task task, Long userId) { + boolean isCreator = task.createdByUserId().equals(userId); + + boolean isGroupAdmin = loadGroupMemberPort.loadByGroupIdAndUserId(task.groupId(), userId) + .map(member -> "ADMIN".equals(member.role())) + .orElse(false); + + if (!isCreator && !isGroupAdmin) { + throw new IllegalArgumentException("Permission denied: only creator or group admin can change task status"); + } + } + + private void validateStateTransition(String currentStatus, String newStatus) { + Set allowedTransitions = VALID_TRANSITIONS.get(currentStatus); + + if (allowedTransitions == null) { + throw new IllegalStateException("Cannot change status from " + currentStatus); + } + + if (!allowedTransitions.contains(newStatus)) { + throw new IllegalStateException( + "Invalid state transition: " + currentStatus + " -> " + newStatus + + ". Allowed transitions: " + allowedTransitions + ); + } + } + + private TaskResult buildTaskResult(UpdateTaskPort.Task task) { + int participantCount = loadTaskMemberPort.countByTaskId(task.id()); + + int totalCompleted = loadDailyProgressPort.countCompletedByTaskId(task.id()); + int totalRecords = loadDailyProgressPort.countTotalByTaskId(task.id()); + double overallCompletionRate = totalRecords > 0 + ? (double) totalCompleted / totalRecords * 100 : 0.0; + + LoadUserPort.User creator = loadUserPort.loadById(task.createdByUserId()) + .orElse(null); + + UserInfo createdBy = creator != null + ? new UserInfo(creator.id(), creator.email(), creator.name()) + : new UserInfo(task.createdByUserId(), "unknown", "Unknown"); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'"); + + return new TaskResult( + task.id(), + task.groupId(), + task.title(), + task.description(), + task.status(), + task.startDate(), + task.endDate(), + task.totalDays(), + participantCount, + task.maxParticipants(), + overallCompletionRate, + createdBy, + Instant.ofEpochMilli(task.createdAt()).atOffset(ZoneOffset.UTC).format(formatter), + Instant.ofEpochMilli(task.updatedAt()).atOffset(ZoneOffset.UTC).format(formatter) + ); + } +} From b67027b77b703867c5df5581198c1dc948e4f0c4 Mon Sep 17 00:00:00 2001 From: JBGeum Date: Thu, 8 Jan 2026 14:00:03 +0900 Subject: [PATCH 4/9] =?UTF-8?q?Feat(task):=20Task=20=EB=B0=8F=20TaskMember?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20Persistence=20Adapter=20=EB=B0=8F=20Ent?= =?UTF-8?q?ity=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/TaskMemberPersistenceAdapter.java | 80 ++++++++++ .../task/adapter/TaskPersistenceAdapter.java | 150 ++++++++++++++++++ .../task/entity/TaskJpaEntity.java | 94 +++++++++++ .../task/entity/TaskMemberJpaEntity.java | 41 +++++ .../task/repository/TaskJpaRepository.java | 17 ++ .../repository/TaskMemberJpaRepository.java | 22 +++ 6 files changed, 404 insertions(+) create mode 100644 infrastructure/src/main/java/com/btg/infrastructure/persistence/task/adapter/TaskMemberPersistenceAdapter.java create mode 100644 infrastructure/src/main/java/com/btg/infrastructure/persistence/task/adapter/TaskPersistenceAdapter.java create mode 100644 infrastructure/src/main/java/com/btg/infrastructure/persistence/task/entity/TaskJpaEntity.java create mode 100644 infrastructure/src/main/java/com/btg/infrastructure/persistence/task/entity/TaskMemberJpaEntity.java create mode 100644 infrastructure/src/main/java/com/btg/infrastructure/persistence/task/repository/TaskJpaRepository.java create mode 100644 infrastructure/src/main/java/com/btg/infrastructure/persistence/task/repository/TaskMemberJpaRepository.java diff --git a/infrastructure/src/main/java/com/btg/infrastructure/persistence/task/adapter/TaskMemberPersistenceAdapter.java b/infrastructure/src/main/java/com/btg/infrastructure/persistence/task/adapter/TaskMemberPersistenceAdapter.java new file mode 100644 index 0000000..7cb32b4 --- /dev/null +++ b/infrastructure/src/main/java/com/btg/infrastructure/persistence/task/adapter/TaskMemberPersistenceAdapter.java @@ -0,0 +1,80 @@ +package com.btg.infrastructure.persistence.task.adapter; + +import com.btg.core.application.port.out.task.DeleteTaskMemberPort; +import com.btg.core.application.port.out.task.LoadTaskMemberPort; +import com.btg.core.application.port.out.task.SaveTaskMemberPort; +import com.btg.infrastructure.persistence.task.entity.TaskMemberJpaEntity; +import com.btg.infrastructure.persistence.task.repository.TaskMemberJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class TaskMemberPersistenceAdapter implements + SaveTaskMemberPort, LoadTaskMemberPort, DeleteTaskMemberPort { + + private final TaskMemberJpaRepository taskMemberRepository; + + @Override + public SaveTaskMemberPort.TaskMember save(Long taskId, Long userId) { + TaskMemberJpaEntity entity = new TaskMemberJpaEntity(taskId, userId); + TaskMemberJpaEntity saved = taskMemberRepository.save(entity); + + return new SaveTaskMemberPort.TaskMember( + saved.getId(), + saved.getTaskId(), + saved.getUserId(), + saved.getJoinedAt().toInstant(ZoneOffset.UTC).toEpochMilli() + ); + } + + @Override + public Optional loadByTaskIdAndUserId(Long taskId, Long userId) { + return taskMemberRepository.findByTaskIdAndUserId(taskId, userId) + .map(this::toTaskMember); + } + + @Override + public List loadByTaskId(Long taskId) { + return taskMemberRepository.findByTaskIdOrderByJoinedAtAsc(taskId).stream() + .map(this::toTaskMember) + .collect(Collectors.toList()); + } + + @Override + public int countByTaskId(Long taskId) { + return taskMemberRepository.countByTaskId(taskId); + } + + @Override + public boolean existsByTaskIdAndUserId(Long taskId, Long userId) { + return taskMemberRepository.existsByTaskIdAndUserId(taskId, userId); + } + + @Override + @Transactional + public void deleteByTaskIdAndUserId(Long taskId, Long userId) { + taskMemberRepository.deleteByTaskIdAndUserId(taskId, userId); + } + + @Override + @Transactional + public void deleteAllByTaskId(Long taskId) { + taskMemberRepository.deleteByTaskId(taskId); + } + + private LoadTaskMemberPort.TaskMember toTaskMember(TaskMemberJpaEntity entity) { + return new LoadTaskMemberPort.TaskMember( + entity.getId(), + entity.getTaskId(), + entity.getUserId(), + entity.getJoinedAt().toInstant(ZoneOffset.UTC).toEpochMilli() + ); + } +} diff --git a/infrastructure/src/main/java/com/btg/infrastructure/persistence/task/adapter/TaskPersistenceAdapter.java b/infrastructure/src/main/java/com/btg/infrastructure/persistence/task/adapter/TaskPersistenceAdapter.java new file mode 100644 index 0000000..6a7006f --- /dev/null +++ b/infrastructure/src/main/java/com/btg/infrastructure/persistence/task/adapter/TaskPersistenceAdapter.java @@ -0,0 +1,150 @@ +package com.btg.infrastructure.persistence.task.adapter; + +import com.btg.core.application.port.out.task.DeleteTaskPort; +import com.btg.core.application.port.out.task.LoadTaskPort; +import com.btg.core.application.port.out.task.SaveTaskPort; +import com.btg.core.application.port.out.task.UpdateTaskPort; +import com.btg.infrastructure.persistence.task.entity.TaskJpaEntity; +import com.btg.infrastructure.persistence.task.repository.TaskJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; + +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class TaskPersistenceAdapter implements LoadTaskPort, SaveTaskPort, UpdateTaskPort, DeleteTaskPort { + + private final TaskJpaRepository taskRepository; + + @Override + public SaveTaskPort.Task save(Long groupId, Long createdByUserId, String title, String description, + String status, String startDate, String endDate, + Integer totalDays, Integer maxParticipants) { + TaskJpaEntity entity = new TaskJpaEntity( + groupId, + createdByUserId, + title, + description, + status, + LocalDate.parse(startDate), + LocalDate.parse(endDate), + totalDays, + maxParticipants + ); + TaskJpaEntity saved = taskRepository.save(entity); + return toSaveTaskPortTask(saved); + } + + @Override + public Optional loadById(Long taskId) { + return taskRepository.findById(taskId) + .map(this::toLoadTaskPortTask); + } + + @Override + public PagedTask loadByGroupId(Long groupId, String status, int page, int size) { + PageRequest pageRequest = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + + Page taskPage; + if (status == null || status.isBlank() || "ALL".equals(status)) { + taskPage = taskRepository.findByGroupId(groupId, pageRequest); + } else { + taskPage = taskRepository.findByGroupIdAndStatus(groupId, status, pageRequest); + } + + return new PagedTask( + taskPage.getContent().stream().map(this::toLoadTaskPortTask).toList(), + (int) taskPage.getTotalElements(), + taskPage.getTotalPages(), + taskPage.getNumber(), + taskPage.getSize() + ); + } + + @Override + public UpdateTaskPort.Task updateStatus(Long taskId, String newStatus) { + TaskJpaEntity entity = taskRepository.findById(taskId) + .orElseThrow(() -> new IllegalArgumentException("Task not found: " + taskId)); + + entity.updateStatus(newStatus); + TaskJpaEntity saved = taskRepository.save(entity); + + return toUpdateTaskPortTask(saved); + } + + @Override + public UpdateTaskPort.Task update(Long taskId, String title, String description, Integer maxParticipants) { + TaskJpaEntity entity = taskRepository.findById(taskId) + .orElseThrow(() -> new IllegalArgumentException("Task not found: " + taskId)); + + entity.update(title, description, maxParticipants); + TaskJpaEntity saved = taskRepository.save(entity); + + return toUpdateTaskPortTask(saved); + } + + @Override + @Transactional + public void deleteById(Long taskId) { + taskRepository.deleteById(taskId); + } + + private SaveTaskPort.Task toSaveTaskPortTask(TaskJpaEntity entity) { + return new SaveTaskPort.Task( + entity.getId(), + entity.getGroupId(), + entity.getCreatedByUserId(), + entity.getTitle(), + entity.getDescription(), + entity.getStatus(), + entity.getStartDate().toString(), + entity.getEndDate().toString(), + entity.getTotalDays(), + entity.getMaxParticipants(), + entity.getCreatedAt().toInstant(ZoneOffset.UTC).toEpochMilli(), + entity.getUpdatedAt().toInstant(ZoneOffset.UTC).toEpochMilli() + ); + } + + private LoadTaskPort.Task toLoadTaskPortTask(TaskJpaEntity entity) { + return new LoadTaskPort.Task( + entity.getId(), + entity.getGroupId(), + entity.getCreatedByUserId(), + entity.getTitle(), + entity.getDescription(), + entity.getStatus(), + entity.getStartDate().toString(), + entity.getEndDate().toString(), + entity.getTotalDays(), + entity.getMaxParticipants(), + entity.getCreatedAt().toInstant(ZoneOffset.UTC).toEpochMilli(), + entity.getUpdatedAt().toInstant(ZoneOffset.UTC).toEpochMilli() + ); + } + + private UpdateTaskPort.Task toUpdateTaskPortTask(TaskJpaEntity entity) { + return new UpdateTaskPort.Task( + entity.getId(), + entity.getGroupId(), + entity.getCreatedByUserId(), + entity.getTitle(), + entity.getDescription(), + entity.getStatus(), + entity.getStartDate().toString(), + entity.getEndDate().toString(), + entity.getTotalDays(), + entity.getMaxParticipants(), + entity.getCreatedAt().toInstant(ZoneOffset.UTC).toEpochMilli(), + entity.getUpdatedAt().toInstant(ZoneOffset.UTC).toEpochMilli() + ); + } +} diff --git a/infrastructure/src/main/java/com/btg/infrastructure/persistence/task/entity/TaskJpaEntity.java b/infrastructure/src/main/java/com/btg/infrastructure/persistence/task/entity/TaskJpaEntity.java new file mode 100644 index 0000000..003de1f --- /dev/null +++ b/infrastructure/src/main/java/com/btg/infrastructure/persistence/task/entity/TaskJpaEntity.java @@ -0,0 +1,94 @@ +package com.btg.infrastructure.persistence.task.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "tasks") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TaskJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "group_id", nullable = false) + private Long groupId; + + @Column(name = "created_by_user_id", nullable = false) + private Long createdByUserId; + + @Column(nullable = false, length = 200) + private String title; + + @Column(length = 1000) + private String description; + + @Column(nullable = false, length = 20) + private String status; + + @Column(name = "start_date", nullable = false) + private LocalDate startDate; + + @Column(name = "end_date", nullable = false) + private LocalDate endDate; + + @Column(name = "total_days", nullable = false) + private Integer totalDays; + + @Column(name = "max_participants") + private Integer maxParticipants; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } + + public TaskJpaEntity(Long groupId, Long createdByUserId, String title, String description, + String status, LocalDate startDate, LocalDate endDate, + Integer totalDays, Integer maxParticipants) { + this.groupId = groupId; + this.createdByUserId = createdByUserId; + this.title = title; + this.description = description; + this.status = status; + this.startDate = startDate; + this.endDate = endDate; + this.totalDays = totalDays; + this.maxParticipants = maxParticipants; + } + + public void updateStatus(String newStatus) { + this.status = newStatus; + } + + public void update(String title, String description, Integer maxParticipants) { + if (title != null) { + this.title = title; + } + if (description != null) { + this.description = description; + } + if (maxParticipants != null) { + this.maxParticipants = maxParticipants; + } + } +} diff --git a/infrastructure/src/main/java/com/btg/infrastructure/persistence/task/entity/TaskMemberJpaEntity.java b/infrastructure/src/main/java/com/btg/infrastructure/persistence/task/entity/TaskMemberJpaEntity.java new file mode 100644 index 0000000..703b9ec --- /dev/null +++ b/infrastructure/src/main/java/com/btg/infrastructure/persistence/task/entity/TaskMemberJpaEntity.java @@ -0,0 +1,41 @@ +package com.btg.infrastructure.persistence.task.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table( + name = "task_members", + uniqueConstraints = @UniqueConstraint(columnNames = {"task_id", "user_id"}) +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TaskMemberJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "task_id", nullable = false) + private Long taskId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(nullable = false, updatable = false) + private LocalDateTime joinedAt; + + @PrePersist + protected void onCreate() { + joinedAt = LocalDateTime.now(); + } + + public TaskMemberJpaEntity(Long taskId, Long userId) { + this.taskId = taskId; + this.userId = userId; + } +} diff --git a/infrastructure/src/main/java/com/btg/infrastructure/persistence/task/repository/TaskJpaRepository.java b/infrastructure/src/main/java/com/btg/infrastructure/persistence/task/repository/TaskJpaRepository.java new file mode 100644 index 0000000..75eb7a8 --- /dev/null +++ b/infrastructure/src/main/java/com/btg/infrastructure/persistence/task/repository/TaskJpaRepository.java @@ -0,0 +1,17 @@ +package com.btg.infrastructure.persistence.task.repository; + +import com.btg.infrastructure.persistence.task.entity.TaskJpaEntity; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface TaskJpaRepository extends JpaRepository { + + Optional findById(Long id); + + Page findByGroupId(Long groupId, Pageable pageable); + + Page findByGroupIdAndStatus(Long groupId, String status, Pageable pageable); +} diff --git a/infrastructure/src/main/java/com/btg/infrastructure/persistence/task/repository/TaskMemberJpaRepository.java b/infrastructure/src/main/java/com/btg/infrastructure/persistence/task/repository/TaskMemberJpaRepository.java new file mode 100644 index 0000000..cb7b6a0 --- /dev/null +++ b/infrastructure/src/main/java/com/btg/infrastructure/persistence/task/repository/TaskMemberJpaRepository.java @@ -0,0 +1,22 @@ +package com.btg.infrastructure.persistence.task.repository; + +import com.btg.infrastructure.persistence.task.entity.TaskMemberJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface TaskMemberJpaRepository extends JpaRepository { + + Optional findByTaskIdAndUserId(Long taskId, Long userId); + + List findByTaskIdOrderByJoinedAtAsc(Long taskId); + + int countByTaskId(Long taskId); + + boolean existsByTaskIdAndUserId(Long taskId, Long userId); + + void deleteByTaskIdAndUserId(Long taskId, Long userId); + + void deleteByTaskId(Long taskId); +} From 18a0efa07cfe1889de8e4eab8c51abb611521ee3 Mon Sep 17 00:00:00 2001 From: JBGeum Date: Thu, 8 Jan 2026 14:05:33 +0900 Subject: [PATCH 5/9] =?UTF-8?q?Feat(task):=20Task=20=EA=B4=80=EB=A0=A8=20C?= =?UTF-8?q?ontroller,=20DTO,=20Mapper=20=EB=B0=8F=20UseCase=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/mapper/TaskResponseMapper.java | 27 ++++-- .../web/task/TaskController.java | 89 +++++++++++++++---- .../dto/request/UpdateTaskStatusRequest.java | 8 ++ .../dto/response/TaskMemberListResponse.java | 9 ++ .../task/dto/response/TaskMemberResponse.java | 10 +++ .../dto/response/TaskProgressResponse.java | 9 ++ 6 files changed, 129 insertions(+), 23 deletions(-) create mode 100644 infrastructure/src/main/java/com/btg/infrastructure/web/task/dto/request/UpdateTaskStatusRequest.java create mode 100644 infrastructure/src/main/java/com/btg/infrastructure/web/task/dto/response/TaskMemberListResponse.java create mode 100644 infrastructure/src/main/java/com/btg/infrastructure/web/task/dto/response/TaskMemberResponse.java create mode 100644 infrastructure/src/main/java/com/btg/infrastructure/web/task/dto/response/TaskProgressResponse.java diff --git a/infrastructure/src/main/java/com/btg/infrastructure/web/mapper/TaskResponseMapper.java b/infrastructure/src/main/java/com/btg/infrastructure/web/mapper/TaskResponseMapper.java index e6526b6..1f980fd 100644 --- a/infrastructure/src/main/java/com/btg/infrastructure/web/mapper/TaskResponseMapper.java +++ b/infrastructure/src/main/java/com/btg/infrastructure/web/mapper/TaskResponseMapper.java @@ -1,13 +1,7 @@ package com.btg.infrastructure.web.mapper; -import com.btg.core.application.port.in.task.CreateTaskUseCase; -import com.btg.core.application.port.in.task.GetTaskUseCase; -import com.btg.core.application.port.in.task.ListTasksUseCase; -import com.btg.core.application.port.in.task.UpdateTaskUseCase; -import com.btg.infrastructure.web.task.dto.response.PagedTaskResponse; -import com.btg.infrastructure.web.task.dto.response.TaskDetailResponse; -import com.btg.infrastructure.web.task.dto.response.TaskResponse; -import com.btg.infrastructure.web.task.dto.response.UserResponse; +import com.btg.core.application.port.in.task.*; +import com.btg.infrastructure.web.task.dto.response.*; import org.mapstruct.Mapper; import org.mapstruct.ReportingPolicy; @@ -27,6 +21,10 @@ public interface TaskResponseMapper { TaskResponse toResponse(UpdateTaskUseCase.TaskResult result); UserResponse toUserResponse(UpdateTaskUseCase.UserInfo userInfo); + // UpdateTaskStatusUseCase 변환 + TaskResponse toResponse(UpdateTaskStatusUseCase.TaskResult result); + UserResponse toUserResponse(UpdateTaskStatusUseCase.UserInfo userInfo); + // GetTaskUseCase 변환 TaskDetailResponse toDetailResponse(GetTaskUseCase.TaskDetailResult result); UserResponse toUserResponse(GetTaskUseCase.UserInfo userInfo); @@ -36,4 +34,17 @@ public interface TaskResponseMapper { UserResponse toUserResponse(ListTasksUseCase.UserInfo userInfo); List toResponseList(List summaries); PagedTaskResponse toPagedResponse(ListTasksUseCase.PagedTaskResult result); + + // JoinTaskUseCase 변환 + TaskMemberResponse toMemberResponse(JoinTaskUseCase.TaskMemberResult result); + UserResponse toUserResponse(JoinTaskUseCase.UserInfo userInfo); + + // ListTaskMembersUseCase 변환 + TaskMemberResponse toMemberResponse(ListTaskMembersUseCase.TaskMemberInfo memberInfo); + UserResponse toUserResponse(ListTaskMembersUseCase.UserInfo userInfo); + List toMemberResponseList(List members); + TaskMemberListResponse toMemberListResponse(ListTaskMembersUseCase.TaskMemberListResult result); + + // GetTaskProgressUseCase 변환 + TaskProgressResponse toProgressResponse(GetTaskProgressUseCase.TaskProgressResult result); } diff --git a/infrastructure/src/main/java/com/btg/infrastructure/web/task/TaskController.java b/infrastructure/src/main/java/com/btg/infrastructure/web/task/TaskController.java index 9fa99c1..8e10e94 100644 --- a/infrastructure/src/main/java/com/btg/infrastructure/web/task/TaskController.java +++ b/infrastructure/src/main/java/com/btg/infrastructure/web/task/TaskController.java @@ -1,9 +1,11 @@ package com.btg.infrastructure.web.task; import com.btg.core.application.port.in.task.*; +import com.btg.infrastructure.security.SecurityContextUtil; import com.btg.infrastructure.web.mapper.TaskResponseMapper; import com.btg.infrastructure.web.task.dto.request.CreateTaskRequest; import com.btg.infrastructure.web.task.dto.request.UpdateTaskRequest; +import com.btg.infrastructure.web.task.dto.request.UpdateTaskStatusRequest; import com.btg.infrastructure.web.task.dto.response.*; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -21,12 +23,16 @@ public class TaskController { private final UpdateTaskUseCase updateTaskUseCase; private final DeleteTaskUseCase deleteTaskUseCase; private final ListTasksUseCase listTasksUseCase; + private final UpdateTaskStatusUseCase updateTaskStatusUseCase; + private final JoinTaskUseCase joinTaskUseCase; + private final LeaveTaskUseCase leaveTaskUseCase; + private final ListTaskMembersUseCase listTaskMembersUseCase; + private final GetTaskProgressUseCase getTaskProgressUseCase; private final TaskResponseMapper taskResponseMapper; @PostMapping public ResponseEntity createTask(@Valid @RequestBody CreateTaskRequest request) { - // TODO: Get authenticated user ID from SecurityContext - Long userId = 1L; + Long userId = SecurityContextUtil.getCurrentUserId(); CreateTaskUseCase.CreateTaskCommand command = new CreateTaskUseCase.CreateTaskCommand( userId, @@ -51,8 +57,7 @@ public ResponseEntity listTasks( @RequestParam(defaultValue = "0") Integer page, @RequestParam(defaultValue = "20") Integer size ) { - // TODO: Get authenticated user ID from SecurityContext - Long userId = 1L; + Long userId = SecurityContextUtil.getCurrentUserId(); ListTasksUseCase.ListTasksQuery query = new ListTasksUseCase.ListTasksQuery( userId, @@ -69,23 +74,37 @@ public ResponseEntity listTasks( @GetMapping("/{taskId}") public ResponseEntity getTask(@PathVariable Long taskId) { - // TODO: Get authenticated user ID from SecurityContext - Long userId = 1L; + Long userId = SecurityContextUtil.getCurrentUserId(); GetTaskUseCase.TaskDetailResult result = getTaskUseCase.getTask(taskId, userId); return ResponseEntity.ok(taskResponseMapper.toDetailResponse(result)); } - // TODO: PATCH /tasks/{taskId}/status - Task 상태 변경 + @PatchMapping("/{taskId}/status") + public ResponseEntity updateTaskStatus( + @PathVariable Long taskId, + @Valid @RequestBody UpdateTaskStatusRequest request + ) { + Long userId = SecurityContextUtil.getCurrentUserId(); + + UpdateTaskStatusUseCase.UpdateStatusCommand command = new UpdateTaskStatusUseCase.UpdateStatusCommand( + taskId, + userId, + request.status() + ); + + UpdateTaskStatusUseCase.TaskResult result = updateTaskStatusUseCase.updateStatus(command); + + return ResponseEntity.ok(taskResponseMapper.toResponse(result)); + } @PutMapping("/{taskId}") public ResponseEntity updateTask( @PathVariable Long taskId, @Valid @RequestBody UpdateTaskRequest request ) { - // TODO: Get authenticated user ID from SecurityContext - Long userId = 1L; + Long userId = SecurityContextUtil.getCurrentUserId(); UpdateTaskUseCase.UpdateTaskCommand command = new UpdateTaskUseCase.UpdateTaskCommand( taskId, @@ -102,8 +121,7 @@ public ResponseEntity updateTask( @DeleteMapping("/{taskId}") public ResponseEntity deleteTask(@PathVariable Long taskId) { - // TODO: Get authenticated user ID from SecurityContext - Long userId = 1L; + Long userId = SecurityContextUtil.getCurrentUserId(); DeleteTaskUseCase.DeleteTaskCommand command = new DeleteTaskUseCase.DeleteTaskCommand( taskId, @@ -115,8 +133,49 @@ public ResponseEntity deleteTask(@PathVariable Long taskId) { return ResponseEntity.noContent().build(); } - // TODO: GET /tasks/{taskId}/members - Task 참가자 목록 - // TODO: POST /tasks/{taskId}/members - Task 참가 - // TODO: DELETE /tasks/{taskId}/members/me - Task 참가 취소 - // TODO: GET /tasks/{taskId}/progress - Task 전체 진행률 + @GetMapping("/{taskId}/members") + public ResponseEntity listTaskMembers(@PathVariable Long taskId) { + ListTaskMembersUseCase.ListTaskMembersQuery query = + new ListTaskMembersUseCase.ListTaskMembersQuery(taskId); + + ListTaskMembersUseCase.TaskMemberListResult result = listTaskMembersUseCase.listTaskMembers(query); + + return ResponseEntity.ok(taskResponseMapper.toMemberListResponse(result)); + } + + @PostMapping("/{taskId}/members") + public ResponseEntity joinTask(@PathVariable Long taskId) { + Long userId = SecurityContextUtil.getCurrentUserId(); + + JoinTaskUseCase.JoinTaskCommand command = new JoinTaskUseCase.JoinTaskCommand( + taskId, + userId + ); + + JoinTaskUseCase.TaskMemberResult result = joinTaskUseCase.joinTask(command); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(taskResponseMapper.toMemberResponse(result)); + } + + @DeleteMapping("/{taskId}/members/me") + public ResponseEntity leaveTask(@PathVariable Long taskId) { + Long userId = SecurityContextUtil.getCurrentUserId(); + + LeaveTaskUseCase.LeaveTaskCommand command = new LeaveTaskUseCase.LeaveTaskCommand( + taskId, + userId + ); + + leaveTaskUseCase.leaveTask(command); + + return ResponseEntity.noContent().build(); + } + + @GetMapping("/{taskId}/progress") + public ResponseEntity getTaskProgress(@PathVariable Long taskId) { + GetTaskProgressUseCase.TaskProgressResult result = getTaskProgressUseCase.getTaskProgress(taskId); + + return ResponseEntity.ok(taskResponseMapper.toProgressResponse(result)); + } } diff --git a/infrastructure/src/main/java/com/btg/infrastructure/web/task/dto/request/UpdateTaskStatusRequest.java b/infrastructure/src/main/java/com/btg/infrastructure/web/task/dto/request/UpdateTaskStatusRequest.java new file mode 100644 index 0000000..ab06e9d --- /dev/null +++ b/infrastructure/src/main/java/com/btg/infrastructure/web/task/dto/request/UpdateTaskStatusRequest.java @@ -0,0 +1,8 @@ +package com.btg.infrastructure.web.task.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record UpdateTaskStatusRequest( + @NotBlank(message = "Status is required") + String status +) {} diff --git a/infrastructure/src/main/java/com/btg/infrastructure/web/task/dto/response/TaskMemberListResponse.java b/infrastructure/src/main/java/com/btg/infrastructure/web/task/dto/response/TaskMemberListResponse.java new file mode 100644 index 0000000..19f3cc1 --- /dev/null +++ b/infrastructure/src/main/java/com/btg/infrastructure/web/task/dto/response/TaskMemberListResponse.java @@ -0,0 +1,9 @@ +package com.btg.infrastructure.web.task.dto.response; + +import java.util.List; + +public record TaskMemberListResponse( + List members, + Integer totalCount, + Double averageCompletionRate +) {} diff --git a/infrastructure/src/main/java/com/btg/infrastructure/web/task/dto/response/TaskMemberResponse.java b/infrastructure/src/main/java/com/btg/infrastructure/web/task/dto/response/TaskMemberResponse.java new file mode 100644 index 0000000..7720abe --- /dev/null +++ b/infrastructure/src/main/java/com/btg/infrastructure/web/task/dto/response/TaskMemberResponse.java @@ -0,0 +1,10 @@ +package com.btg.infrastructure.web.task.dto.response; + +public record TaskMemberResponse( + Long id, + UserResponse user, + Double completionRate, + Integer completedDays, + Integer totalDays, + Long joinedAt +) {} diff --git a/infrastructure/src/main/java/com/btg/infrastructure/web/task/dto/response/TaskProgressResponse.java b/infrastructure/src/main/java/com/btg/infrastructure/web/task/dto/response/TaskProgressResponse.java new file mode 100644 index 0000000..14ca6e7 --- /dev/null +++ b/infrastructure/src/main/java/com/btg/infrastructure/web/task/dto/response/TaskProgressResponse.java @@ -0,0 +1,9 @@ +package com.btg.infrastructure.web.task.dto.response; + +public record TaskProgressResponse( + Long taskId, + Integer totalParticipants, + Integer totalDays, + Double overallCompletionRate, + Double averageCompletionRate +) {} From 04a561ea34153fa360ec9895e41fc96e576c57dd Mon Sep 17 00:00:00 2001 From: JBGeum Date: Thu, 8 Jan 2026 14:08:45 +0900 Subject: [PATCH 6/9] =?UTF-8?q?Feat(dailyprogress):=20DailyProgress=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20Port,=20Service,=20Entity=20=EB=B0=8F=20Pe?= =?UTF-8?q?rsistence=20Adapter=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DeleteDailyProgressPort.java | 8 ++ .../dailyprogress/LoadDailyProgressPort.java | 22 +++++ .../dailyprogress/SaveDailyProgressPort.java | 10 +++ .../GetDailyProgressService.java | 38 +++++++++ .../UpdateDailyProgressService.java | 29 +++++++ .../DailyProgressPersistenceAdapter.java | 81 +++++++++++++++++++ .../entity/DailyProgressJpaEntity.java | 51 ++++++++++++ .../DailyProgressJpaRepository.java | 31 +++++++ .../DailyProgressController.java | 7 +- 9 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 domain/src/main/java/com/btg/core/application/port/out/dailyprogress/DeleteDailyProgressPort.java create mode 100644 domain/src/main/java/com/btg/core/application/port/out/dailyprogress/LoadDailyProgressPort.java create mode 100644 domain/src/main/java/com/btg/core/application/port/out/dailyprogress/SaveDailyProgressPort.java create mode 100644 domain/src/main/java/com/btg/core/application/service/dailyprogress/GetDailyProgressService.java create mode 100644 domain/src/main/java/com/btg/core/application/service/dailyprogress/UpdateDailyProgressService.java create mode 100644 infrastructure/src/main/java/com/btg/infrastructure/persistence/dailyprogress/adapter/DailyProgressPersistenceAdapter.java create mode 100644 infrastructure/src/main/java/com/btg/infrastructure/persistence/dailyprogress/entity/DailyProgressJpaEntity.java create mode 100644 infrastructure/src/main/java/com/btg/infrastructure/persistence/dailyprogress/repository/DailyProgressJpaRepository.java diff --git a/domain/src/main/java/com/btg/core/application/port/out/dailyprogress/DeleteDailyProgressPort.java b/domain/src/main/java/com/btg/core/application/port/out/dailyprogress/DeleteDailyProgressPort.java new file mode 100644 index 0000000..42e5a02 --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/port/out/dailyprogress/DeleteDailyProgressPort.java @@ -0,0 +1,8 @@ +package com.btg.core.application.port.out.dailyprogress; + +public interface DeleteDailyProgressPort { + + void deleteByTaskMemberId(Long taskMemberId); + + void deleteAllByTaskId(Long taskId); +} \ No newline at end of file diff --git a/domain/src/main/java/com/btg/core/application/port/out/dailyprogress/LoadDailyProgressPort.java b/domain/src/main/java/com/btg/core/application/port/out/dailyprogress/LoadDailyProgressPort.java new file mode 100644 index 0000000..41d8559 --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/port/out/dailyprogress/LoadDailyProgressPort.java @@ -0,0 +1,22 @@ +package com.btg.core.application.port.out.dailyprogress; + +import java.util.List; + +public interface LoadDailyProgressPort { + + List loadByTaskMemberId(Long taskMemberId); + + int countCompletedByTaskMemberId(Long taskMemberId); + + int countCompletedByTaskId(Long taskId); + + int countTotalByTaskId(Long taskId); + + record DailyProgress( + Long id, + Long taskMemberId, + String date, + Boolean completed, + String completedAt + ) {} +} diff --git a/domain/src/main/java/com/btg/core/application/port/out/dailyprogress/SaveDailyProgressPort.java b/domain/src/main/java/com/btg/core/application/port/out/dailyprogress/SaveDailyProgressPort.java new file mode 100644 index 0000000..e6eb92d --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/port/out/dailyprogress/SaveDailyProgressPort.java @@ -0,0 +1,10 @@ +package com.btg.core.application.port.out.dailyprogress; + +import java.util.List; + +public interface SaveDailyProgressPort { + + void saveAll(Long taskMemberId, List entries); + + record DailyProgressEntry(String date, Boolean completed) {} +} diff --git a/domain/src/main/java/com/btg/core/application/service/dailyprogress/GetDailyProgressService.java b/domain/src/main/java/com/btg/core/application/service/dailyprogress/GetDailyProgressService.java new file mode 100644 index 0000000..59979f5 --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/service/dailyprogress/GetDailyProgressService.java @@ -0,0 +1,38 @@ +package com.btg.core.application.service.dailyprogress; + +import com.btg.core.application.port.in.dailyprogress.GetDailyProgressUseCase; +import org.springframework.stereotype.Service; + +/** + * GetDailyProgressUseCase 구현체 + * + * TODO: 실제 비즈니스 로직 구현 필요 + * - DailyProgress 조회 로직 + * - 통계 계산 로직 + * - Outbound Port 연결 (LoadDailyProgressPort 등) + */ +@Service +public class GetDailyProgressService implements GetDailyProgressUseCase { + + @Override + public DailyProgressSummaryResult getDailyProgressSummary(Long taskId) { + // TODO: Implement actual business logic + // 1. Validate taskId + // 2. Load task and verify it exists + // 3. Load all daily progress records for the task + // 4. Calculate statistics (completion rate, etc.) + // 5. Return summary result + throw new UnsupportedOperationException("getDailyProgressSummary not implemented yet"); + } + + @Override + public MyDailyProgressResult getMyDailyProgress(Long taskId, Long userId) { + // TODO: Implement actual business logic + // 1. Validate taskId and userId + // 2. Load task and verify it exists + // 3. Verify user is participating in the task + // 4. Load user's daily progress records + // 5. Return user's progress result + throw new UnsupportedOperationException("getMyDailyProgress not implemented yet"); + } +} diff --git a/domain/src/main/java/com/btg/core/application/service/dailyprogress/UpdateDailyProgressService.java b/domain/src/main/java/com/btg/core/application/service/dailyprogress/UpdateDailyProgressService.java new file mode 100644 index 0000000..917fa1c --- /dev/null +++ b/domain/src/main/java/com/btg/core/application/service/dailyprogress/UpdateDailyProgressService.java @@ -0,0 +1,29 @@ +package com.btg.core.application.service.dailyprogress; + +import com.btg.core.application.port.in.dailyprogress.UpdateDailyProgressUseCase; +import org.springframework.stereotype.Service; + +/** + * UpdateDailyProgressUseCase 구현체 + * + * TODO: 실제 비즈니스 로직 구현 필요 + * - DailyProgress 업데이트 로직 + * - 날짜 유효성 검증 + * - Outbound Port 연결 (SaveDailyProgressPort 등) + */ +@Service +public class UpdateDailyProgressService implements UpdateDailyProgressUseCase { + + @Override + public DailyProgressResult updateDailyProgress(UpdateDailyProgressCommand command) { + // TODO: Implement actual business logic + // 1. Validate command (already done in record constructor) + // 2. Verify task exists and status is not RECRUITING + // 3. Verify user is participating in the task + // 4. Verify date is within task date range + // 5. Update or create daily progress record + // 6. Auto-set completedAt when completed=true + // 7. Return updated result + throw new UnsupportedOperationException("updateDailyProgress not implemented yet"); + } +} diff --git a/infrastructure/src/main/java/com/btg/infrastructure/persistence/dailyprogress/adapter/DailyProgressPersistenceAdapter.java b/infrastructure/src/main/java/com/btg/infrastructure/persistence/dailyprogress/adapter/DailyProgressPersistenceAdapter.java new file mode 100644 index 0000000..f914612 --- /dev/null +++ b/infrastructure/src/main/java/com/btg/infrastructure/persistence/dailyprogress/adapter/DailyProgressPersistenceAdapter.java @@ -0,0 +1,81 @@ +package com.btg.infrastructure.persistence.dailyprogress.adapter; + +import com.btg.core.application.port.out.dailyprogress.DeleteDailyProgressPort; +import com.btg.core.application.port.out.dailyprogress.LoadDailyProgressPort; +import com.btg.core.application.port.out.dailyprogress.SaveDailyProgressPort; +import com.btg.infrastructure.persistence.dailyprogress.entity.DailyProgressJpaEntity; +import com.btg.infrastructure.persistence.dailyprogress.repository.DailyProgressJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.List; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class DailyProgressPersistenceAdapter implements SaveDailyProgressPort, LoadDailyProgressPort, DeleteDailyProgressPort { + + private final DailyProgressJpaRepository dailyProgressRepository; + + @Override + public void saveAll(Long taskMemberId, List entries) { + List entities = entries.stream() + .map(entry -> new DailyProgressJpaEntity( + taskMemberId, + LocalDate.parse(entry.date()), + entry.completed() + )) + .collect(Collectors.toList()); + + dailyProgressRepository.saveAll(entities); + } + + @Override + public List loadByTaskMemberId(Long taskMemberId) { + return dailyProgressRepository.findByTaskMemberId(taskMemberId).stream() + .map(this::toDailyProgress) + .collect(Collectors.toList()); + } + + @Override + public int countCompletedByTaskMemberId(Long taskMemberId) { + return dailyProgressRepository.countByTaskMemberIdAndCompletedTrue(taskMemberId); + } + + @Override + public int countCompletedByTaskId(Long taskId) { + return dailyProgressRepository.countCompletedByTaskId(taskId); + } + + @Override + public int countTotalByTaskId(Long taskId) { + return dailyProgressRepository.countTotalByTaskId(taskId); + } + + @Override + @Transactional + public void deleteByTaskMemberId(Long taskMemberId) { + dailyProgressRepository.deleteByTaskMemberId(taskMemberId); + } + + @Override + @Transactional + public void deleteAllByTaskId(Long taskId) { + dailyProgressRepository.deleteAllByTaskId(taskId); + } + + private DailyProgress toDailyProgress(DailyProgressJpaEntity entity) { + return new DailyProgress( + entity.getId(), + entity.getTaskMemberId(), + entity.getDate().toString(), + entity.getCompleted(), + entity.getCompletedAt() != null + ? entity.getCompletedAt().toInstant(ZoneOffset.UTC).toString() + : null + ); + } +} diff --git a/infrastructure/src/main/java/com/btg/infrastructure/persistence/dailyprogress/entity/DailyProgressJpaEntity.java b/infrastructure/src/main/java/com/btg/infrastructure/persistence/dailyprogress/entity/DailyProgressJpaEntity.java new file mode 100644 index 0000000..7157000 --- /dev/null +++ b/infrastructure/src/main/java/com/btg/infrastructure/persistence/dailyprogress/entity/DailyProgressJpaEntity.java @@ -0,0 +1,51 @@ +package com.btg.infrastructure.persistence.dailyprogress.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table( + name = "daily_progress", + uniqueConstraints = @UniqueConstraint(columnNames = {"task_member_id", "date"}) +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DailyProgressJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "task_member_id", nullable = false) + private Long taskMemberId; + + @Column(nullable = false) + private LocalDate date; + + @Column(nullable = false) + private Boolean completed; + + @Column + private LocalDateTime completedAt; + + public DailyProgressJpaEntity(Long taskMemberId, LocalDate date, Boolean completed) { + this.taskMemberId = taskMemberId; + this.date = date; + this.completed = completed; + } + + public void markCompleted() { + this.completed = true; + this.completedAt = LocalDateTime.now(); + } + + public void markIncomplete() { + this.completed = false; + this.completedAt = null; + } +} diff --git a/infrastructure/src/main/java/com/btg/infrastructure/persistence/dailyprogress/repository/DailyProgressJpaRepository.java b/infrastructure/src/main/java/com/btg/infrastructure/persistence/dailyprogress/repository/DailyProgressJpaRepository.java new file mode 100644 index 0000000..325da9f --- /dev/null +++ b/infrastructure/src/main/java/com/btg/infrastructure/persistence/dailyprogress/repository/DailyProgressJpaRepository.java @@ -0,0 +1,31 @@ +package com.btg.infrastructure.persistence.dailyprogress.repository; + +import com.btg.infrastructure.persistence.dailyprogress.entity.DailyProgressJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface DailyProgressJpaRepository extends JpaRepository { + + List findByTaskMemberId(Long taskMemberId); + + int countByTaskMemberIdAndCompletedTrue(Long taskMemberId); + + @Query("SELECT COUNT(dp) FROM DailyProgressJpaEntity dp " + + "WHERE dp.taskMemberId IN (SELECT tm.id FROM TaskMemberJpaEntity tm WHERE tm.taskId = :taskId) " + + "AND dp.completed = true") + int countCompletedByTaskId(@Param("taskId") Long taskId); + + @Query("SELECT COUNT(dp) FROM DailyProgressJpaEntity dp " + + "WHERE dp.taskMemberId IN (SELECT tm.id FROM TaskMemberJpaEntity tm WHERE tm.taskId = :taskId)") + int countTotalByTaskId(@Param("taskId") Long taskId); + + void deleteByTaskMemberId(Long taskMemberId); + + @Query("DELETE FROM DailyProgressJpaEntity dp " + + "WHERE dp.taskMemberId IN (SELECT tm.id FROM TaskMemberJpaEntity tm WHERE tm.taskId = :taskId)") + @org.springframework.data.jpa.repository.Modifying + void deleteAllByTaskId(@Param("taskId") Long taskId); +} diff --git a/infrastructure/src/main/java/com/btg/infrastructure/web/dailyprogress/DailyProgressController.java b/infrastructure/src/main/java/com/btg/infrastructure/web/dailyprogress/DailyProgressController.java index 954a6db..15bda0f 100644 --- a/infrastructure/src/main/java/com/btg/infrastructure/web/dailyprogress/DailyProgressController.java +++ b/infrastructure/src/main/java/com/btg/infrastructure/web/dailyprogress/DailyProgressController.java @@ -2,6 +2,7 @@ import com.btg.core.application.port.in.dailyprogress.GetDailyProgressUseCase; import com.btg.core.application.port.in.dailyprogress.UpdateDailyProgressUseCase; +import com.btg.infrastructure.security.SecurityContextUtil; import com.btg.infrastructure.web.dailyprogress.dto.request.UpdateDailyProgressRequest; import com.btg.infrastructure.web.dailyprogress.dto.response.DailyProgressResponse; import com.btg.infrastructure.web.dailyprogress.dto.response.DailyProgressSummaryResponse; @@ -31,8 +32,7 @@ public ResponseEntity getDailyProgressSummary(@Pat @GetMapping("/me") public ResponseEntity getMyDailyProgress(@PathVariable Long taskId) { - // TODO: Get authenticated user ID from SecurityContext - Long userId = 1L; + Long userId = SecurityContextUtil.getCurrentUserId(); GetDailyProgressUseCase.MyDailyProgressResult result = getDailyProgressUseCase.getMyDailyProgress(taskId, userId); @@ -46,8 +46,7 @@ public ResponseEntity updateDailyProgress( @PathVariable String date, @Valid @RequestBody UpdateDailyProgressRequest request ) { - // TODO: Get authenticated user ID from SecurityContext - Long userId = 1L; + Long userId = SecurityContextUtil.getCurrentUserId(); UpdateDailyProgressUseCase.UpdateDailyProgressCommand command = new UpdateDailyProgressUseCase.UpdateDailyProgressCommand( From a207a405e989f6656983bfc37f4b41b20020fb9b Mon Sep 17 00:00:00 2001 From: JBGeum Date: Thu, 8 Jan 2026 14:13:25 +0900 Subject: [PATCH 7/9] =?UTF-8?q?Feat:=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/web/group/GroupController.java | 13 +++++-------- .../web/group/GroupMemberController.java | 7 +++---- .../btg/infrastructure/web/user/UserController.java | 7 +++---- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/infrastructure/src/main/java/com/btg/infrastructure/web/group/GroupController.java b/infrastructure/src/main/java/com/btg/infrastructure/web/group/GroupController.java index 05fb399..a5dca74 100644 --- a/infrastructure/src/main/java/com/btg/infrastructure/web/group/GroupController.java +++ b/infrastructure/src/main/java/com/btg/infrastructure/web/group/GroupController.java @@ -1,6 +1,7 @@ package com.btg.infrastructure.web.group; import com.btg.core.application.port.in.group.*; +import com.btg.infrastructure.security.SecurityContextUtil; import com.btg.infrastructure.web.group.dto.request.CreateGroupRequest; import com.btg.infrastructure.web.group.dto.request.UpdateGroupRequest; import com.btg.infrastructure.web.group.dto.response.*; @@ -27,8 +28,7 @@ public class GroupController { @PostMapping public ResponseEntity createGroup(@Valid @RequestBody CreateGroupRequest request) { - // TODO: Get authenticated user ID from SecurityContext - Long userId = 1L; + Long userId = SecurityContextUtil.getCurrentUserId(); CreateGroupUseCase.CreateGroupCommand command = new CreateGroupUseCase.CreateGroupCommand( userId, @@ -66,8 +66,7 @@ public ResponseEntity listGroups( @RequestParam(defaultValue = "0") Integer page, @RequestParam(defaultValue = "20") Integer size ) { - // TODO: Get authenticated user ID from SecurityContext - Long userId = 1L; + Long userId = SecurityContextUtil.getCurrentUserId(); ListGroupsUseCase.ListGroupsQuery query = new ListGroupsUseCase.ListGroupsQuery( userId, @@ -93,8 +92,7 @@ public ResponseEntity updateGroup( @PathVariable Long groupId, @Valid @RequestBody UpdateGroupRequest request ) { - // TODO: Get authenticated user ID from SecurityContext - Long userId = 1L; + Long userId = SecurityContextUtil.getCurrentUserId(); UpdateGroupUseCase.UpdateGroupCommand command = new UpdateGroupUseCase.UpdateGroupCommand( groupId, @@ -111,8 +109,7 @@ public ResponseEntity updateGroup( @DeleteMapping("/{groupId}") public ResponseEntity deleteGroup(@PathVariable Long groupId) { - // TODO: Get authenticated user ID from SecurityContext - Long userId = 1L; + Long userId = SecurityContextUtil.getCurrentUserId(); DeleteGroupUseCase.DeleteGroupCommand command = new DeleteGroupUseCase.DeleteGroupCommand( groupId, diff --git a/infrastructure/src/main/java/com/btg/infrastructure/web/group/GroupMemberController.java b/infrastructure/src/main/java/com/btg/infrastructure/web/group/GroupMemberController.java index a21976d..2a820a7 100644 --- a/infrastructure/src/main/java/com/btg/infrastructure/web/group/GroupMemberController.java +++ b/infrastructure/src/main/java/com/btg/infrastructure/web/group/GroupMemberController.java @@ -3,6 +3,7 @@ import com.btg.core.application.port.in.group.JoinGroupUseCase; import com.btg.core.application.port.in.group.LeaveGroupUseCase; import com.btg.core.application.port.in.group.ListGroupMembersUseCase; +import com.btg.infrastructure.security.SecurityContextUtil; import com.btg.infrastructure.web.group.dto.response.GroupMemberListResponse; import com.btg.infrastructure.web.group.dto.response.GroupMemberResponse; import com.btg.infrastructure.web.group.dto.response.UserResponse; @@ -35,8 +36,7 @@ public ResponseEntity listGroupMembers(@PathVariable Lo @PostMapping public ResponseEntity joinGroup(@PathVariable Long groupId) { - // TODO: Get authenticated user ID from SecurityContext - Long userId = 1L; + Long userId = SecurityContextUtil.getCurrentUserId(); JoinGroupUseCase.JoinGroupCommand command = new JoinGroupUseCase.JoinGroupCommand( groupId, @@ -61,8 +61,7 @@ public ResponseEntity joinGroup(@PathVariable Long groupId) @DeleteMapping("/me") public ResponseEntity leaveGroup(@PathVariable Long groupId) { - // TODO: Get authenticated user ID from SecurityContext - Long userId = 1L; + Long userId = SecurityContextUtil.getCurrentUserId(); LeaveGroupUseCase.LeaveGroupCommand command = new LeaveGroupUseCase.LeaveGroupCommand(groupId, userId); diff --git a/infrastructure/src/main/java/com/btg/infrastructure/web/user/UserController.java b/infrastructure/src/main/java/com/btg/infrastructure/web/user/UserController.java index 6b8e2f0..d9b721a 100644 --- a/infrastructure/src/main/java/com/btg/infrastructure/web/user/UserController.java +++ b/infrastructure/src/main/java/com/btg/infrastructure/web/user/UserController.java @@ -2,6 +2,7 @@ import com.btg.core.application.port.in.user.GetUserProfileUseCase; import com.btg.core.application.port.in.user.UpdateUserProfileUseCase; +import com.btg.infrastructure.security.SecurityContextUtil; import com.btg.infrastructure.web.mapper.UserResponseMapper; import com.btg.infrastructure.web.user.dto.request.UpdateUserRequest; import com.btg.infrastructure.web.user.dto.response.UserResponse; @@ -21,8 +22,7 @@ public class UserController { @GetMapping("/me") public ResponseEntity getMyProfile() { - // TODO: userId 받아오기 - Long userId = 1L; + Long userId = SecurityContextUtil.getCurrentUserId(); GetUserProfileUseCase.UserProfileResult result = getUserProfileUseCase.getUserProfile(userId); @@ -31,8 +31,7 @@ public ResponseEntity getMyProfile() { @PutMapping("/me") public ResponseEntity updateMyProfile(@Valid @RequestBody UpdateUserRequest request) { - // TODO: userId 받아오기 - Long userId = 1L; + Long userId = SecurityContextUtil.getCurrentUserId(); UpdateUserProfileUseCase.UpdateUserProfileCommand command = new UpdateUserProfileUseCase.UpdateUserProfileCommand( userId, From fb09b645000c5911664ce1cc3f8ff9b4c4a7c224 Mon Sep 17 00:00:00 2001 From: JBGeum Date: Thu, 8 Jan 2026 14:16:44 +0900 Subject: [PATCH 8/9] =?UTF-8?q?Feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=8B=9C=20DB=20=EC=9E=84=EC=8B=9C=EB=A1=9C=20h2=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle | 5 +---- .../btg/infrastructure/web/GlobalExceptionHandler.java | 10 ++++++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index dcc79ef..f7b4dfe 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -7,11 +7,8 @@ dependencies { implementation project(':infrastructure') implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - - - testImplementation 'org.springframework:spring-tx' testImplementation 'org.springframework.boot:spring-boot-starter-security' - testRuntimeOnly 'com.h2database:h2' + runtimeOnly 'com.h2database:h2' runtimeOnly 'org.postgresql:postgresql' diff --git a/infrastructure/src/main/java/com/btg/infrastructure/web/GlobalExceptionHandler.java b/infrastructure/src/main/java/com/btg/infrastructure/web/GlobalExceptionHandler.java index b35a55e..6e917ee 100644 --- a/infrastructure/src/main/java/com/btg/infrastructure/web/GlobalExceptionHandler.java +++ b/infrastructure/src/main/java/com/btg/infrastructure/web/GlobalExceptionHandler.java @@ -24,6 +24,16 @@ public ResponseEntity handleIllegalArgumentException(IllegalArgum return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity handleIllegalStateException(IllegalStateException ex) { + ErrorResponse errorResponse = new ErrorResponse( + HttpStatus.UNAUTHORIZED.value(), + ex.getMessage(), + LocalDateTime.now() + ); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse); + } + @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleMethodArgumentNotValidException( MethodArgumentNotValidException ex) { From 78ff2ba0643c5bddb7841c1b2aeb0a0d77385d55 Mon Sep 17 00:00:00 2001 From: JBGeum Date: Thu, 8 Jan 2026 16:54:41 +0900 Subject: [PATCH 9/9] =?UTF-8?q?Feat:=20=EB=B9=84=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=84=A4=EC=A0=95=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20AsyncConfig=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/config/AsyncConfig.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 infrastructure/src/main/java/com/btg/infrastructure/config/AsyncConfig.java diff --git a/infrastructure/src/main/java/com/btg/infrastructure/config/AsyncConfig.java b/infrastructure/src/main/java/com/btg/infrastructure/config/AsyncConfig.java new file mode 100644 index 0000000..bb7aff5 --- /dev/null +++ b/infrastructure/src/main/java/com/btg/infrastructure/config/AsyncConfig.java @@ -0,0 +1,26 @@ +package com.btg.infrastructure.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean(name = "taskDetailExecutor") + public Executor taskDetailExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(6); // 기본 스레드 수 + executor.setMaxPoolSize(12); // 최대 스레드 수 + executor.setQueueCapacity(50); // 대기 큐 크기 + executor.setThreadNamePrefix("task-detail-"); //로그 추적 용이성 + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(30); + executor.initialize(); + return executor; + } +} \ No newline at end of file