dtos = tasks.stream()
+ .map(t -> MyTasksTodayResponseDto.builder()
+ .id(t.getId())
+ .title(t.getTitle())
+ .contents(t.getDescription())
+ .priority(t.getPriority())
+ .managerId(t.getAssignee().getId())
+ .userId(t.getUser().getId())
+ .deadline(t.getDueDate())
+ .status(t.getStatus())
+ .started_at(t.getStartedAt())
+ .created_at(t.getCreatedAt())
+ .updated_at(t.getUpdatedAt())
+ .build()
+ )
+ .toList();
+
+ return dtos;
+ }
+}
diff --git a/src/main/java/com/example/onederful/domain/log/HttpRequestUtil.java b/src/main/java/com/example/onederful/domain/log/HttpRequestUtil.java
new file mode 100644
index 0000000..8d6ce5c
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/log/HttpRequestUtil.java
@@ -0,0 +1,63 @@
+package com.example.onederful.domain.log;
+
+import org.springframework.stereotype.Component;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import com.example.onederful.domain.log.enums.Method;
+import com.example.onederful.exception.CustomException;
+import com.example.onederful.exception.ErrorCode;
+import com.example.onederful.security.JwtUtil;
+
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Component
+@RequiredArgsConstructor
+public class HttpRequestUtil {
+
+ private final JwtUtil jwtUtil;
+
+ // HttpServletRequest으로부터 요청 ip, 메서드, url, 로그인한 userId
+ public RequestInfo getRequestInfo() {
+ ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+ if (attrs == null) {
+ throw new CustomException(ErrorCode.INVALID_OR_EXPIRED_REQUEST);
+ }
+ HttpServletRequest request = attrs.getRequest();
+ if (request == null) {
+ throw new CustomException(ErrorCode.INVALID_OR_EXPIRED_REQUEST);
+ }
+
+ // 요청한 사용자의 ip
+ String ip = request.getRemoteAddr();
+
+ // 요청 메서드
+ String method = request.getMethod();
+ Method enumMethod = Method.valueOf(method);
+
+ // 요청 url
+ String url = request.getRequestURI();
+
+ // 토큰으로부터 요청한 사용자의 userId
+ Long userId = null;
+ // 로그인, 회원가입 등 토큰 체크 안 할 URL 처리
+ if (!url.startsWith("/api/auth/login") && !url.startsWith("/api/auth/register")) {
+ userId = jwtUtil.extractId(request);
+ }
+
+ return new RequestInfo(ip, enumMethod, url, userId);
+ }
+
+ // 반환용 클래스
+ @Getter
+ @AllArgsConstructor
+ public static class RequestInfo {
+ private final String ip;
+ private final Method method;
+ private final String url;
+ private final Long userId;
+ }
+}
diff --git a/src/main/java/com/example/onederful/domain/log/LoggingAspect.java b/src/main/java/com/example/onederful/domain/log/LoggingAspect.java
new file mode 100644
index 0000000..ff92320
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/log/LoggingAspect.java
@@ -0,0 +1,106 @@
+package com.example.onederful.domain.log;
+
+import com.example.onederful.domain.log.enums.Activity;
+import com.example.onederful.domain.log.service.LogService;
+import com.example.onederful.domain.task.entity.Task;
+import com.example.onederful.domain.task.enums.ProcessStatus;
+import com.example.onederful.domain.task.service.TaskService;
+import java.util.Objects;
+import lombok.RequiredArgsConstructor;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.AfterReturning;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Pointcut;
+import org.springframework.stereotype.Component;
+
+@Aspect
+@Component
+@RequiredArgsConstructor
+public class LoggingAspect {
+
+ private final HttpRequestUtil httpRequestUtil;
+ private final TaskService taskService;
+ private final LogService logService;
+
+ @Pointcut("execution(* com.example..UserService.login(..))")
+ public void loginMethod() {
+ }
+
+ @Pointcut(
+ "execution(* com.example..TaskService.createTask(..)) || " +
+ "execution(* com.example..TaskService.updateTask(..)) || " +
+ "execution(* com.example..TaskService.deleteTask(..)) || " +
+ "execution(* com.example..CommentService.save(..)) || " +
+ "execution(* com.example..CommentService.updateComment(..)) || " +
+ "execution(* com.example..CommentService.deleteComment(..))"
+ )
+ public void cudMethods() {
+ }
+
+ @Pointcut("execution(* com.example..TaskService.updateTaskStatus(..))")
+ public void updateTaskStatusMethod() {
+ }
+
+ // 로그인 시 자동 로그 기록
+ @AfterReturning(pointcut = "loginMethod()", returning = "result")
+ public void logLoginMethod(Object result) {
+
+ // HttpServletRequest으로부터 요청 ip, 메서드, url
+ HttpRequestUtil.RequestInfo request = httpRequestUtil.getRequestInfo();
+
+ // 로그 저장
+ logService.saveLoginLog(request.getIp(), request.getMethod(), request.getUrl(), result);
+ }
+
+ // 생성, 수정, 삭제 시 자동 로그 기록
+ @AfterReturning(pointcut = "cudMethods()", returning = "result")
+ public void logCudMethods(Object result) {
+
+ // HttpServletRequest으로부터 요청 ip, 메서드, url, 로그인한 userid
+ HttpRequestUtil.RequestInfo request = httpRequestUtil.getRequestInfo();
+
+ // 로그 저장
+ logService.saveCudLog(request.getIp(), request.getMethod(), request.getUrl(),
+ request.getUserId(), result);
+ }
+
+ // 상태 변경 시 자동 로그 기록
+ @Around("updateTaskStatusMethod()")
+ public Object logTaskStatusChange(ProceedingJoinPoint joinPoint) throws Throwable {
+ Object[] args = joinPoint.getArgs();
+ Long taskId = (Long) args[0]; // 첫 번째 인자가 taskId
+
+ // 기존 task 상태 조회
+ Task beforeTask = taskService.findById(taskId); // 서비스 계층 사용
+ ProcessStatus beforeStatus = beforeTask != null ? beforeTask.getStatus() : null;
+
+ // 메서드 실행
+ Object result = joinPoint.proceed();
+
+ // 변경 후 task 상태 조회
+ Task afterTask = taskService.findById(taskId);
+ ProcessStatus afterStatus = afterTask != null ? afterTask.getStatus() : null;
+
+ // 변경되었는지 비교 후 로그 기록
+ Activity activity = null;
+ if (Objects.equals(beforeStatus, ProcessStatus.TODO) && Objects.equals(afterStatus,
+ ProcessStatus.IN_PROGRESS)) {
+ activity = Activity.TASK_STATUS_TODO_TO_IN_PROGRESS;
+ } else if (Objects.equals(beforeStatus, ProcessStatus.IN_PROGRESS) && Objects.equals(
+ afterStatus, ProcessStatus.DONE)) {
+ activity = Activity.TASK_STATUS_IN_PROGRESS_TO_DONE;
+ }
+
+ if (activity != null) {
+ // HttpServletRequest으로부터 요청 ip, 메서드, url, 로그인한 userid
+ HttpRequestUtil.RequestInfo request = httpRequestUtil.getRequestInfo();
+
+ // 로그 저장
+ logService.saveTaskStatusChangeLog(request.getIp(), request.getMethod(),
+ request.getUrl(), request.getUserId(), taskId, activity);
+ }
+
+ return result;
+ }
+}
diff --git a/src/main/java/com/example/onederful/domain/log/controller/LogController.java b/src/main/java/com/example/onederful/domain/log/controller/LogController.java
new file mode 100644
index 0000000..4ff49a3
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/log/controller/LogController.java
@@ -0,0 +1,52 @@
+package com.example.onederful.domain.log.controller;
+
+import com.example.onederful.common.ApiResponseDto;
+import com.example.onederful.common.ListResponse;
+import com.example.onederful.domain.log.dto.LogResponse;
+import com.example.onederful.domain.log.service.LogService;
+import java.time.LocalDate;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.web.PageableDefault;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequiredArgsConstructor
+public class LogController {
+
+ private final LogService logService;
+
+ /**
+ * 활동 로그 조회
+ *
+ * 검색 조건:
+ *
+ * @param userId 유저 아이디 (필수 N)
+ * @param activity 활동 유형 (필수 N)
+ * @param targetId 대상 ID (필수 N)
+ * @param start 시작일 (필수 N)
+ * @param end 종료일 (필수 N)
+ * @param pageable 페이징을 위한 page, size, sort (필수 N)
+ * @return 조회된 활동 로그
+ */
+ @GetMapping("/api/activities")
+ public ResponseEntity getLog(
+ @RequestParam(required = false) Long userId,
+ @RequestParam(required = false) String activity,
+ @RequestParam(required = false) Long targetId,
+ @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate start,
+ @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end,
+ @PageableDefault(page = 0, size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable
+ ) {
+
+ ListResponse response = logService.findLog(userId, activity, targetId, start,
+ end, pageable);
+
+ return ResponseEntity.ok(ApiResponseDto.success("활동 로그 리스트 조회에 성공하였습니다.", response));
+ }
+}
diff --git a/src/main/java/com/example/onederful/domain/log/dto/LogResponse.java b/src/main/java/com/example/onederful/domain/log/dto/LogResponse.java
new file mode 100644
index 0000000..10ec211
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/log/dto/LogResponse.java
@@ -0,0 +1,39 @@
+package com.example.onederful.domain.log.dto;
+
+import java.time.LocalDateTime;
+
+import com.example.onederful.domain.log.entity.Log;
+
+import lombok.Getter;
+
+@Getter
+public class LogResponse {
+ LocalDateTime createdAt;
+ String userName;
+ String activityStr;
+ Long targetID;
+ String logMessage;
+
+ public LogResponse(
+ LocalDateTime createdAt,
+ String userName,
+ String activityStr,
+ Long targetID,
+ String logMessage)
+ {
+ this.createdAt = createdAt;
+ this.userName = userName;
+ this.activityStr = activityStr;
+ this.targetID = targetID;
+ this.logMessage = logMessage;
+ }
+
+ public static LogResponse of(Log log) {
+ return new LogResponse(
+ log.getCreatedAt(),
+ log.getUser().getName(),
+ log.getActivity().toString(),
+ log.getTargetId(),
+ log.getActivity().getLogMessage());
+ }
+}
diff --git a/src/main/java/com/example/onederful/domain/log/entity/Log.java b/src/main/java/com/example/onederful/domain/log/entity/Log.java
new file mode 100644
index 0000000..f01f6e1
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/log/entity/Log.java
@@ -0,0 +1,64 @@
+package com.example.onederful.domain.log.entity;
+
+import com.example.onederful.domain.log.enums.Activity;
+import com.example.onederful.domain.log.enums.Method;
+import com.example.onederful.domain.user.entity.User;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import java.time.LocalDateTime;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+@Entity
+@Builder
+@Getter
+@Table(name = "logs")
+@AllArgsConstructor
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@EntityListeners(AuditingEntityListener.class)
+public class Log {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name="user_id", nullable = false)
+ private User user;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name="activity", nullable = false)
+ private Activity activity;
+
+ @Column(name="ip_address", nullable=false)
+ private String ipAddress;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "method", nullable = false)
+ private Method method;
+
+ @Column(name = "target_id", nullable = false)
+ private Long targetId;
+
+ @Column(name="request_url", nullable = false)
+ private String requestUrl;
+
+ @CreatedDate
+ @Column(name = "created_at", updatable = false)
+ private LocalDateTime createdAt;
+}
diff --git a/src/main/java/com/example/onederful/domain/log/enums/Activity.java b/src/main/java/com/example/onederful/domain/log/enums/Activity.java
new file mode 100644
index 0000000..e6d0ad6
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/log/enums/Activity.java
@@ -0,0 +1,26 @@
+package com.example.onederful.domain.log.enums;
+
+public enum Activity {
+ TASK_CREATED("새로운 작업이 생성되었습니다."),
+ TASK_UPDATED("작업이 수정되었습니다."),
+ TASK_DELETED("작업이 삭제되었습니다."),
+ TASK_STATUS_TODO_TO_IN_PROGRESS("작업이 TODO에서 IN_PROGRESS로 변경되었습니다."),
+ TASK_STATUS_IN_PROGRESS_TO_DONE("작업이 IN_PROGRESS에서 DONE으로 변경되었습니다."),
+ COMMENT_CREATED("새로운 댓글이 생성되었습니다."),
+ COMMENT_UPDATED("댓글이 수정되었습니다."),
+ COMMENT_DELETED("댓글이 삭제되었습니다."),
+ USER_LOGGED_IN("로그인 하였습니다.");
+ // USER_LOGGED_OUT("로그아웃 하였습니다.")
+
+ private final String logMessage;
+
+ // 생성자
+ Activity(String logMessage) {
+ this.logMessage = logMessage;
+ }
+
+ // getter
+ public String getLogMessage() {
+ return logMessage;
+ }
+}
diff --git a/src/main/java/com/example/onederful/domain/log/enums/Method.java b/src/main/java/com/example/onederful/domain/log/enums/Method.java
new file mode 100644
index 0000000..98a00ca
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/log/enums/Method.java
@@ -0,0 +1,5 @@
+package com.example.onederful.domain.log.enums;
+
+public enum Method {
+ POST, GET, PATCH, PUT, DELETE
+}
diff --git a/src/main/java/com/example/onederful/domain/log/repository/LogRepository.java b/src/main/java/com/example/onederful/domain/log/repository/LogRepository.java
new file mode 100644
index 0000000..c124625
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/log/repository/LogRepository.java
@@ -0,0 +1,11 @@
+package com.example.onederful.domain.log.repository;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
+import org.springframework.stereotype.Repository;
+
+import com.example.onederful.domain.log.entity.Log;
+
+@Repository
+public interface LogRepository extends JpaRepository, JpaSpecificationExecutor {
+}
diff --git a/src/main/java/com/example/onederful/domain/log/repository/LogSpecification.java b/src/main/java/com/example/onederful/domain/log/repository/LogSpecification.java
new file mode 100644
index 0000000..a0c035c
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/log/repository/LogSpecification.java
@@ -0,0 +1,46 @@
+package com.example.onederful.domain.log.repository;
+
+import java.time.LocalDate;
+
+import org.springframework.data.jpa.domain.Specification;
+
+import com.example.onederful.domain.log.entity.Log;
+import com.example.onederful.domain.log.enums.Activity;
+
+public class LogSpecification {
+ // userId를 통한 검색 조건 (WHERE userId = ?)
+ public static Specification hasUserId(Long userId) {
+ return (root, query, builder) ->
+ userId == null ? null : builder.equal(root.get("user").get("id"), userId);
+ }
+
+ // activity를 통한 검색 조건 (WHERE activity = ?)
+ public static Specification hasActivity(Activity activity) {
+ return (root, query, builder) ->
+ activity == null ? null : builder.equal(root.get("activity"), activity);
+ }
+
+ // targetId를 통한 검색 조건 (WHERE targetId = ?)
+ public static Specification hasTargetId(Long targetId) {
+ return (root, query, builder) ->
+ targetId == null ? null : builder.equal(root.get("targetId"), targetId);
+ }
+
+ // 날짜를 통한 검색 조건 (WHERE ? BETWEEN start and end)
+ public static Specification betweenDates(LocalDate start, LocalDate end) {
+ return (root, query, builder) -> {
+ // 둘다 없을 경우
+ if (start == null && end == null) return null;
+ // 둘다 있을 경우 -> between
+ // start.atStartOfDay() = 00-01-01(시작날) 00:00:00
+ // end.plusDays(1).atStartOfDay() = (00-01-02(종료날)일 경우) 00-01-03 00:00:00
+ if (start != null && end != null)
+ return builder.between(root.get("createdAt"), start.atStartOfDay(), end.plusDays(1).atStartOfDay());
+ // 시작날만 있을 경우
+ if (start != null)
+ return builder.greaterThanOrEqualTo(root.get("createdAt"), start.atStartOfDay());
+ // 종료날만 있을 경우
+ return builder.lessThan(root.get("createdAt"), end.plusDays(1).atStartOfDay());
+ };
+ }
+}
diff --git a/src/main/java/com/example/onederful/domain/log/service/LogService.java b/src/main/java/com/example/onederful/domain/log/service/LogService.java
new file mode 100644
index 0000000..df2ce0b
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/log/service/LogService.java
@@ -0,0 +1,175 @@
+package com.example.onederful.domain.log.service;
+
+import com.example.onederful.common.ListResponse;
+import com.example.onederful.domain.comment.dto.CommentResponseDataDto;
+import com.example.onederful.domain.log.dto.LogResponse;
+import com.example.onederful.domain.log.entity.Log;
+import com.example.onederful.domain.log.enums.Activity;
+import com.example.onederful.domain.log.enums.Method;
+import com.example.onederful.domain.log.repository.LogRepository;
+import com.example.onederful.domain.log.repository.LogSpecification;
+import com.example.onederful.domain.task.dto.response.TaskResponse;
+import com.example.onederful.domain.user.dto.Tokeninfo;
+import com.example.onederful.domain.user.entity.User;
+import com.example.onederful.domain.user.repository.UserRepository;
+import com.example.onederful.exception.CustomException;
+import com.example.onederful.exception.ErrorCode;
+import com.example.onederful.security.JwtUtil;
+import jakarta.transaction.Transactional;
+import java.time.LocalDate;
+import java.util.stream.Collectors;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.domain.Specification;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class LogService {
+
+ private final LogRepository logRepository;
+ private final UserRepository userRepositry;
+ private final JwtUtil jwtUtil;
+
+ // log 조회 메서드
+ public ListResponse findLog(
+ Long userId, String activityStr, Long targetId,
+ LocalDate start, LocalDate end, Pageable pageable) {
+
+ // 활동 유형 Enum 형태로 변환
+ Activity activity = null;
+ try {
+ if (activityStr != null) {
+ activity = Activity.valueOf(activityStr);
+ }
+ } catch (IllegalArgumentException e) {
+ throw new CustomException(ErrorCode.INVALID_ACTIVITY);
+ }
+
+ // 들어온 조건 여부로 동적 쿼리 설정
+ Specification spec =
+ LogSpecification.hasUserId(userId)
+ .and(LogSpecification.hasActivity(activity))
+ .and(LogSpecification.hasTargetId(targetId))
+ .and(LogSpecification.betweenDates(start, end));
+
+ Page logs = logRepository.findAll(spec, pageable);
+
+ return ListResponse.builder()
+ .content(logs.getContent().stream().map(LogResponse::of).collect(Collectors.toList()))
+ .totalElements(logs.getTotalElements())
+ .size(logs.getSize())
+ .number(logs.getNumber())
+ .totalPages(logs.getTotalPages())
+ .build();
+ }
+
+ // 로그인 시 로그 기록
+ @Transactional
+ public void saveLoginLog(String ip, Method method, String url, Object result) {
+ // userId
+ Long userId = null;
+ if (result instanceof Tokeninfo) {
+ String token = ((Tokeninfo) result).getToken();
+ userId = jwtUtil.extractAllClaims(token).get("id", Long.class);
+ }
+
+ // 현재 유저 조회
+ User user = userRepositry.findById(userId).orElseThrow(
+ () -> new CustomException(ErrorCode.UNAUTHORIZED)
+ );
+
+ // 활동 유형
+ Activity activity = Activity.USER_LOGGED_IN;
+
+ // 대상 id
+ Long targetId = userId;
+
+ // 로그 DB에 저장
+ Log log = Log.builder()
+ .user(user)
+ .activity(activity)
+ .ipAddress(ip)
+ .method(method)
+ .targetId(targetId)
+ .requestUrl(url)
+ .build();
+
+ logRepository.save(log);
+ }
+
+ // 생성, 수정, 삭제 시 로그 기록
+ @Transactional
+ public void saveCudLog(String ip, Method method, String url, Long userId, Object result) {
+ // 현재 유저 조회
+ User user = userRepositry.findById(userId).orElseThrow(
+ () -> new CustomException(ErrorCode.UNAUTHORIZED)
+ );
+
+ // 활동 유형 -> 요청 메서드와 url로 일치하는 활동 유형 찾기
+ Activity activity = null;
+ if (method.equals(Method.POST) && url.contains("/comments")) {
+ activity = Activity.COMMENT_CREATED;
+ } else if (method.equals(Method.PUT) && url.contains("/comments")) {
+ activity = Activity.COMMENT_UPDATED;
+ } else if (method.equals(Method.DELETE) && url.contains("/comments")) {
+ activity = Activity.COMMENT_DELETED;
+ } else if (method.equals(Method.POST) && url.contains("/tasks")) {
+ activity = Activity.TASK_CREATED;
+ } else if (method.equals(Method.PUT) && url.contains("/tasks")) {
+ activity = Activity.TASK_UPDATED;
+ } else if (method.equals(Method.DELETE) && url.contains("/tasks")) {
+ activity = Activity.TASK_DELETED;
+ }
+
+ // 대상 id -> 생성인 경우 응답에서 / 수정과 삭제의 경우 url 마지막에서 찾기
+ Long targetId = null;
+ if (activity.equals(Activity.TASK_CREATED)) {
+ if (result instanceof TaskResponse) {
+ targetId = ((TaskResponse) result).getId();
+ }
+ } else if (activity.equals(Activity.COMMENT_CREATED)) {
+ if (result instanceof CommentResponseDataDto) {
+ targetId = ((CommentResponseDataDto) result).getId();
+ }
+ } else {
+ String[] parts = url.split("/");
+ String lastPart = parts[parts.length - 1]; // /api/.../{id}의 id
+ targetId = Long.parseLong(lastPart);
+ }
+
+ // 로그 DB에 저장
+ Log log = Log.builder()
+ .user(user)
+ .activity(activity)
+ .ipAddress(ip)
+ .method(method)
+ .targetId(targetId)
+ .requestUrl(url)
+ .build();
+
+ logRepository.save(log);
+ }
+
+ // 상태 변경 시 로그 기록
+ public void saveTaskStatusChangeLog(String ip, Method method, String url, Long userId,
+ Long targetId, Activity activity) {
+ // 현재 유저 조회
+ User user = userRepositry.findById(userId).orElseThrow(
+ () -> new CustomException(ErrorCode.UNAUTHORIZED)
+ );
+
+ // 로그 DB에 저장
+ Log log = Log.builder()
+ .user(user)
+ .activity(activity)
+ .ipAddress(ip)
+ .method(method)
+ .targetId(targetId)
+ .requestUrl(url)
+ .build();
+
+ logRepository.save(log);
+ }
+}
diff --git a/src/main/java/com/example/onederful/domain/task/common/CreateGroup.java b/src/main/java/com/example/onederful/domain/task/common/CreateGroup.java
new file mode 100644
index 0000000..e910e6d
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/task/common/CreateGroup.java
@@ -0,0 +1,5 @@
+package com.example.onederful.domain.task.common;
+
+public interface CreateGroup {
+
+}
diff --git a/src/main/java/com/example/onederful/domain/task/common/UpdateGroup.java b/src/main/java/com/example/onederful/domain/task/common/UpdateGroup.java
new file mode 100644
index 0000000..452d884
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/task/common/UpdateGroup.java
@@ -0,0 +1,5 @@
+package com.example.onederful.domain.task.common;
+
+public interface UpdateGroup {
+
+}
diff --git a/src/main/java/com/example/onederful/domain/task/controller/TaskController.java b/src/main/java/com/example/onederful/domain/task/controller/TaskController.java
new file mode 100644
index 0000000..2570a24
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/task/controller/TaskController.java
@@ -0,0 +1,99 @@
+package com.example.onederful.domain.task.controller;
+
+import com.example.onederful.common.ApiResponseDto;
+import com.example.onederful.common.ListResponse;
+import com.example.onederful.domain.task.common.CreateGroup;
+import com.example.onederful.domain.task.common.UpdateGroup;
+import com.example.onederful.domain.task.dto.request.TaskSaveRequest;
+import com.example.onederful.domain.task.dto.request.TaskStatusUpdateRequest;
+import com.example.onederful.domain.task.dto.response.TaskResponse;
+import com.example.onederful.domain.task.enums.ProcessStatus;
+import com.example.onederful.domain.task.service.TaskService;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotNull;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort.Direction;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@Validated
+@RequiredArgsConstructor
+@RequestMapping("/api/tasks")
+public class TaskController {
+
+ private final TaskService taskService;
+
+ @PostMapping
+ public ResponseEntity createTask(
+ @RequestBody @Validated(CreateGroup.class) @Valid TaskSaveRequest request,
+ HttpServletRequest httpServletRequest) {
+
+ TaskResponse response = taskService.createTask(request, httpServletRequest);
+
+ return ResponseEntity.ok(ApiResponseDto.success("업무 생성에 성공하였습니다.", response));
+ }
+
+ @GetMapping("/{id}")
+ public ResponseEntity findTask(@PathVariable @NotNull Long id) {
+
+ TaskResponse response = taskService.findTask(id);
+
+ return ResponseEntity.ok(ApiResponseDto.success("업무 상세조회에 성공하였습니다.", response));
+ }
+
+ @GetMapping
+ public ResponseEntity findTasks(
+ @RequestParam(defaultValue = "0") @Min(0) int page,
+ @RequestParam(defaultValue = "5") @Min(5) int size,
+ @RequestParam(defaultValue = "") String search,
+ @RequestParam(defaultValue = "TODO") ProcessStatus status
+ ) {
+ Pageable pageable = PageRequest.of(page, size, Direction.ASC, "dueDate");
+
+ ListResponse response = taskService.findTasks(pageable, search, status);
+
+ return ResponseEntity.ok(ApiResponseDto.success("업무 리스트 조회에 성공하였습니다.", response));
+ }
+
+ @DeleteMapping("/{id}")
+ public ResponseEntity deleteTask(@PathVariable @NotNull Long id) {
+
+ taskService.deleteTask(id);
+
+ return ResponseEntity.ok(ApiResponseDto.success("업무 삭제에 성공하였습니다.", null));
+ }
+
+ @PutMapping("/{id}")
+ public ResponseEntity updateTask(@PathVariable @NotNull Long id,
+ @RequestBody @Validated(UpdateGroup.class) @Valid TaskSaveRequest request) {
+
+ TaskResponse response = taskService.updateTask(id, request);
+
+ return ResponseEntity.ok(ApiResponseDto.success("업무 수정에 성공하였습니다.", response));
+ }
+
+ @PatchMapping("/{id}/status")
+ public ResponseEntity updateTaskStatus(@PathVariable @NotNull Long id,
+ @RequestBody @Valid
+ TaskStatusUpdateRequest request) {
+
+ TaskResponse response = taskService.updateTaskStatus(id, request);
+
+ return ResponseEntity.ok(ApiResponseDto.success("업무 상태 변경에 성공하였습니다.", response));
+ }
+}
diff --git a/src/main/java/com/example/onederful/domain/task/dto/request/TaskSaveRequest.java b/src/main/java/com/example/onederful/domain/task/dto/request/TaskSaveRequest.java
new file mode 100644
index 0000000..bd2cd07
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/task/dto/request/TaskSaveRequest.java
@@ -0,0 +1,37 @@
+package com.example.onederful.domain.task.dto.request;
+
+import com.example.onederful.domain.task.common.UpdateGroup;
+import com.example.onederful.domain.task.enums.Priority;
+import com.example.onederful.domain.task.enums.ProcessStatus;
+import jakarta.validation.constraints.FutureOrPresent;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import java.time.LocalDateTime;
+import lombok.Builder;
+import lombok.Getter;
+
+@Getter
+@Builder
+public class TaskSaveRequest {
+
+ @NotBlank(message = "업무 제목은 필수 항목입니다.")
+ @Size(min = 1, max = 100, message = "1자 이상 100자 이하로 입력해주세요.")
+ private String title;
+
+ @NotBlank(message = "업무 내용은 필수 항목입니다.")
+ private String description;
+
+ @NotNull(message = "업무의 우선순위는 필수 항목입니다.")
+ private Priority priority;
+
+ @NotNull(message = "관리자 선택은 필수 항목입니다.")
+ private Long assigneeId;
+
+ @NotNull(groups = {UpdateGroup.class}, message = "업무의 상태는 필수 항목입니다.")
+ private ProcessStatus status;
+
+ @NotNull(message = "마감일은 필수 항목입니다.")
+ @FutureOrPresent(message = "마감일은 오늘이후만 가능합니다.")
+ private LocalDateTime dueDate;
+}
diff --git a/src/main/java/com/example/onederful/domain/task/dto/request/TaskStatusUpdateRequest.java b/src/main/java/com/example/onederful/domain/task/dto/request/TaskStatusUpdateRequest.java
new file mode 100644
index 0000000..4668624
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/task/dto/request/TaskStatusUpdateRequest.java
@@ -0,0 +1,14 @@
+package com.example.onederful.domain.task.dto.request;
+
+import com.example.onederful.domain.task.enums.ProcessStatus;
+import lombok.Getter;
+
+@Getter
+public class TaskStatusUpdateRequest {
+
+ private final ProcessStatus status;
+
+ public TaskStatusUpdateRequest(ProcessStatus status) {
+ this.status = status;
+ }
+}
diff --git a/src/main/java/com/example/onederful/domain/task/dto/response/TaskAssignee.java b/src/main/java/com/example/onederful/domain/task/dto/response/TaskAssignee.java
new file mode 100644
index 0000000..4b59157
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/task/dto/response/TaskAssignee.java
@@ -0,0 +1,29 @@
+package com.example.onederful.domain.task.dto.response;
+
+import com.example.onederful.domain.user.entity.User;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class TaskAssignee {
+
+ private Long id;
+ private String username;
+ private String name;
+ private String email;
+
+ public static TaskAssignee of(User user) {
+ return TaskAssignee.builder()
+ .id(user.getId())
+ .username(user.getUsername())
+ .name(user.getName())
+ .email(user.getEmail())
+ .build();
+ }
+}
diff --git a/src/main/java/com/example/onederful/domain/task/dto/response/TaskResponse.java b/src/main/java/com/example/onederful/domain/task/dto/response/TaskResponse.java
new file mode 100644
index 0000000..2b3b802
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/task/dto/response/TaskResponse.java
@@ -0,0 +1,46 @@
+package com.example.onederful.domain.task.dto.response;
+
+import com.example.onederful.domain.task.entity.Task;
+import com.example.onederful.domain.task.enums.Priority;
+import com.example.onederful.domain.task.enums.ProcessStatus;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class TaskResponse {
+
+ private Long id;
+ private String title;
+ private String description;
+ private Priority priority;
+ private ProcessStatus status;
+ private Long assigneeId;
+ private TaskAssignee assignee;
+
+ private OffsetDateTime dueDate;
+ private OffsetDateTime createdAt;
+ private OffsetDateTime updatedAt;
+
+ public static TaskResponse of(Task task) {
+ return TaskResponse.builder()
+ .id(task.getId())
+ .title(task.getTitle())
+ .description(task.getDescription())
+ .status(task.getStatus())
+ .priority(task.getPriority())
+ .assigneeId(task.getAssignee().getId())
+ .assignee(TaskAssignee.of(task.getAssignee()))
+ .dueDate(task.getDueDate().atOffset(ZoneOffset.UTC))
+ .createdAt(task.getCreatedAt().atOffset(ZoneOffset.UTC))
+ .updatedAt(task.getUpdatedAt().atOffset(ZoneOffset.UTC))
+ .build();
+ }
+}
diff --git a/src/main/java/com/example/onederful/domain/task/entity/Task.java b/src/main/java/com/example/onederful/domain/task/entity/Task.java
new file mode 100644
index 0000000..e4d51e8
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/task/entity/Task.java
@@ -0,0 +1,109 @@
+package com.example.onederful.domain.task.entity;
+
+
+import com.example.onederful.domain.task.enums.Priority;
+import com.example.onederful.domain.task.enums.ProcessStatus;
+import com.example.onederful.domain.user.entity.User;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import java.time.LocalDateTime;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.hibernate.annotations.SQLRestriction;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+@Entity
+@Builder
+@Getter
+@Table(name = "tasks")
+@SQLRestriction("is_deleted = false")
+@AllArgsConstructor
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@EntityListeners(AuditingEntityListener.class)
+public class Task {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "title", nullable = false)
+ private String title;
+
+ @Column(name = "description", nullable = false)
+ private String description;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "priority", nullable = false)
+ private Priority priority;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "assignee_id", nullable = false)
+ private User assignee;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id", nullable = false)
+ private User user;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "status", nullable = false)
+ private ProcessStatus status;
+
+ @Column(name = "due_date", nullable = false)
+ private LocalDateTime dueDate;
+
+ @Column(name = "started_at", nullable = false)
+ private LocalDateTime startedAt;
+
+ @CreatedDate
+ @Column(name = "created_at", updatable = false)
+ private LocalDateTime createdAt;
+
+ @LastModifiedDate
+ @Column(name = "updated_at")
+ private LocalDateTime updatedAt;
+
+ @Column(name = "deleted_at")
+ private LocalDateTime deletedAt;
+
+ @Builder.Default
+ @Column(name = "is_deleted", nullable = false)
+ private Boolean isDeleted = false;
+
+ public void updateTask(String title, String content, Priority priority, User assignee,
+ LocalDateTime dueDate, ProcessStatus status) {
+ this.title = title;
+ this.description = content;
+ this.priority = priority;
+ this.assignee = assignee;
+ this.dueDate = dueDate;
+ this.status = status;
+ }
+
+ public void updateTaskStatus(ProcessStatus status) {
+ this.status = status;
+ }
+
+ public void delete() {
+ this.isDeleted = true;
+ this.deletedAt = LocalDateTime.now();
+ }
+
+ public void taskStart() {
+ this.startedAt = LocalDateTime.now();
+ }
+}
diff --git a/src/main/java/com/example/onederful/domain/task/enums/Priority.java b/src/main/java/com/example/onederful/domain/task/enums/Priority.java
new file mode 100644
index 0000000..74f7633
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/task/enums/Priority.java
@@ -0,0 +1,5 @@
+package com.example.onederful.domain.task.enums;
+
+public enum Priority {
+ LOW, MEDIUM, HIGH
+}
diff --git a/src/main/java/com/example/onederful/domain/task/enums/ProcessStatus.java b/src/main/java/com/example/onederful/domain/task/enums/ProcessStatus.java
new file mode 100644
index 0000000..a828a6c
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/task/enums/ProcessStatus.java
@@ -0,0 +1,5 @@
+package com.example.onederful.domain.task.enums;
+
+public enum ProcessStatus {
+ TODO,IN_PROGRESS,DONE
+}
diff --git a/src/main/java/com/example/onederful/domain/task/repository/TaskRepository.java b/src/main/java/com/example/onederful/domain/task/repository/TaskRepository.java
new file mode 100644
index 0000000..c2bd7a8
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/task/repository/TaskRepository.java
@@ -0,0 +1,18 @@
+package com.example.onederful.domain.task.repository;
+
+import com.example.onederful.domain.task.entity.Task;
+import com.example.onederful.domain.task.enums.ProcessStatus;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface TaskRepository extends JpaRepository {
+
+ @Query("SELECT t FROM Task t WHERE (t.title LIKE %:search% OR t.description LIKE %:search%) AND t.status = :status")
+ Page findTasks(@Param("search") String search, @Param("status") ProcessStatus status,
+ Pageable pageable);
+}
diff --git a/src/main/java/com/example/onederful/domain/task/service/TaskService.java b/src/main/java/com/example/onederful/domain/task/service/TaskService.java
new file mode 100644
index 0000000..bc7bbd8
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/task/service/TaskService.java
@@ -0,0 +1,144 @@
+package com.example.onederful.domain.task.service;
+
+import com.example.onederful.common.ListResponse;
+import com.example.onederful.domain.task.dto.request.TaskSaveRequest;
+import com.example.onederful.domain.task.dto.request.TaskStatusUpdateRequest;
+import com.example.onederful.domain.task.dto.response.TaskResponse;
+import com.example.onederful.domain.task.entity.Task;
+import com.example.onederful.domain.task.enums.ProcessStatus;
+import com.example.onederful.domain.task.repository.TaskRepository;
+import com.example.onederful.domain.user.entity.User;
+import com.example.onederful.domain.user.repository.UserRepository;
+import com.example.onederful.exception.CustomException;
+import com.example.onederful.exception.ErrorCode;
+import com.example.onederful.security.JwtUtil;
+import jakarta.servlet.http.HttpServletRequest;
+import java.util.stream.Collectors;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+public class TaskService {
+
+ private final TaskRepository taskRepository;
+ private final UserRepository userRepository;
+ private final JwtUtil jwtUtil;
+
+ @Transactional
+ public TaskResponse createTask(TaskSaveRequest request, HttpServletRequest httpServletRequest) {
+
+ Long userId = jwtUtil.extractId(httpServletRequest);
+
+ User me = userRepository.findById(userId)
+ .orElseThrow(() -> new CustomException(ErrorCode.NONEXISTENT_USER));
+ User manager = userRepository.findById(request.getAssigneeId())
+ .orElseThrow(() -> new CustomException(ErrorCode.NONEXISTENT_USER));
+
+ Task task = Task.builder()
+ .title(request.getTitle())
+ .description(request.getDescription())
+ .priority(request.getPriority())
+ .assignee(manager)
+ .user(me)
+ .status(ProcessStatus.TODO)
+ .dueDate(request.getDueDate())
+ .build();
+
+ Task savedTask = taskRepository.save(task);
+
+ return TaskResponse.of(savedTask);
+ }
+
+ @Transactional(readOnly = true)
+ public TaskResponse findTask(Long id) {
+
+ Task task = taskRepository.findById(id)
+ .orElseThrow(() -> new CustomException(ErrorCode.NONEXISTENT_TASK));
+
+ return TaskResponse.of(task);
+ }
+
+ @Transactional(readOnly = true)
+ public ListResponse findTasks(Pageable pageable, String search,
+ ProcessStatus status) {
+
+ Page tasks = taskRepository.findTasks(search, status, pageable);
+
+ return ListResponse.builder()
+ .content(tasks.getContent().stream().map(TaskResponse::of).collect(Collectors.toList()))
+ .totalElements(tasks.getTotalElements())
+ .size(tasks.getSize())
+ .number(tasks.getNumber())
+ .totalPages(tasks.getTotalPages())
+ .build();
+ }
+
+ @Transactional
+ public void deleteTask(Long id) {
+
+ Task task = taskRepository.findById(id)
+ .orElseThrow(() -> new CustomException(ErrorCode.NONEXISTENT_TASK));
+
+ task.delete();
+ }
+
+ @Transactional
+ public TaskResponse updateTaskStatus(Long id, TaskStatusUpdateRequest request) {
+ Task task = taskRepository.findById(id)
+ .orElseThrow(() -> new CustomException(ErrorCode.NONEXISTENT_TASK));
+
+ taskUpdateValid(task, request.getStatus());
+
+ task.updateTaskStatus(request.getStatus());
+
+ return TaskResponse.of(task);
+ }
+
+ @Transactional
+ public TaskResponse updateTask(Long id, TaskSaveRequest request) {
+
+ Task task = taskRepository.findById(id)
+ .orElseThrow(() -> new CustomException(ErrorCode.NONEXISTENT_TASK));
+ User assignee = userRepository.findById(request.getAssigneeId())
+ .orElseThrow(() -> new CustomException(ErrorCode.NONEXISTENT_USER));
+
+ taskUpdateValid(task, request.getStatus());
+
+ task.updateTask(request.getTitle(), request.getDescription(), request.getPriority(),
+ assignee,
+ request.getDueDate(), request.getStatus());
+
+ return TaskResponse.of(task);
+ }
+
+ @Transactional(readOnly = true)
+ public Task findById(Long id) {
+ return taskRepository.findById(id)
+ .orElseThrow(() -> new CustomException(ErrorCode.NONEXISTENT_TASK));
+ }
+
+ private void taskUpdateValid(Task task, ProcessStatus status) {
+ if (task.getStatus() == ProcessStatus.DONE) {
+ if (status != ProcessStatus.DONE) {
+ throw new CustomException(ErrorCode.BAD_REQUEST_STATUS);
+ }
+ }
+
+ if (task.getStatus() == ProcessStatus.TODO) {
+ if (status != ProcessStatus.TODO && status != ProcessStatus.IN_PROGRESS) {
+ throw new CustomException(ErrorCode.BAD_REQUEST_STATUS);
+ }
+ task.taskStart();
+ }
+
+ if (task.getStatus() == ProcessStatus.IN_PROGRESS) {
+ if (status != ProcessStatus.IN_PROGRESS && status != ProcessStatus.DONE) {
+ throw new CustomException(ErrorCode.BAD_REQUEST_STATUS);
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/example/onederful/domain/user/common/LoginGroup.java b/src/main/java/com/example/onederful/domain/user/common/LoginGroup.java
new file mode 100644
index 0000000..9a2a32d
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/user/common/LoginGroup.java
@@ -0,0 +1,4 @@
+package com.example.onederful.domain.user.common;
+
+public interface LoginGroup {
+}
diff --git a/src/main/java/com/example/onederful/domain/user/common/PasswordGroup.java b/src/main/java/com/example/onederful/domain/user/common/PasswordGroup.java
new file mode 100644
index 0000000..559625a
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/user/common/PasswordGroup.java
@@ -0,0 +1,4 @@
+package com.example.onederful.domain.user.common;
+
+public interface PasswordGroup {
+}
diff --git a/src/main/java/com/example/onederful/domain/user/common/SignupGroup.java b/src/main/java/com/example/onederful/domain/user/common/SignupGroup.java
new file mode 100644
index 0000000..0e42206
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/user/common/SignupGroup.java
@@ -0,0 +1,4 @@
+package com.example.onederful.domain.user.common;
+
+public interface SignupGroup {
+}
diff --git a/src/main/java/com/example/onederful/domain/user/common/UserMapper.java b/src/main/java/com/example/onederful/domain/user/common/UserMapper.java
new file mode 100644
index 0000000..b12f9f2
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/user/common/UserMapper.java
@@ -0,0 +1,40 @@
+package com.example.onederful.domain.user.common;
+
+import com.example.onederful.domain.user.dto.*;
+import com.example.onederful.domain.user.entity.User;
+import com.example.onederful.domain.user.enums.Role;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+
+
+public class UserMapper {
+
+ // Dto → Entity
+ public static User user (RequestDto dto){
+ return User.builder()
+ .username(dto.getUsername())
+ .email(dto.getEmail())
+ .password(dto.getPassword())
+ .name(dto.getName())
+ .role(Role.USER)
+ .isDeleted(false)
+ .build();
+ }
+
+ // ResponseBody data (유저 정보) (Entity → Dto)
+ public static UserResponseDto data(User user){
+ // LocalDateTime -> OffsetDateTime
+ OffsetDateTime createAt = user.getCreatedAt().atOffset(ZoneOffset.UTC);
+
+ return new UserResponseDto(
+ user.getId(),
+ user.getUsername(),
+ user.getEmail(),
+ user.getName(),
+ user.getRole(),
+ createAt
+ );
+ }
+
+
+}
diff --git a/src/main/java/com/example/onederful/domain/user/controller/AuthController.java b/src/main/java/com/example/onederful/domain/user/controller/AuthController.java
new file mode 100644
index 0000000..f53a8be
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/user/controller/AuthController.java
@@ -0,0 +1,82 @@
+package com.example.onederful.domain.user.controller;
+
+import com.example.onederful.common.ApiResponseDto;
+import com.example.onederful.domain.user.common.LoginGroup;
+import com.example.onederful.domain.user.common.PasswordGroup;
+import com.example.onederful.domain.user.common.SignupGroup;
+import com.example.onederful.domain.user.dto.*;
+import com.example.onederful.domain.user.service.UserService;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api")
+public class AuthController {
+
+ private final UserService userService;
+
+ // 회원가입
+ @PostMapping("/auth/register")
+ public ResponseEntity register(@Validated(SignupGroup.class) @RequestBody RequestDto requestDto){
+
+ UserResponseDto signup = userService.signup(requestDto);
+
+ ApiResponseDto success = ApiResponseDto.success("회원가입이 성공하였습니다.", signup);
+
+ return ResponseEntity.status(HttpStatus.OK).body(success);
+ }
+
+ // 로그인
+ @PostMapping("/auth/login")
+ public ResponseEntity login(@Validated(LoginGroup.class) @RequestBody RequestDto requestDto){
+
+ Tokeninfo token = userService.login(requestDto);
+
+ ApiResponseDto success = ApiResponseDto.success("로그인이 완료되었습니다.", token);
+
+ return ResponseEntity.status(HttpStatus.OK).body(success);
+ }
+
+ // 현재 사용자 정보 조회
+ @GetMapping("/users/me")
+ public ResponseEntity select (HttpServletRequest request){
+
+ UserResponseDto select = userService.select(request);
+
+ ApiResponseDto success = ApiResponseDto.success("사용자가 정보를 조회했습니다.", select);
+
+ return ResponseEntity.status(HttpStatus.OK).body(success);
+ }
+
+ // 회원 탈퇴 (계정 삭제)
+ @PostMapping("/auth/withdraw")
+ public ResponseEntity withdraw (HttpServletRequest request,
+ @Validated(PasswordGroup.class) @RequestBody RequestDto dto){
+
+ userService.withdraw(request,dto);
+
+ ApiResponseDto success = ApiResponseDto.success("회원탈퇴가 완료되었습니다.", null);
+
+ return ResponseEntity.status(HttpStatus.OK).body(success);
+ }
+
+ // 모든 회원 정보 조회
+ @GetMapping("/users")
+ public ResponseEntity selectAll(){
+ List selectAll = userService.selectAll();
+
+ ApiResponseDto success = ApiResponseDto.success("요청이 성공적으로 처리되었습니다.",selectAll);
+
+ return ResponseEntity.status(HttpStatus.OK).body(success);
+ }
+
+
+
+}
diff --git a/src/main/java/com/example/onederful/domain/user/dto/RequestDto.java b/src/main/java/com/example/onederful/domain/user/dto/RequestDto.java
new file mode 100644
index 0000000..f8ef9ec
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/user/dto/RequestDto.java
@@ -0,0 +1,38 @@
+package com.example.onederful.domain.user.dto;
+
+import com.example.onederful.domain.user.common.LoginGroup;
+import com.example.onederful.domain.user.common.PasswordGroup;
+import com.example.onederful.domain.user.common.SignupGroup;
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@AllArgsConstructor
+@NoArgsConstructor
+@Getter
+public class RequestDto {
+ @NotBlank(groups = {SignupGroup.class, LoginGroup.class}, message = "사용자명은 필수입니다.")
+ @Pattern(groups = {SignupGroup.class, LoginGroup.class},
+ regexp = "^[a-zA-Z0-9]{4,20}$",
+ message = "사용자명은 4-20자의 영문/숫자만 허용됩니다")
+ private String username;
+
+ @NotBlank (groups = {SignupGroup.class} , message = "이메일은 필수입니다.")
+ @Email (groups = {SignupGroup.class}, message = "유효한 이메일 형식이 아닙니다.")
+ private String email;
+
+ @NotBlank (groups = {SignupGroup.class,LoginGroup.class, PasswordGroup.class} , message = "비밀번호는 필수입니다.")
+ @Pattern (groups = {SignupGroup.class,LoginGroup.class, PasswordGroup.class},
+ regexp = "^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d)(?=.*[^\\w\\s]).+$",
+ message = "비밀번호는 대문자·소문자·숫자·특수문자를 각각 1자 이상 포함해야 합니다."
+ )
+ private String password;
+
+ @NotBlank (groups = {SignupGroup.class}, message = "이름은 필수입니다.")
+ @Size(groups = {SignupGroup.class}, min = 2, max = 50, message = "이름은 2~50자 사이어야 합니다.")
+ private String name;
+}
diff --git a/src/main/java/com/example/onederful/domain/user/dto/Tokeninfo.java b/src/main/java/com/example/onederful/domain/user/dto/Tokeninfo.java
new file mode 100644
index 0000000..d5da589
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/user/dto/Tokeninfo.java
@@ -0,0 +1,10 @@
+package com.example.onederful.domain.user.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public class Tokeninfo {
+ private String token;
+}
diff --git a/src/main/java/com/example/onederful/domain/user/dto/UserResponseDto.java b/src/main/java/com/example/onederful/domain/user/dto/UserResponseDto.java
new file mode 100644
index 0000000..e027bd2
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/user/dto/UserResponseDto.java
@@ -0,0 +1,19 @@
+package com.example.onederful.domain.user.dto;
+
+import com.example.onederful.domain.user.enums.Role;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+
+import java.time.OffsetDateTime;
+
+@Getter
+@AllArgsConstructor
+public class UserResponseDto {
+ private Long id;
+ private String username;
+ private String email;
+ private String name;
+ private Role role;
+ private OffsetDateTime createdAt;
+}
diff --git a/src/main/java/com/example/onederful/domain/user/entity/User.java b/src/main/java/com/example/onederful/domain/user/entity/User.java
new file mode 100644
index 0000000..83554be
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/user/entity/User.java
@@ -0,0 +1,69 @@
+package com.example.onederful.domain.user.entity;
+
+import com.example.onederful.domain.user.enums.Role;
+import jakarta.persistence.*;
+
+import java.time.LocalDateTime;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.hibernate.annotations.Where;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+@Entity
+@Builder
+@Getter
+@Table(name = "users")
+@AllArgsConstructor
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@EntityListeners(AuditingEntityListener.class)
+@Where(clause = "is_deleted = false")
+public class User {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "username", nullable = false)
+ private String username;
+
+ @Column(name = "email", nullable = false)
+ private String email;
+
+ @Column(name="password", nullable = false)
+ private String password;
+
+ @Column(name = "name", nullable = false)
+ private String name;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name= "role", nullable = false)
+ private Role role;
+
+ @CreatedDate
+ @Column(name = "created_at", updatable = false)
+ private LocalDateTime createdAt;
+
+ @LastModifiedDate
+ @Column(name = "updated_at")
+ private LocalDateTime updatedAt;
+
+ @Column(name = "deleted_at")
+ private LocalDateTime deletedAt;
+
+ @Builder.Default
+ @Column(name="is_deleted", nullable = false)
+ private Boolean isDeleted = false;
+
+ public void setEncodedPassword(String encodedPassword){
+ this.password = encodedPassword;
+ }
+
+ public void delete() {
+ this.isDeleted = true;
+ }
+}
diff --git a/src/main/java/com/example/onederful/domain/user/enums/Role.java b/src/main/java/com/example/onederful/domain/user/enums/Role.java
new file mode 100644
index 0000000..b3cc86a
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/user/enums/Role.java
@@ -0,0 +1,5 @@
+package com.example.onederful.domain.user.enums;
+
+public enum Role {
+ ADMIN, USER
+}
diff --git a/src/main/java/com/example/onederful/domain/user/repository/UserRepository.java b/src/main/java/com/example/onederful/domain/user/repository/UserRepository.java
new file mode 100644
index 0000000..fa642f7
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/user/repository/UserRepository.java
@@ -0,0 +1,13 @@
+package com.example.onederful.domain.user.repository;
+
+import com.example.onederful.domain.user.entity.User;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface UserRepository extends JpaRepository {
+ boolean existsByEmail(String email);
+ boolean existsByUsername(String username);
+ Optional findByUsername(String username);
+
+}
diff --git a/src/main/java/com/example/onederful/domain/user/service/UserService.java b/src/main/java/com/example/onederful/domain/user/service/UserService.java
new file mode 100644
index 0000000..4e1520f
--- /dev/null
+++ b/src/main/java/com/example/onederful/domain/user/service/UserService.java
@@ -0,0 +1,143 @@
+package com.example.onederful.domain.user.service;
+
+import com.example.onederful.config.PasswordEncoder;
+import com.example.onederful.domain.user.common.UserMapper;
+import com.example.onederful.domain.user.dto.RequestDto;
+import com.example.onederful.domain.user.dto.Tokeninfo;
+import com.example.onederful.domain.user.dto.UserResponseDto;
+import com.example.onederful.domain.user.entity.User;
+import com.example.onederful.domain.user.repository.UserRepository;
+import com.example.onederful.exception.CustomException;
+import com.example.onederful.exception.ErrorCode;
+import com.example.onederful.security.JwtUtil;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+public class UserService {
+
+ private final PasswordEncoder passwordEncoder;
+ private final UserRepository userRepository;
+ private final JwtUtil jwtUtil;
+
+
+ // 회원가입
+ @Transactional
+ public UserResponseDto signup(RequestDto dto){
+
+ // 이메일 중복 확인
+ if(userRepository.existsByEmail(dto.getEmail())){
+ throw new CustomException(ErrorCode.DUPLICATE_EMAIL);
+ }
+
+
+ // 아이디 중복 확인
+ if(userRepository.existsByUsername(dto.getUsername())){
+ throw new CustomException(ErrorCode.DUPLICATE_USER);
+ }
+
+ // Dto → Entity
+ User user = UserMapper.user(dto);
+
+ // 비밀번호 암호화
+ user.setEncodedPassword(passwordEncoder.encode(user.getPassword()));
+
+ User savedUser = userRepository.save(user);
+
+ // ResponseBody data(유저 정보)
+ return UserMapper.data(savedUser);
+ }
+
+
+ // 로그인
+ public Tokeninfo login(RequestDto dto){
+ String username = dto.getUsername();
+ String password = dto.getPassword();
+
+
+ User user = userRepository.findByUsername(username).orElseThrow(
+ () -> new CustomException(ErrorCode.BAD_REQUEST)
+ );
+
+ if (!passwordEncoder.matches(password, user.getPassword())) {
+ throw new CustomException(ErrorCode.BAD_REQUEST);
+ }
+
+ // JWT Token
+ String token = jwtUtil.generateToken(user);
+
+ // ResponseBody data(Token)
+ return token(token);
+ }
+
+
+ // 회원 정보 조회
+ @Transactional
+ public UserResponseDto select(HttpServletRequest request){
+
+ // 토큰에서 Id 가져오기
+ Long userId = jwtUtil.extractId(request);
+
+ User user = userRepository.findById(userId).orElseThrow(
+ () -> new CustomException(ErrorCode.UNAUTHORIZED)
+ );
+
+ return UserMapper.data(user);
+ }
+
+
+ // 회원 탈퇴
+ @Transactional
+ public void withdraw(HttpServletRequest request , RequestDto dto){
+ // 토큰에서 Id 가져오기
+ Long userId = jwtUtil.extractId(request);
+
+ // 비밀번호
+ String password = dto.getPassword();
+
+ User user = userRepository.findById(userId).orElseThrow(
+ () -> new CustomException(ErrorCode.UNAUTHORIZED)
+ );
+
+ if (!passwordEncoder.matches(password,user.getPassword())) {
+ throw new CustomException(ErrorCode.INVALID_PASSWORD);
+ }
+
+ user.delete();
+ }
+
+
+ // 모든 회원 정보 조회
+ public List selectAll(){
+
+ List result = new ArrayList<>();
+
+ List all = userRepository.findAll();
+
+ for(User user : all){
+ result.add(UserMapper.data(user));
+ }
+
+ return result;
+
+ }
+
+
+
+
+ // ResponseBody date (Token)
+ private Tokeninfo token (String token){
+
+ String newToken = token.substring(7);
+
+ return new Tokeninfo(newToken);
+ }
+
+
+}
diff --git a/src/main/java/com/example/onederful/exception/CustomException.java b/src/main/java/com/example/onederful/exception/CustomException.java
new file mode 100644
index 0000000..97a182f
--- /dev/null
+++ b/src/main/java/com/example/onederful/exception/CustomException.java
@@ -0,0 +1,11 @@
+package com.example.onederful.exception;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public class CustomException extends RuntimeException{
+ private final ErrorCode errorCode;
+
+}
diff --git a/src/main/java/com/example/onederful/exception/ErrorCode.java b/src/main/java/com/example/onederful/exception/ErrorCode.java
new file mode 100644
index 0000000..d4d42e8
--- /dev/null
+++ b/src/main/java/com/example/onederful/exception/ErrorCode.java
@@ -0,0 +1,35 @@
+package com.example.onederful.exception;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import org.springframework.http.HttpStatus;
+
+@AllArgsConstructor
+@Getter
+public enum ErrorCode {
+
+ // User
+ DUPLICATE_USER(HttpStatus.CONFLICT,"이미 존재하는 사용자명입니다."),
+ DUPLICATE_EMAIL(HttpStatus.CONFLICT,"이미 존재하는 이메일입니다."),
+ BAD_REQUEST(HttpStatus.BAD_REQUEST,"잘못된 사용자명 또는 비밀번호입니다."),
+ UNAUTHORIZED(HttpStatus.UNAUTHORIZED,"인증이 필요합니다."),
+ INVALID_PASSWORD(HttpStatus.UNAUTHORIZED,"비밀번호가 일치하지 않습니다."),
+ LOGOUT_FAIL(HttpStatus.UNAUTHORIZED,"로그아웃에 실패하였습니다."),
+ NONEXISTENT_USER(HttpStatus.BAD_REQUEST, "존재하지 않는 사용자입니다."),
+
+ // Task
+ NONEXISTENT_TASK(HttpStatus.BAD_REQUEST, "존재하지 않는 테스크입니다."),
+ BAD_REQUEST_STATUS(HttpStatus.BAD_REQUEST, "업무 상태변경은 바로 다음 단계로만 가능합니다."),
+
+ // Comment
+ NONEXISTENT_COMMENT(HttpStatus.BAD_REQUEST, "존재하지 않는 댓글입니다."),
+ INVALID_COMMENT(HttpStatus.BAD_REQUEST, "삭제된 댓글입니다."),
+
+ // Log
+ INVALID_OR_EXPIRED_REQUEST(HttpStatus.BAD_REQUEST,"요청 정보가 유효하지 않거나 만료되었습니다."),
+ INVALID_ACTIVITY(HttpStatus.BAD_REQUEST,"알 수 없는 활동 유형입니다.");
+
+
+ private final HttpStatus status;
+ private final String message;
+}
diff --git a/src/main/java/com/example/onederful/exception/GlobalExceptionHandler.java b/src/main/java/com/example/onederful/exception/GlobalExceptionHandler.java
new file mode 100644
index 0000000..b999e8f
--- /dev/null
+++ b/src/main/java/com/example/onederful/exception/GlobalExceptionHandler.java
@@ -0,0 +1,34 @@
+package com.example.onederful.exception;
+
+import com.example.onederful.common.ApiResponseDto;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import java.util.Optional;
+
+@ControllerAdvice
+public class GlobalExceptionHandler {
+
+ @ExceptionHandler(CustomException.class)
+ public ResponseEntity handleCustomException(CustomException e){
+ ErrorCode errorCode = e.getErrorCode();
+
+ ApiResponseDto response = ApiResponseDto.error(errorCode.getMessage());
+
+ return ResponseEntity.status(errorCode.getStatus()).body(response);
+ }
+
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e){
+ String message = Optional.ofNullable(e.getBindingResult().getFieldError())
+ .map(FieldError::getDefaultMessage)
+ .orElse("잘못된 요청입니다.");
+
+ ApiResponseDto response = ApiResponseDto.error(message);
+
+ return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
+ }
+}
diff --git a/src/main/java/com/example/onederful/filter/JwtFilter.java b/src/main/java/com/example/onederful/filter/JwtFilter.java
new file mode 100644
index 0000000..df25a03
--- /dev/null
+++ b/src/main/java/com/example/onederful/filter/JwtFilter.java
@@ -0,0 +1,80 @@
+package com.example.onederful.filter;
+
+import com.example.onederful.security.JwtUtil;
+import jakarta.servlet.*;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import java.time.OffsetDateTime;
+
+@Slf4j
+@RequiredArgsConstructor
+public class JwtFilter implements Filter {
+
+ private final JwtUtil jwtUtil;
+
+ @Override
+ public void doFilter(
+ ServletRequest servletRequest,
+ ServletResponse servletResponse,
+ FilterChain filterChain) throws IOException, ServletException {
+
+ HttpServletRequest request = (HttpServletRequest) servletRequest;
+ HttpServletResponse response = (HttpServletResponse) servletResponse;
+
+ String requestURI = request.getRequestURI();
+
+ String authorizationHeader = request.getHeader("Authorization");
+
+ if("OPTIONS".equals(request.getMethod())) {
+ filterChain.doFilter(servletRequest, servletResponse);
+ }
+
+ // 회원가입, 로그인 경우
+ if (requestURI.startsWith("/api/auth/register") || requestURI.startsWith("/api/auth/login")
+ ||
+ requestURI.startsWith("/swagger-ui") ||
+ requestURI.startsWith("/v3/api-docs") ||
+ requestURI.startsWith("/swagger-resources") ||
+ requestURI.startsWith("/webjars")) {
+ filterChain.doFilter(servletRequest, servletResponse);
+ return;
+ }
+
+ // 토큰 존재 유무 확인
+ if(authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")){
+ errorResponse(response,HttpServletResponse.SC_UNAUTHORIZED,"인증이 필요합니다");
+ return;
+ }
+
+ // "Bearer" 빼고 확인
+ String jwt = authorizationHeader.substring(7);
+
+ // 토큰 검증
+ String errorMessage = jwtUtil.validateToken(jwt);
+ if (errorMessage != null) {
+ errorResponse(response, HttpServletResponse.SC_FORBIDDEN, errorMessage);
+ return;
+ }
+
+ filterChain.doFilter(servletRequest, servletResponse);
+ }
+
+ // 공통 에러 응답 처리
+ private void errorResponse(HttpServletResponse response, int status, String message) throws IOException {
+ response.setStatus(status);
+ response.setContentType("application/json;charset=utf-8");
+
+ String json = "{" +
+ "\"success\" : false," +
+ "\"message\": \""+ message + "\"," +
+ "\"data\" : null," +
+ "\"timestamp\" : \"" + OffsetDateTime.now() + "\"" +
+ "}";
+
+ response.getWriter().write(json);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/onederful/security/JwtUtil.java b/src/main/java/com/example/onederful/security/JwtUtil.java
new file mode 100644
index 0000000..73ec59e
--- /dev/null
+++ b/src/main/java/com/example/onederful/security/JwtUtil.java
@@ -0,0 +1,125 @@
+package com.example.onederful.security;
+
+import com.example.onederful.domain.user.entity.User;
+import io.jsonwebtoken.*;
+import io.jsonwebtoken.security.Keys;
+import io.jsonwebtoken.security.SecurityException;
+import jakarta.annotation.PostConstruct;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.security.Key;
+import java.util.Base64;
+import java.util.Date;
+
+@Slf4j
+@Component
+public class JwtUtil {
+
+ // JWT Token 접두사
+ public final static String BEARER_PREFIX = "Bearer ";
+
+ // JWT Token 만료시간
+ @Value("${jwt.expiration}")
+ public Long expirationTime;
+
+ // JWT 서명 알고리즘
+ private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
+
+ // 비밀 키
+ @Value("${jwt.secret.key}")
+ private String secretKey;
+
+ // 실제 서명에서 사용할 키 객체
+ private Key key;
+
+
+ /**
+ * 빈 초기화 메서드
+ * - 애플리케이션 실행 시 비밀키를 Base64로 디코딩 하여 key 객체를 초기화
+ */
+ @PostConstruct
+ public void init(){
+ byte [] bytes = Base64.getDecoder().decode(secretKey);
+ key = Keys.hmacShaKeyFor(bytes);
+ }
+
+
+ /**
+ * JWT 토큰 생성
+ * @param user User Entity
+ * @return 생성된 JWT 토큰
+ */
+ public String generateToken(User user){
+
+ Long id = user.getId();
+ String username = user.getUsername();
+ Date date = new Date();
+
+ return BEARER_PREFIX +
+ Jwts.builder()
+ .setSubject(username)
+ .claim("id",id)
+ .setIssuedAt(date)
+ .setExpiration(new Date(date.getTime()+ expirationTime))
+ .signWith(key,signatureAlgorithm)
+ .compact();
+ }
+
+
+ /**
+ * JWT 토큰 유효성 검증
+ * @param token JWT 토큰
+ * @return 토큰의 유효성 여부 (true : 유효, false : 유효하지 않음)
+ */
+ public String validateToken(String token){
+ try {
+ Jwts.parserBuilder()
+ .setSigningKey(key)
+ .build()
+ .parseClaimsJws(token);
+ return null; // 유효함
+ } catch (SecurityException | MalformedJwtException e) {
+ return "유효하지 않은 JWT 서명입니다.";
+ } catch (ExpiredJwtException e) {
+ return "만료된 JWT 토큰입니다.";
+ } catch (UnsupportedJwtException e) {
+ return "지원되지 않는 JWT 토큰입니다.";
+ } catch (IllegalArgumentException e) {
+ return "잘못된 JWT 토큰입니다.";
+ }
+ }
+
+ /**
+ * Token에 존재하는 모든 클레임(페이로드 값)을 추출
+ * @param token 검증된 JWT 토큰 (로그인 한 상태)
+ * @return 클라임 객체
+ */
+ public Claims extractAllClaims(String token){
+ return Jwts.parserBuilder()
+ .setSigningKey(key)
+ .build()
+ .parseClaimsJws(token)
+ .getBody();
+ }
+
+ /**
+ * Token에 저장된 ID(기본키) 가져오기
+ * @param request Request
+ * @return ID값
+ */
+ public Long extractId(HttpServletRequest request) {
+ String authorizationHeader = request.getHeader("Authorization");
+ if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
+ return null; // 로그인 안 된 상태면 null
+ }
+ String token = authorizationHeader.substring(7);
+ return extractAllClaims(token).get("id", Long.class);
+ }
+
+
+
+
+}
\ No newline at end of file
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
deleted file mode 100644
index fb897aa..0000000
--- a/src/main/resources/application.properties
+++ /dev/null
@@ -1,3 +0,0 @@
-spring:
- application:
- name: onederful
\ No newline at end of file
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 3c5778d..ec9fc33 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -5,4 +5,11 @@ spring:
datasource:
url: ${DB_URL}
username: ${DB_USERNAME}
- password: ${DB_PASSWORD}
\ No newline at end of file
+ password: ${DB_PASSWORD}
+ driver-class-name: com.mysql.cj.jdbc.Driver
+
+jwt:
+ secret:
+ key: ${SECRET_KEY}
+
+ expiration: 3600000
\ No newline at end of file
diff --git a/src/test/java/com/example/onederful/domain/comment/service/CommentServiceTest.java b/src/test/java/com/example/onederful/domain/comment/service/CommentServiceTest.java
new file mode 100644
index 0000000..4d8b014
--- /dev/null
+++ b/src/test/java/com/example/onederful/domain/comment/service/CommentServiceTest.java
@@ -0,0 +1,131 @@
+package com.example.onederful.domain.comment.service;
+
+import com.example.onederful.domain.comment.dto.CommentResponseDataDto;
+import com.example.onederful.domain.comment.entity.Comment;
+import com.example.onederful.domain.comment.repository.CommentRepository;
+import com.example.onederful.domain.task.entity.Task;
+import com.example.onederful.domain.task.repository.TaskRepository;
+import com.example.onederful.domain.user.entity.User;
+import com.example.onederful.domain.user.repository.UserRepository;
+import com.example.onederful.security.JwtUtil;
+import jakarta.servlet.http.HttpServletRequest;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.LocalDateTime;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+
+@ExtendWith(MockitoExtension.class)
+class CommentServiceTest {
+ @Mock
+ private CommentRepository commentRepository;
+ @Mock
+ private UserRepository userRepository;
+ @Mock
+ private TaskRepository taskRepository;
+ @Mock
+ private JwtUtil jwtUtil;
+
+ @InjectMocks
+ private CommentService commentService;
+
+ @Test
+ @DisplayName("지정된 테스크에 HttpServletRequest에서 사용자 정보를 가져와서 댓글을 생성 할 수 있는지")
+ void save() {
+
+ // given
+ Long task_id = 5L;
+ Long user_id = 2L;
+ HttpServletRequest httpServletRequest = mock(HttpServletRequest.class);
+ String content = "댓글 생성 테스트";
+
+ User user = mock(User.class);
+ Task task = mock(Task.class);
+ Comment comment = Comment.builder()
+ .id(1L)
+ .content(content)
+ .user(user)
+ .task(task)
+ .build();
+
+
+ given(jwtUtil.extractId(httpServletRequest)).willReturn(user_id);
+
+ given(userRepository.findById(user_id)).willReturn(Optional.of(user));
+ given(taskRepository.findById(task_id)).willReturn(Optional.of(task));
+ given(commentRepository.save(any(Comment.class))).willReturn(comment);
+ // when
+ CommentResponseDataDto result = commentService.save(task_id, httpServletRequest, content);
+
+ // then
+ assertThat(result.getContent()).isEqualTo(content);
+ assertThat(result.getId()).isEqualTo(1L);
+
+ }
+
+ @Test
+ @DisplayName("새로운 댓글내용 입력했을때 그 값으로 변하는지 안하는지")
+ void updateComment() {
+ // given
+ Long taskId = 5L;
+ Long commentId = 1L;
+ Long userId = 10L;
+ String originalContent = "기존 댓글 입니다.";
+ String updatedContent = " 수정된 댓글 입니다.";
+ HttpServletRequest httpServletRequest = mock(HttpServletRequest.class);
+
+ Task task = mock(Task.class);
+ User user = mock(User.class);
+ Comment comment = Comment.builder()
+ .id(commentId)
+ .content(originalContent)
+ .user(user)
+ .task(task)
+ .isDeleted(false)
+ .build();
+
+ given(taskRepository.findById(taskId)).willReturn(Optional.of(task));
+ given(commentRepository.findById(commentId)).willReturn(Optional.of(comment));
+ given(jwtUtil.extractId(httpServletRequest)).willReturn(userId);
+ given(userRepository.findById(userId)).willReturn(Optional.of(user));
+
+ // when
+ CommentResponseDataDto result = commentService.updateComment(taskId, commentId, updatedContent, httpServletRequest);
+
+ // then
+ assertThat(result.getContent()).isEqualTo(updatedContent);
+ assertThat(result.getUpdatedAt()).isNotNull();
+
+ }
+
+ @Test
+ @DisplayName("댓글이 삭제 되는지")
+ void deleteComment() {
+ // given
+ Long commentId = 1L;
+ LocalDateTime later = LocalDateTime.now().plusMinutes(1);
+ Comment comment = Comment.builder()
+ .id(commentId)
+ .isDeleted(false)
+ .build();
+ given(commentRepository.findById(commentId)).willReturn(Optional.of(comment));
+
+ // when
+ commentService.deleteComment(commentId);
+
+ // then
+ assertThat(comment.getIsDeleted()).isEqualTo(true);
+ assertThat(comment.getDeletedAt()).isBefore(later);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/example/onederful/domain/dashboard/repository/DashboardRepositoryTest.java b/src/test/java/com/example/onederful/domain/dashboard/repository/DashboardRepositoryTest.java
new file mode 100644
index 0000000..866e670
--- /dev/null
+++ b/src/test/java/com/example/onederful/domain/dashboard/repository/DashboardRepositoryTest.java
@@ -0,0 +1,240 @@
+package com.example.onederful.domain.dashboard.repository;
+
+import com.example.onederful.domain.dashboard.dto.StatisticsResponseDto;
+import com.example.onederful.domain.task.entity.Task;
+import com.example.onederful.domain.task.enums.Priority;
+import com.example.onederful.domain.task.enums.ProcessStatus;
+import com.example.onederful.domain.task.repository.TaskRepository;
+import com.example.onederful.domain.user.entity.User;
+import com.example.onederful.domain.user.enums.Role;
+import com.example.onederful.domain.user.repository.UserRepository;
+import jakarta.persistence.EntityManager;
+import jakarta.transaction.Transactional;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.as;
+import static org.assertj.core.api.Assertions.assertThat;
+
+
+import java.time.LocalDateTime;
+
+@DataJpaTest
+@ActiveProfiles("test")
+@Transactional
+public class DashboardRepositoryTest {
+
+ @Autowired
+ private EntityManager em;
+
+ @Autowired
+ private TaskRepository taskRepository;
+
+ @Autowired
+ private UserRepository userRepository;
+
+ private DashboardRepositoryImpl dashboardRepository;
+
+ private User user;
+
+ @Test
+ void 통계_정보_조회_성공(){
+ //given
+ dashboardRepository = new DashboardRepositoryImpl(em);
+
+ user = User.builder()
+ .username("iamgroot")
+ .email("iamgroot@example.com")
+ .password("Password123!")
+ .name("groot")
+ .role(Role.USER)
+ .build();
+
+ userRepository.save(user);
+
+ taskRepository.save(Task.builder()
+ .title("Task1")
+ .description("Task1 Content")
+ .priority(Priority.HIGH)
+ .assignee(user)
+ .user(user)
+ .status(ProcessStatus.TODO)
+ .createdAt(LocalDateTime.now())
+ .updatedAt(LocalDateTime.now())
+ .startedAt(LocalDateTime.now().plusDays(1))
+ .dueDate(LocalDateTime.now().plusDays(5))
+ .build()
+ );
+
+ taskRepository.save(Task.builder()
+ .title("Task2")
+ .description("Task2 Content")
+ .priority(Priority.MEDIUM)
+ .assignee(user)
+ .user(user)
+ .status(ProcessStatus.IN_PROGRESS)
+ .createdAt(LocalDateTime.now())
+ .updatedAt(LocalDateTime.now())
+ .startedAt(LocalDateTime.now())
+ .dueDate(LocalDateTime.now().minusDays(5))
+ .build()
+ );
+
+ taskRepository.save(Task.builder()
+ .title("Task3")
+ .description("Task3 Content")
+ .priority(Priority.HIGH)
+ .assignee(user)
+ .user(user)
+ .status(ProcessStatus.DONE)
+ .createdAt(LocalDateTime.now())
+ .updatedAt(LocalDateTime.now())
+ .startedAt(LocalDateTime.now().minusDays(1))
+ .dueDate(LocalDateTime.now().plusDays(5))
+ .build()
+ );
+
+ taskRepository.save(Task.builder()
+ .title("Task4")
+ .description("Task4 Content")
+ .priority(Priority.LOW)
+ .assignee(user)
+ .user(user)
+ .status(ProcessStatus.TODO)
+ .createdAt(LocalDateTime.now())
+ .updatedAt(LocalDateTime.now())
+ .startedAt(LocalDateTime.now().plusDays(1))
+ .dueDate(LocalDateTime.now().plusDays(5))
+ .build()
+ );
+
+ taskRepository.save(Task.builder()
+ .title("Task5")
+ .description("Task5 Content")
+ .priority(Priority.LOW)
+ .assignee(user)
+ .user(user)
+ .status(ProcessStatus.DONE)
+ .createdAt(LocalDateTime.now())
+ .updatedAt(LocalDateTime.now())
+ .startedAt(LocalDateTime.now().minusDays(2))
+ .dueDate(LocalDateTime.now().plusDays(5))
+ .build()
+ );
+ em.flush();
+ em.clear();
+
+ //when
+ StatisticsResponseDto result = dashboardRepository.getStatistics();
+
+ //then
+ assertThat(result.getTotalTaskCount()).isEqualTo(5);
+ assertThat(result.getTodoTaskCount()).isEqualTo(2);
+ assertThat(result.getInProgressTaskCount()).isEqualTo(1);
+ assertThat(result.getDoneTaskCount()).isEqualTo(2);
+ assertThat(result.getOverdueTaskCount()).isEqualTo(1);
+ assertThat(result.getTaskDoneRate()).isEqualTo(40.0);
+ }
+
+ @Test
+ void 오늘_내_태스크_조회(){
+ //given
+ dashboardRepository = new DashboardRepositoryImpl(em);
+
+ user = User.builder()
+ .username("iamgroot")
+ .email("iamgroot@example.com")
+ .password("Password123!")
+ .name("groot")
+ .role(Role.USER)
+ .build();
+
+ userRepository.save(user);
+
+ taskRepository.save(Task.builder()
+ .title("Task1")
+ .description("Task1 Content")
+ .priority(Priority.LOW)
+ .assignee(user)
+ .user(user)
+ .status(ProcessStatus.TODO)
+ .createdAt(LocalDateTime.now())
+ .updatedAt(LocalDateTime.now())
+ .startedAt(LocalDateTime.now())
+ .dueDate(LocalDateTime.now().plusDays(5))
+ .build()
+ );
+
+ taskRepository.save(Task.builder()
+ .title("Task2")
+ .description("Task2 Content")
+ .priority(Priority.HIGH)
+ .assignee(user)
+ .user(user)
+ .status(ProcessStatus.TODO)
+ .createdAt(LocalDateTime.now())
+ .updatedAt(LocalDateTime.now())
+ .startedAt(LocalDateTime.now())
+ .dueDate(LocalDateTime.now().plusDays(5))
+ .build()
+ );
+
+
+ taskRepository.save(Task.builder()
+ .title("Task3")
+ .description("Task3 Content")
+ .priority(Priority.MEDIUM)
+ .assignee(user)
+ .user(user)
+ .status(ProcessStatus.IN_PROGRESS)
+ .createdAt(LocalDateTime.now())
+ .updatedAt(LocalDateTime.now())
+ .startedAt(LocalDateTime.now())
+ .dueDate(LocalDateTime.now().minusDays(5))
+ .build()
+ );
+
+ taskRepository.save(Task.builder()
+ .title("Task4")
+ .description("Task4 Content")
+ .priority(Priority.HIGH)
+ .assignee(user)
+ .user(user)
+ .status(ProcessStatus.DONE)
+ .createdAt(LocalDateTime.now())
+ .updatedAt(LocalDateTime.now())
+ .startedAt(LocalDateTime.now().minusDays(1))
+ .dueDate(LocalDateTime.now().plusDays(5))
+ .build()
+ );
+
+ taskRepository.save(Task.builder()
+ .title("Task5")
+ .description("Task5 Content")
+ .priority(Priority.LOW)
+ .assignee(user)
+ .user(user)
+ .status(ProcessStatus.DONE)
+ .createdAt(LocalDateTime.now())
+ .updatedAt(LocalDateTime.now())
+ .startedAt(LocalDateTime.now().minusDays(2))
+ .dueDate(LocalDateTime.now().plusDays(5))
+ .build()
+ );
+
+ //when
+ List taskList = dashboardRepository.getMyTasksToday(user.getId());
+
+ //then
+ assertThat(taskList).hasSize(3);
+ assertThat(taskList.get(0).getPriority()).isEqualTo(Priority.HIGH);
+ assertThat(taskList.get(1).getPriority()).isEqualTo(Priority.MEDIUM);
+ assertThat(taskList.get(2).getPriority()).isEqualTo(Priority.LOW);
+
+ }
+
+}
diff --git a/src/test/java/com/example/onederful/domain/dashboard/service/DashboardServiceTest.java b/src/test/java/com/example/onederful/domain/dashboard/service/DashboardServiceTest.java
new file mode 100644
index 0000000..34cdb49
--- /dev/null
+++ b/src/test/java/com/example/onederful/domain/dashboard/service/DashboardServiceTest.java
@@ -0,0 +1,103 @@
+package com.example.onederful.domain.dashboard.service;
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.BDDMockito.*;
+
+import com.example.onederful.domain.dashboard.dto.MyTasksTodayResponseDto;
+import com.example.onederful.domain.dashboard.dto.StatisticsResponseDto;
+import com.example.onederful.domain.dashboard.repository.DashboardRepository;
+import com.example.onederful.domain.task.entity.Task;
+import com.example.onederful.domain.task.enums.Priority;
+import com.example.onederful.domain.task.enums.ProcessStatus;
+import com.example.onederful.domain.user.entity.User;
+import com.example.onederful.domain.user.enums.Role;
+import com.example.onederful.domain.user.repository.UserRepository;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Optional;
+import java.util.List;
+import java.time.LocalDateTime;
+
+
+@ExtendWith(MockitoExtension.class)
+public class DashboardServiceTest {
+ @InjectMocks
+ private DashboardService dashboardService;
+
+ @Mock
+ private DashboardRepository dashboardRepository;
+
+ @Mock
+ private UserRepository userRepository;
+
+ private User user;
+ private Task task;
+ @Test
+ void 통계_정보_조회_성공(){
+ //given
+ StatisticsResponseDto statisticsResponseDto =
+ StatisticsResponseDto.builder()
+ .totalTaskCount(5L)
+ .todoTaskCount(2L)
+ .inProgressTaskCount(1L)
+ .doneTaskCount(2L)
+ .taskDoneRate(40.0)
+ .overdueTaskCount(1L)
+ .build();
+
+ given(dashboardRepository.getStatistics()).willReturn(statisticsResponseDto);
+
+ //when
+ StatisticsResponseDto actualResult = dashboardService.getStatistics();
+
+ //then
+ assertThat(actualResult.getTotalTaskCount()).isEqualTo(5L);
+ assertThat(actualResult.getTodoTaskCount()).isEqualTo(2L);
+ assertThat(actualResult.getInProgressTaskCount()).isEqualTo(1L);
+ assertThat(actualResult.getDoneTaskCount()).isEqualTo(2L);
+ assertThat(actualResult.getTaskDoneRate()).isEqualTo(40.0);
+ }
+
+ @Test
+ void 오늘_내_태스크_조회_성공(){
+ //given
+
+ user = User.builder()
+ .username("iamgroot")
+ .email("iamgroot@example.com")
+ .password("Password123!")
+ .name("groot")
+ .role(Role.USER)
+ .build();
+
+ task = Task.builder()
+ .title("Task1")
+ .description("Task1 Content")
+ .priority(Priority.HIGH)
+ .assignee(user)
+ .user(user)
+ .status(ProcessStatus.TODO)
+ .createdAt(LocalDateTime.now())
+ .updatedAt(LocalDateTime.now())
+ .startedAt(LocalDateTime.now().plusDays(1))
+ .dueDate(LocalDateTime.now().plusDays(5))
+ .build();
+
+ given(userRepository.findById(1L)).willReturn(Optional.of(user));
+ given(dashboardRepository.getMyTasksToday(1L)).willReturn(List.of(task));
+
+ //when
+ List actualResult = dashboardService.getMyTasksToday(1L);
+
+ //then
+ assertThat(actualResult).hasSize(1);
+ MyTasksTodayResponseDto myTasksTodayResponseDto = actualResult.get(0);
+ assertThat(myTasksTodayResponseDto.getId()).isEqualTo(task.getId());
+ assertThat(myTasksTodayResponseDto.getContents()).isEqualTo(task.getDescription());
+ assertThat(myTasksTodayResponseDto.getManagerId()).isEqualTo(user.getId());
+
+ }
+}
diff --git a/src/test/java/com/example/onederful/domain/task/service/TaskServiceTest.java b/src/test/java/com/example/onederful/domain/task/service/TaskServiceTest.java
new file mode 100644
index 0000000..9ed2995
--- /dev/null
+++ b/src/test/java/com/example/onederful/domain/task/service/TaskServiceTest.java
@@ -0,0 +1,431 @@
+package com.example.onederful.domain.task.service;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.verify;
+
+import com.example.onederful.domain.task.dto.request.TaskSaveRequest;
+import com.example.onederful.domain.task.dto.request.TaskStatusUpdateRequest;
+import com.example.onederful.domain.task.entity.Task;
+import com.example.onederful.domain.task.enums.Priority;
+import com.example.onederful.domain.task.enums.ProcessStatus;
+import com.example.onederful.domain.task.repository.TaskRepository;
+import com.example.onederful.domain.user.entity.User;
+import com.example.onederful.domain.user.enums.Role;
+import com.example.onederful.domain.user.repository.UserRepository;
+import com.example.onederful.security.JwtUtil;
+import jakarta.servlet.http.HttpServletRequest;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.test.util.ReflectionTestUtils;
+
+@ExtendWith(MockitoExtension.class)
+public class TaskServiceTest {
+
+ @Mock
+ private TaskRepository taskRepository;
+
+ @Mock
+ private UserRepository userRepository;
+
+ @Mock
+ private JwtUtil jwtUtil;
+
+ @InjectMocks
+ private TaskService taskService;
+
+ @Test
+ @DisplayName("업무 생성이 성공한다.")
+ void 업무_생성_성공_테스트() {
+ //given
+ TaskSaveRequest request = TaskSaveRequest.builder()
+ .title("title")
+ .description("description")
+ .priority(Priority.LOW)
+ .assigneeId(1L)
+ .dueDate(LocalDateTime.of(2027, 4, 2, 23, 59, 59))
+ .build();
+
+ HttpServletRequest httpServletRequest = new MockHttpServletRequest();
+
+ Long userId = 1L;
+
+ User me = User.builder()
+ .id(userId)
+ .email("me@example.com")
+ .name("me1")
+ .password("!@A12345")
+ .role(Role.USER)
+ .username("me1")
+ .build();
+
+ User manager = User.builder()
+ .id(userId)
+ .email("manager@example.com")
+ .name("manager")
+ .password("!@A12345")
+ .role(Role.USER)
+ .username("manager")
+ .build();
+
+ Task task = Task.builder()
+ .id(2L)
+ .title(request.getTitle())
+ .description(request.getDescription())
+ .priority(request.getPriority())
+ .assignee(manager)
+ .user(me)
+ .status(ProcessStatus.TODO)
+ .dueDate(request.getDueDate())
+ .build();
+
+ ReflectionTestUtils.setField(task, "createdAt", LocalDateTime.now());
+ ReflectionTestUtils.setField(task, "updatedAt", LocalDateTime.now());
+
+ given(jwtUtil.extractId(any(HttpServletRequest.class))).willReturn(userId);
+ given(userRepository.findById(me.getId())).willReturn(Optional.of(me));
+ given(userRepository.findById(manager.getId())).willReturn(Optional.of(manager));
+ given(taskRepository.save(any(Task.class))).willReturn(task);
+
+ //when
+
+ taskService.createTask(request, httpServletRequest);
+
+ //then
+ Assertions.assertEquals(task.getTitle(), request.getTitle());
+ Assertions.assertEquals(task.getDescription(), request.getDescription());
+
+ verify(taskRepository).save(any(Task.class));
+ }
+
+ @Test
+ @DisplayName("업무 세부사항 조회가 성공한다.")
+ void 업무_조회_성공_테스트() {
+ //given
+ Long userId = 1L;
+
+ User me = User.builder()
+ .id(userId)
+ .email("me@example.com")
+ .name("me1")
+ .password("!@A12345")
+ .role(Role.USER)
+ .username("me1")
+ .build();
+
+ User manager = User.builder()
+ .id(userId)
+ .email("manager@example.com")
+ .name("manager")
+ .password("!@A12345")
+ .role(Role.USER)
+ .username("manager")
+ .build();
+
+ Task task = Task.builder()
+ .id(2L)
+ .title("test")
+ .description("description")
+ .priority(Priority.LOW)
+ .assignee(manager)
+ .user(me)
+ .status(ProcessStatus.TODO)
+ .dueDate(LocalDateTime.now())
+ .build();
+
+ ReflectionTestUtils.setField(task, "createdAt", LocalDateTime.now());
+ ReflectionTestUtils.setField(task, "updatedAt", LocalDateTime.now());
+
+ given(taskRepository.findById(anyLong())).willReturn(Optional.of(task));
+
+ //when
+
+ taskService.findTask(anyLong());
+
+ //then
+
+ Assertions.assertEquals("test", task.getTitle());
+ Assertions.assertEquals("description", task.getDescription());
+
+ verify(taskRepository).findById(anyLong());
+ }
+
+ @Test
+ @DisplayName("업무 세부사항 조회가 성공한다.")
+ void 업무_리스트_조회_성공_테스트() {
+ //given
+ Long userId = 1L;
+
+ User me = User.builder()
+ .id(userId)
+ .email("me@example.com")
+ .name("me1")
+ .password("!@A12345")
+ .role(Role.USER)
+ .username("me1")
+ .build();
+
+ User manager = User.builder()
+ .id(userId)
+ .email("manager@example.com")
+ .name("manager")
+ .password("!@A12345")
+ .role(Role.USER)
+ .username("manager")
+ .build();
+
+ Task task1 = Task.builder()
+ .id(2L)
+ .title("test1")
+ .description("description1")
+ .priority(Priority.LOW)
+ .assignee(manager)
+ .user(me)
+ .status(ProcessStatus.TODO)
+ .dueDate(LocalDateTime.now())
+ .build();
+
+ Task task2 = Task.builder()
+ .id(2L)
+ .title("test2")
+ .description("description2")
+ .priority(Priority.LOW)
+ .assignee(manager)
+ .user(me)
+ .status(ProcessStatus.TODO)
+ .dueDate(LocalDateTime.now())
+ .build();
+
+ ReflectionTestUtils.setField(task1, "createdAt", LocalDateTime.now());
+ ReflectionTestUtils.setField(task1, "updatedAt", LocalDateTime.now());
+ ReflectionTestUtils.setField(task2, "createdAt", LocalDateTime.now());
+ ReflectionTestUtils.setField(task2, "updatedAt", LocalDateTime.now());
+
+ List list = List.of(task1, task2);
+ Page tasks = new PageImpl<>(list);
+
+ Pageable pageable = PageRequest.of(0, 10);
+ String search = "test";
+ ProcessStatus status = ProcessStatus.TODO;
+
+ given(taskRepository.findTasks(search, status, pageable)).willReturn(tasks);
+
+ //when
+
+ taskService.findTasks(pageable, search, status);
+
+ //then
+ Assertions.assertEquals(2, tasks.getTotalElements());
+ Assertions.assertEquals(1, tasks.getTotalPages());
+
+ verify(taskRepository).findTasks(search, status, pageable);
+ }
+
+ @Test
+ @DisplayName("업무 삭제가 성공한다.")
+ void 업무_삭제_성공_테스트() {
+ //given
+ Long userId = 1L;
+
+ User me = User.builder()
+ .id(userId)
+ .email("me@example.com")
+ .name("me1")
+ .password("!@A12345")
+ .role(Role.USER)
+ .username("me1")
+ .build();
+
+ User manager = User.builder()
+ .id(userId)
+ .email("manager@example.com")
+ .name("manager")
+ .password("!@A12345")
+ .role(Role.USER)
+ .username("manager")
+ .build();
+
+ Task task = Task.builder()
+ .id(2L)
+ .title("test")
+ .description("description")
+ .priority(Priority.LOW)
+ .assignee(manager)
+ .user(me)
+ .status(ProcessStatus.TODO)
+ .dueDate(LocalDateTime.now())
+ .build();
+
+ ReflectionTestUtils.setField(task, "createdAt", LocalDateTime.now());
+ ReflectionTestUtils.setField(task, "updatedAt", LocalDateTime.now());
+
+ given(taskRepository.findById(anyLong())).willReturn(Optional.of(task));
+
+ //when
+
+ taskService.deleteTask(task.getId());
+
+ //then
+
+ Assertions.assertTrue(task.getIsDeleted());
+ }
+
+ @Test
+ @DisplayName("업무 수정이 성공한다.")
+ void 업무_수정_성공_테스트() {
+ //given
+
+ Long id = 1L;
+
+ TaskSaveRequest request = TaskSaveRequest.builder()
+ .title("title")
+ .description("description")
+ .priority(Priority.LOW)
+ .assigneeId(1L)
+ .status(ProcessStatus.IN_PROGRESS)
+ .dueDate(LocalDateTime.of(2027, 4, 2, 23, 59, 59))
+ .build();
+
+ Long userId = 1L;
+
+ User me = User.builder()
+ .id(userId)
+ .email("me@example.com")
+ .name("me1")
+ .password("!@A12345")
+ .role(Role.USER)
+ .username("me1")
+ .build();
+
+ User manager = User.builder()
+ .id(request.getAssigneeId())
+ .email("manager@example.com")
+ .name("manager")
+ .password("!@A12345")
+ .role(Role.USER)
+ .username("manager")
+ .build();
+
+ Task task = Task.builder()
+ .id(2L)
+ .title("test")
+ .description("description")
+ .priority(Priority.LOW)
+ .assignee(me)
+ .user(me)
+ .status(ProcessStatus.TODO)
+ .dueDate(LocalDateTime.now())
+ .build();
+
+ ReflectionTestUtils.setField(task, "createdAt", LocalDateTime.now());
+ ReflectionTestUtils.setField(task, "updatedAt", LocalDateTime.now());
+
+ given(taskRepository.findById(anyLong())).willReturn(Optional.of(task));
+ given(userRepository.findById(anyLong())).willReturn(Optional.of(manager));
+
+ //when
+
+ taskService.updateTask(id, request);
+
+ //then
+
+ Assertions.assertEquals(task.getAssignee(), manager);
+ Assertions.assertEquals(ProcessStatus.IN_PROGRESS, task.getStatus());
+ }
+
+ @Test
+ @DisplayName("업무 상태 변경이 성공한다.")
+ void 업무_상태_변경_성공_테스트() {
+ //given
+
+ Long id = 1L;
+
+ TaskStatusUpdateRequest request = new TaskStatusUpdateRequest(ProcessStatus.IN_PROGRESS);
+
+ Long userId = 1L;
+
+ User me = User.builder()
+ .id(userId)
+ .email("me@example.com")
+ .name("me1")
+ .password("!@A12345")
+ .role(Role.USER)
+ .username("me1")
+ .build();
+
+ Task task = Task.builder()
+ .id(2L)
+ .title("test")
+ .description("description")
+ .priority(Priority.LOW)
+ .assignee(me)
+ .user(me)
+ .status(ProcessStatus.TODO)
+ .dueDate(LocalDateTime.now())
+ .build();
+
+ ReflectionTestUtils.setField(task, "createdAt", LocalDateTime.now());
+ ReflectionTestUtils.setField(task, "updatedAt", LocalDateTime.now());
+
+ given(taskRepository.findById(anyLong())).willReturn(Optional.of(task));
+
+ //when
+
+ taskService.updateTaskStatus(id, request);
+
+ //then
+
+ Assertions.assertEquals(ProcessStatus.IN_PROGRESS, task.getStatus());
+ }
+
+ @Test
+ @DisplayName("기본 조회가 성공한다.")
+ void 기본_조회_성공_테스트() {
+ Long id = 1L;
+
+ Long userId = 1L;
+
+ User me = User.builder()
+ .id(userId)
+ .email("me@example.com")
+ .name("me1")
+ .password("!@A12345")
+ .role(Role.USER)
+ .username("me1")
+ .build();
+
+ Task task = Task.builder()
+ .id(2L)
+ .title("test")
+ .description("description")
+ .priority(Priority.LOW)
+ .assignee(me)
+ .user(me)
+ .status(ProcessStatus.TODO)
+ .dueDate(LocalDateTime.now())
+ .build();
+
+ ReflectionTestUtils.setField(task, "createdAt", LocalDateTime.now());
+ ReflectionTestUtils.setField(task, "updatedAt", LocalDateTime.now());
+
+ given(taskRepository.findById(anyLong())).willReturn(Optional.of(task));
+
+ taskService.findById(id);
+
+ verify(taskRepository).findById(anyLong());
+ }
+}
diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml
new file mode 100644
index 0000000..9609027
--- /dev/null
+++ b/src/test/resources/application-test.yml
@@ -0,0 +1,20 @@
+spring:
+ datasource:
+ url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false
+ driver-class-name: org.h2.Driver
+ username: test
+ password:
+
+ jpa:
+ show-sql: true
+ hibernate:
+ ddl-auto: create-drop
+ database-platform: org.hibernate.dialect.H2Dialect
+ properties:
+ hibernate:
+ format_sql: true
+ show_sql: true
+ use_sql_comment: true
+ highlight_sql: true
+
+