Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e3fdba0
feat: 순공 시간 및 타이머 DB 스키마 작성
dh2906 Dec 31, 2025
714e3f8
feat: 순공 시간 관련 엔티티 생성
dh2906 Dec 31, 2025
30c29c0
refactor: 도메인 패키지 명 수정
dh2906 Dec 31, 2025
ae546b1
feat: 순공 시간 타이머 API 명세 작성
dh2906 Dec 31, 2025
c121fa5
feat: 순공 시간 타이머 API 구현
dh2906 Dec 31, 2025
385cb7f
refactor: 스터디 타이머 종료 응답 매핑을 서비스로 이동
dh2906 Dec 31, 2025
d447ccc
fix: 클래스 명, 엔드포인트 수정
dh2906 Dec 31, 2025
98703b7
feat: 순공 시간 조회 API 명세 작성
dh2906 Dec 31, 2025
8ef452f
feat: 순공 시간 조회 API 구현
dh2906 Dec 31, 2025
0b3bab3
fix: 내 정보 조회 시 임시로 반환하는 순공 시간 수정
dh2906 Dec 31, 2025
6503af6
fix: 공부 시간 자리수 컨벤션 정리
dh2906 Dec 31, 2025
3e47d95
refactor: 매직넘버 상수화
dh2906 Dec 31, 2025
60913b4
chore: 충돌 해결하면서 잘못 합친 부분 수정
dh2906 Jan 2, 2026
9b6812c
feat: 컨트롤러 구현
dh2906 Jan 2, 2026
6986b39
refactor: 공부 시간 응답 포맷 변경
dh2906 Jan 2, 2026
b283f89
refactor: 타이머 종료 시 누적 초를 요청으로 받도록 를정
dh2906 Jan 2, 2026
3eed62b
chore: 명세 수정
dh2906 Jan 2, 2026
94efaf1
chore: schema.sql 삭제
dh2906 Jan 2, 2026
bd839c0
refactor: 공부 시간 조회 시 월간 정보도 포함
dh2906 Jan 2, 2026
750fb8e
fix: 내 정보 조회 시 반환하는 순공 시간 포맷을 "HH:mm"으로 변경
dh2906 Jan 2, 2026
197d385
refactor: 중복되는 로직 제거
dh2906 Jan 2, 2026
1ca1b29
refactor: today -> daily 네이밍 수정
dh2906 Jan 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;

import gg.agit.konect.domain.studytime.dto.StudyTimeSummaryResponse;
import gg.agit.konect.domain.studytime.dto.StudyTimerStopRequest;
import gg.agit.konect.domain.studytime.dto.StudyTimerStopResponse;
import gg.agit.konect.global.auth.annotation.UserId;
Expand All @@ -17,6 +19,10 @@
@RequestMapping("/studytimes")
public interface StudyTimeApi {

@Operation(summary = "순공 시간(일간, 월간, 통합)을 조회한다.")
@GetMapping("/summary")
ResponseEntity<StudyTimeSummaryResponse> getSummary(@UserId Integer userId);

@Operation(summary = "스터디 타이머를 시작한다.", description = """
## 설명
- 스터디 타이머를 시작합니다.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
package gg.agit.konect.domain.studytime.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import gg.agit.konect.domain.studytime.dto.StudyTimeSummaryResponse;
import gg.agit.konect.domain.studytime.dto.StudyTimerStopRequest;
import gg.agit.konect.domain.studytime.dto.StudyTimerStopResponse;
import gg.agit.konect.domain.studytime.service.StudyTimeQueryService;
import gg.agit.konect.domain.studytime.service.StudyTimerService;
import gg.agit.konect.global.auth.annotation.UserId;
import jakarta.validation.Valid;
Expand All @@ -20,15 +20,23 @@
public class StudyTimeController implements StudyTimeApi {

private final StudyTimerService studyTimerService;
private final StudyTimeQueryService studyTimeQueryService;

@PostMapping("/timers")
@Override
public ResponseEntity<StudyTimeSummaryResponse> getSummary(@UserId Integer userId) {
StudyTimeSummaryResponse response = studyTimeQueryService.getSummary(userId);

return ResponseEntity.ok(response);
}

@Override
public ResponseEntity<Void> start(@UserId Integer userId) {
studyTimerService.start(userId);

return ResponseEntity.ok().build();
}

@DeleteMapping("/timers")
@Override
public ResponseEntity<StudyTimerStopResponse> stop(
@UserId Integer userId,
@RequestBody @Valid StudyTimerStopRequest request
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package gg.agit.konect.domain.studytime.dto;

import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;

import io.swagger.v3.oas.annotations.media.Schema;

public record StudyTimeSummaryResponse(
@Schema(description = "오늘 누적 공부 시간(누적 초)", example = "45296", requiredMode = REQUIRED)
Long todayStudyTime,

@Schema(description = "월간 누적 공부 시간(누적 초)", example = "334510", requiredMode = REQUIRED)
Long monthlyStudyTime,

@Schema(description = "총 누적 공부 시간(누적 초)", example = "564325", requiredMode = REQUIRED)
Long totalStudyTime
) {
public static StudyTimeSummaryResponse of(Long todayStudyTime, Long monthlyStudyTime, Long totalStudyTime) {
return new StudyTimeSummaryResponse(todayStudyTime, monthlyStudyTime, totalStudyTime);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,13 @@
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;

public record StudyTimerStopRequest(
@NotNull(message = "시간(hour)은 필수 입력입니다.")
@Min(value = 0, message = "시간은 0 이상이어야 합니다.")
@Schema(description = "타이머 시간 - 시간", example = "1", requiredMode = REQUIRED)
Integer hour,

@NotNull(message = "분(minute)은 필수 입력입니다.")
@Min(value = 0, message = "분은 0 이상이어야 합니다.")
@Max(value = 59, message = "분은 59 이하여야 합니다.")
@Schema(description = "타이머 시간 - 분", example = "30", requiredMode = REQUIRED)
Integer minute,

@NotNull(message = "초(second)는 필수 입력입니다.")
@Min(value = 0, message = "초는 0 이상이어야 합니다.")
@Max(value = 59, message = "초는 59 이하여야 합니다.")
@Schema(description = "타이머 시간 - 초", example = "15", requiredMode = REQUIRED)
Integer second
@NotNull(message = "누적 초(totalSeconds)는 필수 입력입니다.")
@Min(value = 0, message = "누적 초는 0 이상이어야 합니다.")
@Schema(description = "타이머 누적 시간(초)", example = "5415", requiredMode = REQUIRED)
Long totalSeconds
) {

private static final long SECONDS_PER_MINUTE = 60L;
private static final long SECONDS_PER_HOUR = 3600L;

public long toTotalSeconds() {
return hour * SECONDS_PER_HOUR + minute * SECONDS_PER_MINUTE + second;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package gg.agit.konect.domain.studytime.model;

public record StudyTimeAggregate(
long sessionSeconds,
long dailySeconds,
long monthlySeconds,
long totalSeconds
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package gg.agit.konect.domain.studytime.service;

import java.time.LocalDate;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import gg.agit.konect.domain.studytime.dto.StudyTimeSummaryResponse;
import gg.agit.konect.domain.studytime.model.StudyTimeDaily;
import gg.agit.konect.domain.studytime.model.StudyTimeMonthly;
import gg.agit.konect.domain.studytime.model.StudyTimeTotal;
import gg.agit.konect.domain.studytime.repository.StudyTimeDailyRepository;
import gg.agit.konect.domain.studytime.repository.StudyTimeMonthlyRepository;
import gg.agit.konect.domain.studytime.repository.StudyTimeTotalRepository;
import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class StudyTimeQueryService {

private final StudyTimeDailyRepository studyTimeDailyRepository;
private final StudyTimeMonthlyRepository studyTimeMonthlyRepository;
private final StudyTimeTotalRepository studyTimeTotalRepository;

public StudyTimeSummaryResponse getSummary(Integer userId) {
Long dailyStudyTime = getDailyStudyTime(userId);
Long monthlyStudyTime = getMonthlyStudyTime(userId);
Long totalStudyTime = getTotalStudyTime(userId);

return StudyTimeSummaryResponse.of(dailyStudyTime, monthlyStudyTime, totalStudyTime);
}

public long getTotalStudyTime(Integer userId) {
return studyTimeTotalRepository.findByUserId(userId)
.map(StudyTimeTotal::getTotalSeconds)
.orElse(0L);
}

public long getDailyStudyTime(Integer userId) {
LocalDate today = LocalDate.now();

return studyTimeDailyRepository.findByUserIdAndStudyDate(userId, today)
.map(StudyTimeDaily::getTotalSeconds)
.orElse(0L);
}

public long getMonthlyStudyTime(Integer userId) {
LocalDate month = LocalDate.now().withDayOfMonth(1);

return studyTimeMonthlyRepository.findByUserIdAndStudyMonth(userId, month)
.map(StudyTimeMonthly::getTotalSeconds)
.orElse(0L);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public class StudyTimerService {

private static final long TIMER_MISMATCH_THRESHOLD_SECONDS = 60L;

private final StudyTimeQueryService studyTimeQueryService;
private final StudyTimerRepository studyTimerRepository;
private final StudyTimeDailyRepository studyTimeDailyRepository;
private final StudyTimeMonthlyRepository studyTimeMonthlyRepository;
Expand Down Expand Up @@ -66,7 +67,7 @@ public StudyTimerStopResponse stop(Integer userId, StudyTimerStopRequest request
LocalDateTime endedAt = LocalDateTime.now();
LocalDateTime startedAt = studyTimer.getStartedAt();
long serverSeconds = Duration.between(startedAt, endedAt).getSeconds();
long clientSeconds = request.toTotalSeconds();
long clientSeconds = request.totalSeconds();

if (isElapsedTimeInvalid(serverSeconds, clientSeconds)) {
studyTimerRepository.delete(studyTimer);
Expand All @@ -75,7 +76,7 @@ public StudyTimerStopResponse stop(Integer userId, StudyTimerStopRequest request

long sessionSeconds = accumulateStudyTime(studyTimer.getUser(), startedAt, endedAt);
studyTimerRepository.delete(studyTimer);
StudyTimeSummary summary = buildSummary(userId, endedAt.toLocalDate(), sessionSeconds);
StudyTimeSummary summary = buildSummary(userId, sessionSeconds);

return StudyTimerStopResponse.from(summary);
}
Expand Down Expand Up @@ -160,20 +161,10 @@ private void addTotalSeconds(User user, long seconds) {
studyTimeTotalRepository.save(total);
}

private StudyTimeSummary buildSummary(Integer userId, LocalDate endDate, long sessionSeconds) {
LocalDate month = endDate.withDayOfMonth(1);

long dailySeconds = studyTimeDailyRepository.findByUserIdAndStudyDate(userId, endDate)
.map(StudyTimeDaily::getTotalSeconds)
.orElse(0L);

long monthlySeconds = studyTimeMonthlyRepository.findByUserIdAndStudyMonth(userId, month)
.map(StudyTimeMonthly::getTotalSeconds)
.orElse(0L);

long totalSeconds = studyTimeTotalRepository.findByUserId(userId)
.map(StudyTimeTotal::getTotalSeconds)
.orElse(0L);
private StudyTimeSummary buildSummary(Integer userId, long sessionSeconds) {
long dailySeconds = studyTimeQueryService.getDailyStudyTime(userId);
long monthlySeconds = studyTimeQueryService.getMonthlyStudyTime(userId);
long totalSeconds = studyTimeQueryService.getTotalStudyTime(userId);

return new StudyTimeSummary(sessionSeconds, dailySeconds, monthlySeconds, totalSeconds);
}
Expand Down
28 changes: 19 additions & 9 deletions src/main/java/gg/agit/konect/domain/user/dto/UserInfoResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,6 @@
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;

import java.time.LocalTime;

import com.fasterxml.jackson.annotation.JsonFormat;

import gg.agit.konect.domain.user.model.User;
import io.swagger.v3.oas.annotations.media.Schema;

Expand All @@ -32,15 +28,22 @@ public record UserInfoResponse(
@Schema(description = "가입 동아리 개수", example = "1", requiredMode = REQUIRED)
Integer joinedClubCount,

@Schema(description = "순공 시간", example = "13:13", requiredMode = REQUIRED)
@JsonFormat(pattern = "HH:mm")
LocalTime studyTime,
@Schema(description = "순공 시간(HH:mm)", example = "12:34", requiredMode = REQUIRED)
String studyTime,

@Schema(description = "읽지 않은 총 동아리 연합회 공지", example = "1", requiredMode = REQUIRED)
Long unreadCouncilNoticeCount
) {

public static UserInfoResponse from(User user, Integer joinedClubCount, Long unreadCouncilNoticeCount) {
private static final long SECONDS_PER_HOUR = 3600;
private static final long SECONDS_PER_MINUTE = 60;

public static UserInfoResponse from(
User user,
Integer joinedClubCount,
Long studyTime,
Long unreadCouncilNoticeCount
) {
return new UserInfoResponse(
user.getName(),
user.getUniversity().getKoreanName(),
Expand All @@ -49,8 +52,15 @@ public static UserInfoResponse from(User user, Integer joinedClubCount, Long unr
user.getEmail(),
user.getImageUrl(),
joinedClubCount,
LocalTime.of(0, 0, 0),
formatSecondsToHHmm(studyTime),
unreadCouncilNoticeCount
);
}

private static String formatSecondsToHHmm(Long seconds) {
long h = seconds / SECONDS_PER_HOUR;
long m = (seconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE;

return String.format("%02d:%02d", h, m);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import gg.agit.konect.domain.club.repository.ClubApplyRepository;
import gg.agit.konect.domain.club.repository.ClubMemberRepository;
import gg.agit.konect.domain.notice.repository.CouncilNoticeReadRepository;
import gg.agit.konect.domain.studytime.service.StudyTimeQueryService;
import gg.agit.konect.domain.university.model.University;
import gg.agit.konect.domain.university.repository.UniversityRepository;
import gg.agit.konect.domain.user.dto.SignupRequest;
Expand Down Expand Up @@ -41,6 +42,7 @@ public class UserService {
private final ClubApplyRepository clubApplyRepository;
private final ChatMessageRepository chatMessageRepository;
private final ChatRoomRepository chatRoomRepository;
private final StudyTimeQueryService studyTimeQueryService;

@Transactional
public Integer signup(String email, Provider provider, SignupRequest request) {
Expand Down Expand Up @@ -79,8 +81,9 @@ public UserInfoResponse getUserInfo(Integer userId) {
User user = userRepository.getById(userId);
int joinedClubCount = clubMemberRepository.findAllByUserId(user.getId()).size();
Long unreadCouncilNoticeCount = councilNoticeReadRepository.countUnreadNoticesByUserId(user.getId());
Long studyTime = studyTimeQueryService.getTotalStudyTime(userId);

return UserInfoResponse.from(user, joinedClubCount, unreadCouncilNoticeCount);
return UserInfoResponse.from(user, joinedClubCount, studyTime, unreadCouncilNoticeCount);
}

@Transactional
Expand Down