diff --git a/.gitignore b/.gitignore index 8df78b67..8dd0c926 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ HELP.md +docs/ .gradle build/ !gradle/wrapper/gradle-wrapper.jar diff --git a/build.gradle b/build.gradle index efead293..331b7e75 100644 --- a/build.gradle +++ b/build.gradle @@ -67,6 +67,7 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' + testRuntimeOnly 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/com/aisip/OnO/backend/admin/controller/AdminAnalysisController.java b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminAnalysisController.java index aa566a11..f55e1908 100644 --- a/src/main/java/com/aisip/OnO/backend/admin/controller/AdminAnalysisController.java +++ b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminAnalysisController.java @@ -92,6 +92,7 @@ public String getAllAnalysis( long periodProcessingAnalysisCount = periodAnalysisStatusCounts.getOrDefault(AnalysisStatus.PROCESSING, 0L); long periodNotStartedAnalysisCount = periodAnalysisStatusCounts.getOrDefault(AnalysisStatus.NOT_STARTED, 0L); long periodNoImageAnalysisCount = periodAnalysisStatusCounts.getOrDefault(AnalysisStatus.NO_IMAGE, 0L); + long periodRateLimitExceededAnalysisCount = periodAnalysisStatusCounts.getOrDefault(AnalysisStatus.RATE_LIMIT_EXCEEDED, 0L); long periodFinishedAnalysisCount = periodCompletedAnalysisCount + periodFailedAnalysisCount; double periodAnalysisFailureRate = periodFinishedAnalysisCount == 0 ? 0.0 @@ -112,6 +113,7 @@ public String getAllAnalysis( model.addAttribute("allProcessingAnalysisCount", allAnalysisStatusCounts.getOrDefault(AnalysisStatus.PROCESSING, 0L)); model.addAttribute("allNotStartedAnalysisCount", allAnalysisStatusCounts.getOrDefault(AnalysisStatus.NOT_STARTED, 0L)); model.addAttribute("allNoImageAnalysisCount", allAnalysisStatusCounts.getOrDefault(AnalysisStatus.NO_IMAGE, 0L)); + model.addAttribute("allRateLimitExceededAnalysisCount", allAnalysisStatusCounts.getOrDefault(AnalysisStatus.RATE_LIMIT_EXCEEDED, 0L)); model.addAttribute("dailyActiveUsers", dailyActiveUsers); model.addAttribute("dailyVisits", dailyVisits); model.addAttribute("dailyNewUsers", dailyNewUsers); @@ -130,6 +132,7 @@ public String getAllAnalysis( model.addAttribute("periodProcessingAnalysisCount", periodProcessingAnalysisCount); model.addAttribute("periodNotStartedAnalysisCount", periodNotStartedAnalysisCount); model.addAttribute("periodNoImageAnalysisCount", periodNoImageAnalysisCount); + model.addAttribute("periodRateLimitExceededAnalysisCount", periodRateLimitExceededAnalysisCount); model.addAttribute("periodAnalysisFailureRate", periodAnalysisFailureRate); model.addAttribute("averageDailyVisitors", averageDailyVisitors); model.addAttribute("startDate", selectedStartDate); diff --git a/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitAspect.java b/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitAspect.java index 95b3a6b5..d4612dea 100644 --- a/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitAspect.java +++ b/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitAspect.java @@ -3,44 +3,23 @@ import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.problem.exception.ProblemErrorCase; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; -import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; -import java.time.Duration; - @Aspect @Component @RequiredArgsConstructor -@Slf4j public class RateLimitAspect { - private static final String KEY_PREFIX = "rate_limit:"; - - private final RedisTemplate redisTemplate; + private final RateLimitService rateLimitService; @Before("@annotation(rateLimit)") public void checkRateLimit(RateLimit rateLimit) { Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - String key = KEY_PREFIX + rateLimit.key() + ":" + userId; - - try { - Long count = redisTemplate.opsForValue().increment(key); - if (count != null && count == 1L) { - redisTemplate.expire(key, Duration.ofDays(1)); - } - if (count != null && count > rateLimit.limitPerDay()) { - log.warn("Rate limit exceeded - userId: {}, key: {}, count: {}/{}", userId, rateLimit.key(), count, rateLimit.limitPerDay()); - throw new ApplicationException(ProblemErrorCase.ANALYSIS_RATE_LIMIT_EXCEEDED); - } - } catch (ApplicationException e) { - throw e; - } catch (Exception e) { - // Redis 장애 시 서비스 중단 방지를 위해 통과 - log.warn("Rate limit check failed for key: {}, failing open - reason: {}", key, e.getMessage()); + if (!rateLimitService.tryConsume(rateLimit.key(), userId, rateLimit.limitPerDay())) { + throw new ApplicationException(ProblemErrorCase.ANALYSIS_RATE_LIMIT_EXCEEDED); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitService.java b/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitService.java new file mode 100644 index 00000000..6a344f3c --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitService.java @@ -0,0 +1,38 @@ +package com.aisip.OnO.backend.common.ratelimit; + +import java.time.Duration; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RateLimitService { + private static final String KEY_PREFIX = "rate_limit:"; + + private final RedisTemplate redisTemplate; + + public boolean tryConsume(String keyName, Long userId, int limitPerDay) { + String key = KEY_PREFIX + keyName + ":" + userId; + + try { + Long count = redisTemplate.opsForValue().increment(key); + if (count != null && count == 1L) { + redisTemplate.expire(key, Duration.ofDays(1)); + } + if (count != null && count > limitPerDay) { + log.warn("Rate limit exceeded - userId: {}, key: {}, count: {}/{}", + userId, keyName, count, limitPerDay); + return false; + } + return true; + } catch (Exception e) { + // Redis 장애 시 서비스 중단 방지를 위해 통과 + log.warn("Rate limit check failed for key: {}, failing open - reason: {}", key, e.getMessage()); + return true; + } + } +} diff --git a/src/main/java/com/aisip/OnO/backend/config/P6SpyConfig.java b/src/main/java/com/aisip/OnO/backend/config/P6SpyConfig.java deleted file mode 100644 index b0bad699..00000000 --- a/src/main/java/com/aisip/OnO/backend/config/P6SpyConfig.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.aisip.OnO.backend.config; - -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Configuration; - -import javax.sql.DataSource; - -@Slf4j -@Configuration -@RequiredArgsConstructor -public class P6SpyConfig { - - @Value("${spring.profiles.active:local}") - private String activeProfile; - - private final DataSource dataSource; - - @PostConstruct - public void setExplainEnabled() { - // 로컬 환경에서만 EXPLAIN 활성화 - boolean enableExplain = "local".equals(activeProfile); - System.setProperty("p6spy.enable.explain", String.valueOf(enableExplain)); - - log.info("P6Spy EXPLAIN enabled: {} (active profile: {})", enableExplain, activeProfile); - - // DataSource를 P6SpyExplainAppender에 전달 - P6SpyExplainAppender.setDataSource(dataSource); - log.info("P6Spy DataSource configured"); - } -} diff --git a/src/main/java/com/aisip/OnO/backend/config/P6SpyExplainAppender.java b/src/main/java/com/aisip/OnO/backend/config/P6SpyExplainAppender.java index 40ed9e82..9696c158 100644 --- a/src/main/java/com/aisip/OnO/backend/config/P6SpyExplainAppender.java +++ b/src/main/java/com/aisip/OnO/backend/config/P6SpyExplainAppender.java @@ -1,152 +1,32 @@ package com.aisip.OnO.backend.config; import com.p6spy.engine.spy.appender.MessageFormattingStrategy; -import lombok.extern.slf4j.Slf4j; -import org.hibernate.engine.jdbc.internal.FormatStyle; -import javax.sql.DataSource; -import java.sql.Connection; -import java.sql.ResultSet; -import java.sql.Statement; import java.util.Locale; -@Slf4j public class P6SpyExplainAppender implements MessageFormattingStrategy { - // DataSource를 static으로 저장 - private static DataSource dataSource; - - // P6SpyConfig에서 호출 - public static void setDataSource(DataSource ds) { - dataSource = ds; - } - - // 시스템 프로퍼티로 제어 (P6SpyConfig에서 설정) - private static boolean isExplainEnabled() { - return Boolean.parseBoolean(System.getProperty("p6spy.enable.explain", "false")); - } - @Override public String formatMessage(int connectionId, String now, long elapsed, String category, String prepared, String sql, String url) { - - // Quartz 관련 쿼리 제외 - if (sql == null || sql.trim().isEmpty() || sql.contains("QRTZ_")) { + if (sql == null || sql.trim().isEmpty() || containsQuartzTable(sql)) { return ""; } - StringBuilder result = new StringBuilder(); - - // 기본 로그 출력 - String formattedSql = formatSql(category, sql); - result.append(String.format("[P6Spy][SLOW_QUERY] | %s | took %dms | %s | connection %d\n%s\n", - now, elapsed, category, connectionId, formattedSql)); - - // EXPLAIN 실행 조건 체크 - boolean explainEnabled = isExplainEnabled(); - boolean isSelect = sql.trim().toLowerCase(Locale.ROOT).startsWith("select"); - - boolean shouldExplain = explainEnabled && isSelect; - - if (shouldExplain) { - try { - String explainResult = executeExplain(sql, url); - result.append("\n"); - result.append("╔════════════════════════════════════════════════════════════════════════════════╗\n"); - result.append("║ EXPLAIN RESULT (took " + elapsed + "ms) ║\n"); - result.append("╠════════════════════════════════════════════════════════════════════════════════╣\n"); - result.append("║ Query: ").append(String.format("%-70s", summarizeSql(sql))).append(" ║\n"); - result.append("╠════════════════════════════════════════════════════════════════════════════════╣\n"); - result.append(explainResult); - result.append("╚════════════════════════════════════════════════════════════════════════════════╝\n"); - } catch (Exception e) { - log.warn("Failed to execute EXPLAIN for query: {}", e.getMessage()); - } - } - - return result.toString(); + String explainSql = toExplainSql(sql); + return String.format("[P6Spy][SLOW_QUERY] took=%dms category=%s connection=%d sql=%s", + elapsed, category, connectionId, explainSql); } - private String formatSql(String category, String sql) { - if (sql == null || sql.trim().isEmpty()) { - return ""; - } - - if (category.equals("statement") && sql.trim().toLowerCase(Locale.ROOT).startsWith("select")) { - return FormatStyle.BASIC.getFormatter().format(sql); - } - - return sql; + private boolean containsQuartzTable(String sql) { + return sql.toUpperCase(Locale.ROOT).contains("QRTZ_"); } - private String summarizeSql(String sql) { + private String toExplainSql(String sql) { String normalizedSql = sql.replaceAll("\\s+", " ").trim(); - return normalizedSql.substring(0, Math.min(70, normalizedSql.length())); - } - - private String executeExplain(String sql, String jdbcUrl) throws Exception { - if (dataSource == null) { - return "DataSource not available"; + if (normalizedSql.endsWith(";")) { + normalizedSql = normalizedSql.substring(0, normalizedSql.length() - 1).trim(); } - - StringBuilder result = new StringBuilder(); - - try (Connection conn = dataSource.getConnection(); - Statement stmt = conn.createStatement(); - ResultSet rs = stmt.executeQuery("EXPLAIN " + sql)) { - - // 컬럼별 너비 설정 (보기 좋게 조정) - String[] columnNames = new String[rs.getMetaData().getColumnCount()]; - int[] columnWidths = new int[columnNames.length]; - - for (int i = 0; i < columnNames.length; i++) { - columnNames[i] = rs.getMetaData().getColumnName(i + 1); - // 컬럼별 최적 너비 - columnWidths[i] = getOptimalWidth(columnNames[i]); - } - - // 헤더 출력 (세로 형식) - result.append("\n"); - - // 데이터 읽기 - java.util.List rows = new java.util.ArrayList<>(); - while (rs.next()) { - String[] row = new String[columnNames.length]; - for (int i = 0; i < columnNames.length; i++) { - row[i] = rs.getString(i + 1); - if (row[i] == null) row[i] = "NULL"; - } - rows.add(row); - } - - // 가독성 좋은 세로 형식으로 출력 - for (String[] row : rows) { - for (int i = 0; i < columnNames.length; i++) { - result.append(String.format(" %-20s: %s\n", columnNames[i], row[i])); - } - result.append("\n"); - } - } - - return result.toString(); - } - - private int getOptimalWidth(String columnName) { - // 컬럼별 최적 너비 설정 - return switch (columnName.toLowerCase()) { - case "id" -> 5; - case "select_type" -> 12; - case "table" -> 15; - case "partitions" -> 12; - case "type" -> 10; - case "possible_keys" -> 25; - case "key" -> 25; - case "key_len" -> 10; - case "ref" -> 15; - case "rows" -> 10; - case "filtered" -> 10; - case "extra" -> 30; - default -> 15; - }; + return "EXPLAIN " + normalizedSql + ";"; } } diff --git a/src/main/java/com/aisip/OnO/backend/folder/exception/FolderErrorCase.java b/src/main/java/com/aisip/OnO/backend/folder/exception/FolderErrorCase.java index 3e5680d4..65e03155 100644 --- a/src/main/java/com/aisip/OnO/backend/folder/exception/FolderErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/folder/exception/FolderErrorCase.java @@ -14,7 +14,9 @@ public enum FolderErrorCase implements ErrorCase { ROOT_FOLDER_NOT_EXIST(404, 5003, "루트 폴더가 존재하지 않습니다."), - ROOT_FOLDER_CANNOT_REMOVE(400, 5004, "루트 폴더는 삭제할 수 없습니다."); + ROOT_FOLDER_CANNOT_REMOVE(400, 5004, "루트 폴더는 삭제할 수 없습니다."), + + ROOT_FOLDER_CANNOT_UPDATE(400, 5005, "루트 폴더는 수정할 수 없습니다."); private final Integer httpStatusCode; private final Integer errorCode; diff --git a/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java b/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java index 992ec5e7..7e4c6b23 100644 --- a/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java +++ b/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java @@ -130,6 +130,11 @@ public Long createFolder(FolderRegisterDto folderRegisterDto, Long userId) { public void updateFolder(FolderRegisterDto folderRegisterDto, Long userId) { Folder folder = findFolderEntity(folderRegisterDto.folderId(), userId); + + if (folder.getParentFolder() == null) { + throw new ApplicationException(FolderErrorCase.ROOT_FOLDER_CANNOT_UPDATE); + } + folder.updateFolderInfo(folderRegisterDto); if (folderRegisterDto.parentFolderId() != null && folder.getParentFolder() != null) { diff --git a/src/main/java/com/aisip/OnO/backend/learningcalendar/controller/LearningCalendarController.java b/src/main/java/com/aisip/OnO/backend/learningcalendar/controller/LearningCalendarController.java new file mode 100644 index 00000000..90a72f68 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/learningcalendar/controller/LearningCalendarController.java @@ -0,0 +1,37 @@ +package com.aisip.OnO.backend.learningcalendar.controller; + +import com.aisip.OnO.backend.common.response.CommonResponse; +import com.aisip.OnO.backend.learningcalendar.dto.LearningCalendarResponseDto; +import com.aisip.OnO.backend.learningcalendar.service.LearningCalendarService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/learning-calendar") +public class LearningCalendarController { + + private final LearningCalendarService learningCalendarService; + + @GetMapping("") + public CommonResponse getLearningCalendar( + @RequestParam("year") int year, + @RequestParam("month") int month + ) { + validateYearMonth(year, month); + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return CommonResponse.success(learningCalendarService.getLearningCalendar(userId, year, month)); + } + + private void validateYearMonth(int year, int month) { + if (year < 1 || month < 1 || month > 12) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."); + } + } +} diff --git a/src/main/java/com/aisip/OnO/backend/learningcalendar/dto/LearningCalendarResponseDto.java b/src/main/java/com/aisip/OnO/backend/learningcalendar/dto/LearningCalendarResponseDto.java new file mode 100644 index 00000000..3ec4d35d --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/learningcalendar/dto/LearningCalendarResponseDto.java @@ -0,0 +1,28 @@ +package com.aisip.OnO.backend.learningcalendar.dto; + +import lombok.Builder; + +import java.time.LocalDate; +import java.util.List; + +@Builder +public record LearningCalendarResponseDto( + int year, + int month, + int currentStreak, + int bestStreak, + int thisMonthStudyDays, + List records +) { + + @Builder + public record DailyStudyRecord( + LocalDate date, + boolean hasStudied, + int reviewCount, + int noteWriteCount, + int studyMinutes, + List reviewedItems + ) { + } +} diff --git a/src/main/java/com/aisip/OnO/backend/learningcalendar/repository/LearningCalendarQueryRepository.java b/src/main/java/com/aisip/OnO/backend/learningcalendar/repository/LearningCalendarQueryRepository.java new file mode 100644 index 00000000..757ba4f2 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/learningcalendar/repository/LearningCalendarQueryRepository.java @@ -0,0 +1,157 @@ +package com.aisip.OnO.backend.learningcalendar.repository; + +import com.querydsl.core.Tuple; +import com.querydsl.core.types.dsl.DateExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import org.springframework.stereotype.Repository; + +import java.sql.Date; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static com.aisip.OnO.backend.problem.entity.QProblem.problem; +import static com.aisip.OnO.backend.problemsolve.entity.QProblemSolve.problemSolve; + +@Repository +public class LearningCalendarQueryRepository { + + private final JPAQueryFactory queryFactory; + + public LearningCalendarQueryRepository(EntityManager entityManager) { + this.queryFactory = new JPAQueryFactory(entityManager); + } + + public List findDailyReviewStats(Long userId, LocalDateTime start, LocalDateTime end) { + DateExpression practicedDate = Expressions.dateTemplate( + Date.class, "DATE({0})", problemSolve.practicedAt + ); + NumberExpression totalSeconds = problemSolve.timeSpentSeconds.sum(); + + List rows = queryFactory + .select(practicedDate, problemSolve.count(), totalSeconds) + .from(problemSolve) + .where(problemSolve.userId.eq(userId) + .and(problemSolve.practicedAt.between(start, end))) + .groupBy(practicedDate) + .orderBy(practicedDate.asc()) + .fetch(); + + return rows.stream() + .map(row -> new DailyReviewStat( + row.get(practicedDate).toLocalDate(), + defaultLong(row.get(problemSolve.count())), + secondsToMinutes(row.get(totalSeconds)) + )) + .toList(); + } + + public List findDailyNoteWriteStats(Long userId, LocalDateTime start, LocalDateTime end) { + DateExpression createdDate = Expressions.dateTemplate( + Date.class, "DATE({0})", problem.createdAt + ); + + List rows = queryFactory + .select(createdDate, problem.count()) + .from(problem) + .where(problem.userId.eq(userId) + .and(problem.createdAt.between(start, end))) + .groupBy(createdDate) + .orderBy(createdDate.asc()) + .fetch(); + + return rows.stream() + .map(row -> new DailyNoteWriteStat( + row.get(createdDate).toLocalDate(), + defaultLong(row.get(problem.count())) + )) + .toList(); + } + + public List findReviewItems(Long userId, LocalDateTime start, LocalDateTime end) { + DateExpression practicedDate = Expressions.dateTemplate( + Date.class, "DATE({0})", problemSolve.practicedAt + ); + + List rows = queryFactory + .select(practicedDate, problem.id, problem.reference, problem.memo, problemSolve.practicedAt) + .from(problemSolve) + .join(problemSolve.problem, problem) + .where(problemSolve.userId.eq(userId) + .and(problemSolve.practicedAt.between(start, end))) + .orderBy(practicedDate.asc(), problemSolve.practicedAt.desc()) + .fetch(); + + return rows.stream() + .map(row -> new ReviewItem( + row.get(practicedDate).toLocalDate(), + itemTitle(row.get(problem.id), row.get(problem.reference), row.get(problem.memo)), + row.get(problemSolve.practicedAt) + )) + .toList(); + } + + public List findDistinctReviewDatesTotal(Long userId) { + DateExpression practicedDate = Expressions.dateTemplate( + Date.class, "DATE({0})", problemSolve.practicedAt + ); + + return queryFactory + .select(practicedDate) + .distinct() + .from(problemSolve) + .where(problemSolve.userId.eq(userId)) + .orderBy(practicedDate.asc()) + .fetch() + .stream() + .map(Date::toLocalDate) + .toList(); + } + + public List findDistinctNoteWriteDatesTotal(Long userId) { + DateExpression createdDate = Expressions.dateTemplate( + Date.class, "DATE({0})", problem.createdAt + ); + + return queryFactory + .select(createdDate) + .distinct() + .from(problem) + .where(problem.userId.eq(userId)) + .orderBy(createdDate.asc()) + .fetch() + .stream() + .map(Date::toLocalDate) + .toList(); + } + + private int secondsToMinutes(Integer seconds) { + return seconds == null ? 0 : seconds / 60; + } + + private long defaultLong(Long value) { + return value == null ? 0L : value; + } + + public record DailyReviewStat(LocalDate date, long reviewCount, int studyMinutes) { + } + + public record DailyNoteWriteStat(LocalDate date, long noteWriteCount) { + } + + public record ReviewItem(LocalDate date, String title, LocalDateTime practicedAt) { + } + + private String itemTitle(Long problemId, String reference, String memo) { + if (reference != null && !reference.isBlank()) { + return reference; + } + if (memo != null && !memo.isBlank()) { + return memo; + } + return "문제 " + problemId; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarService.java b/src/main/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarService.java new file mode 100644 index 00000000..7b304ed1 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarService.java @@ -0,0 +1,183 @@ +package com.aisip.OnO.backend.learningcalendar.service; + +import com.aisip.OnO.backend.learningcalendar.dto.LearningCalendarResponseDto; +import com.aisip.OnO.backend.learningcalendar.repository.LearningCalendarQueryRepository; +import com.aisip.OnO.backend.util.redis.StreakCacheService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class LearningCalendarService { + + private final LearningCalendarQueryRepository calendarRepository; + private final StreakCacheService streakCacheService; + + public LearningCalendarResponseDto getLearningCalendar(Long userId, int year, int month) { + return getLearningCalendar(userId, year, month, LocalDate.now()); + } + + LearningCalendarResponseDto getLearningCalendar(Long userId, int year, int month, LocalDate today) { + YearMonth yearMonth = YearMonth.of(year, month); + LocalDate startDate = yearMonth.atDay(1); + LocalDate endDate = yearMonth.atEndOfMonth(); + LocalDateTime start = startDate.atStartOfDay(); + LocalDateTime end = endDate.atTime(23, 59, 59); + + Map reviewStats = toReviewStatMap( + calendarRepository.findDailyReviewStats(userId, start, end) + ); + Map noteWriteStats = toNoteWriteStatMap( + calendarRepository.findDailyNoteWriteStats(userId, start, end) + ); + Map> reviewedItems = toReviewedItemsMap( + calendarRepository.findReviewItems(userId, start, end) + ); + + List records = startDate.datesUntil(endDate.plusDays(1)) + .map(date -> buildDailyRecord( + date, + reviewStats.get(date), + noteWriteStats.get(date), + reviewedItems.getOrDefault(date, List.of()) + )) + .toList(); + + TreeSet studyDates = findStudyDates(userId); + TreeSet monthStudyDates = records.stream() + .filter(LearningCalendarResponseDto.DailyStudyRecord::hasStudied) + .map(LearningCalendarResponseDto.DailyStudyRecord::date) + .collect(Collectors.toCollection(TreeSet::new)); + + return LearningCalendarResponseDto.builder() + .year(year) + .month(month) + .currentStreak(calculateCurrentStreak(studyDates, today)) + .bestStreak(calculateBestStreak(monthStudyDates)) + .thisMonthStudyDays((int) records.stream().filter(LearningCalendarResponseDto.DailyStudyRecord::hasStudied).count()) + .records(records) + .build(); + } + + private LearningCalendarResponseDto.DailyStudyRecord buildDailyRecord( + LocalDate date, + LearningCalendarQueryRepository.DailyReviewStat reviewStat, + LearningCalendarQueryRepository.DailyNoteWriteStat noteWriteStat, + List reviewedItems + ) { + int reviewCount = reviewStat == null ? 0 : toInt(reviewStat.reviewCount()); + int noteWriteCount = noteWriteStat == null ? 0 : toInt(noteWriteStat.noteWriteCount()); + int studyMinutes = reviewStat == null ? 0 : reviewStat.studyMinutes(); + + return LearningCalendarResponseDto.DailyStudyRecord.builder() + .date(date) + .hasStudied(reviewCount + noteWriteCount > 0) + .reviewCount(reviewCount) + .noteWriteCount(noteWriteCount) + .studyMinutes(studyMinutes) + .reviewedItems(reviewedItems) + .build(); + } + + private TreeSet findStudyDates(Long userId) { + return streakCacheService.get(userId).orElseGet(() -> { + TreeSet studyDates = new TreeSet<>(); + studyDates.addAll(calendarRepository.findDistinctReviewDatesTotal(userId)); + studyDates.addAll(calendarRepository.findDistinctNoteWriteDatesTotal(userId)); + streakCacheService.put(userId, studyDates); + return studyDates; + }); + } + + private int calculateCurrentStreak(TreeSet studyDates, LocalDate today) { + if (studyDates.isEmpty()) { + return 0; + } + + LocalDate cursor = studyDates.contains(today) ? today : today.minusDays(1); + int streak = 0; + while (studyDates.contains(cursor)) { + streak++; + cursor = cursor.minusDays(1); + } + return streak; + } + + private int calculateBestStreak(TreeSet studyDates) { + if (studyDates.isEmpty()) { + return 0; + } + + int bestStreak = 1; + int currentStreak = 1; + LocalDate previous = null; + for (LocalDate date : studyDates) { + if (previous == null) { + previous = date; + continue; + } + if (date.equals(previous.plusDays(1))) { + currentStreak++; + } else { + currentStreak = 1; + } + bestStreak = Math.max(bestStreak, currentStreak); + previous = date; + } + return bestStreak; + } + + private Map toReviewStatMap( + List stats + ) { + Map map = new HashMap<>(); + for (LearningCalendarQueryRepository.DailyReviewStat stat : stats) { + map.put(stat.date(), stat); + } + return map; + } + + private Map toNoteWriteStatMap( + List stats + ) { + Map map = new HashMap<>(); + for (LearningCalendarQueryRepository.DailyNoteWriteStat stat : stats) { + map.put(stat.date(), stat); + } + return map; + } + + private Map> toReviewedItemsMap( + List items + ) { + Map> grouped = new HashMap<>(); + for (LearningCalendarQueryRepository.ReviewItem item : items) { + LinkedHashMap titles = grouped.computeIfAbsent(item.date(), key -> new LinkedHashMap<>()); + if (titles.size() < 10) { + titles.putIfAbsent(item.title(), item.title()); + } + } + + Map> result = new HashMap<>(); + for (Map.Entry> entry : grouped.entrySet()) { + result.put(entry.getKey(), List.copyOf(entry.getValue().values())); + } + return result; + } + + private int toInt(long value) { + return Math.toIntExact(value); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/learningreport/controller/LearningReportController.java b/src/main/java/com/aisip/OnO/backend/learningreport/controller/LearningReportController.java index bfa58b76..814c309d 100644 --- a/src/main/java/com/aisip/OnO/backend/learningreport/controller/LearningReportController.java +++ b/src/main/java/com/aisip/OnO/backend/learningreport/controller/LearningReportController.java @@ -2,6 +2,7 @@ import com.aisip.OnO.backend.common.response.CommonResponse; import com.aisip.OnO.backend.learningreport.dto.LearningReportResponseDto; +import com.aisip.OnO.backend.learningreport.dto.LearningReportSummaryResponseDto; import com.aisip.OnO.backend.learningreport.service.LearningReportService; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; @@ -20,6 +21,14 @@ public class LearningReportController { private final LearningReportService learningReportService; + + @GetMapping("/summary") + public CommonResponse getLearningReportSummary() { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + LearningReportSummaryResponseDto summary = learningReportService.getLearningReportSummary(userId); + return CommonResponse.success(summary); + } + @GetMapping("") public CommonResponse getLearningReport( @RequestParam(value = "baseDate", required = false) diff --git a/src/main/java/com/aisip/OnO/backend/learningreport/dto/LearningReportSummaryResponseDto.java b/src/main/java/com/aisip/OnO/backend/learningreport/dto/LearningReportSummaryResponseDto.java new file mode 100644 index 00000000..fbea15c5 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/learningreport/dto/LearningReportSummaryResponseDto.java @@ -0,0 +1,17 @@ +package com.aisip.OnO.backend.learningreport.dto; + +import lombok.Builder; + +@Builder +public record LearningReportSummaryResponseDto( + String monthLabel, + long monthlyReviewCount, + int monthlyReviewGoal, + long previousMonthlyReviewCount, + long reviewCountDiff, + double reviewCountChangeRate, + String generatedAt, + boolean monthlyReviewGoalAchieved, + long remainingReviewCountToGoal, + String summaryMessage +) {} \ No newline at end of file diff --git a/src/main/java/com/aisip/OnO/backend/learningreport/service/LearningReportService.java b/src/main/java/com/aisip/OnO/backend/learningreport/service/LearningReportService.java index a37e2621..a330a2ad 100644 --- a/src/main/java/com/aisip/OnO/backend/learningreport/service/LearningReportService.java +++ b/src/main/java/com/aisip/OnO/backend/learningreport/service/LearningReportService.java @@ -1,9 +1,10 @@ package com.aisip.OnO.backend.learningreport.service; -import com.aisip.OnO.backend.learningreport.dto.LearningReportResponseDto; import com.aisip.OnO.backend.learningreport.dto.LearningComparison; import com.aisip.OnO.backend.learningreport.dto.LearningPeriodReport; import com.aisip.OnO.backend.learningreport.dto.LearningRecommendations; +import com.aisip.OnO.backend.learningreport.dto.LearningReportResponseDto; +import com.aisip.OnO.backend.learningreport.dto.LearningReportSummaryResponseDto; import com.aisip.OnO.backend.learningreport.dto.LearningTrendPoint; import com.aisip.OnO.backend.learningreport.dto.LearningWeakArea; import com.aisip.OnO.backend.learningreport.repository.LearningReportQueryRepository; @@ -18,7 +19,9 @@ import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.OffsetDateTime; import java.time.YearMonth; +import java.time.ZoneId; import java.time.temporal.ChronoUnit; import java.util.LinkedHashMap; import java.util.List; @@ -32,6 +35,9 @@ public class LearningReportService { private static final int TOP_WEAK_AREAS_LIMIT = 3; private static final String CACHE_KEY_PREFIX = "LEARNING_REPORT"; + private static final String SUMMARY_CACHE_KEY_PREFIX = "LEARNING_REPORT_SUMMARY"; + private static final int MONTHLY_REVIEW_GOAL = 30; + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); private final LearningReportQueryRepository reportRepository; private final OpenAIClient openAIClient; @@ -76,6 +82,98 @@ public LearningReportResponseDto getLearningReport(Long userId, LocalDate baseDa return report; } + public LearningReportSummaryResponseDto getLearningReportSummary(Long userId) { + LocalDate today = LocalDate.now(KST); + String cacheKey = SUMMARY_CACHE_KEY_PREFIX + ":" + today + ":" + userId; + + LearningReportSummaryResponseDto cached = readSummaryCache(cacheKey); + if (cached != null) { + return cached; + } + + YearMonth currentMonth = YearMonth.from(today); + YearMonth previousMonth = currentMonth.minusMonths(1); + + LocalDateTime currentStart = currentMonth.atDay(1).atStartOfDay(); + LocalDateTime currentEnd = currentMonth.atEndOfMonth().atTime(23, 59, 59); + LocalDateTime previousStart = previousMonth.atDay(1).atStartOfDay(); + LocalDateTime previousEnd = previousMonth.atEndOfMonth().atTime(23, 59, 59); + + long monthlyReviewCount = defaultLong(reportRepository.countReviewsInPeriod(userId, currentStart, currentEnd)); + long previousMonthlyReviewCount = defaultLong(reportRepository.countReviewsInPeriod(userId, previousStart, previousEnd)); + long reviewCountDiff = monthlyReviewCount - previousMonthlyReviewCount; + double reviewCountChangeRate = summaryChangeRate(monthlyReviewCount, previousMonthlyReviewCount); + + String monthLabel = currentMonth.getYear() + "년 " + currentMonth.getMonthValue() + "월"; + String generatedAt = OffsetDateTime.now(KST).truncatedTo(ChronoUnit.SECONDS).toString(); + + boolean goalAchieved = monthlyReviewCount >= MONTHLY_REVIEW_GOAL; + long remaining = Math.max(0L, MONTHLY_REVIEW_GOAL - monthlyReviewCount); + String summaryMessage = buildSummaryMessage(monthlyReviewCount, previousMonthlyReviewCount, reviewCountDiff); + + LearningReportSummaryResponseDto summary = LearningReportSummaryResponseDto.builder() + .monthLabel(monthLabel) + .monthlyReviewCount(monthlyReviewCount) + .monthlyReviewGoal(MONTHLY_REVIEW_GOAL) + .previousMonthlyReviewCount(previousMonthlyReviewCount) + .reviewCountDiff(reviewCountDiff) + .reviewCountChangeRate(reviewCountChangeRate) + .generatedAt(generatedAt) + .monthlyReviewGoalAchieved(goalAchieved) + .remainingReviewCountToGoal(remaining) + .summaryMessage(summaryMessage) + .build(); + + writeSummaryCache(cacheKey, summary); + return summary; + } + + private double summaryChangeRate(long current, long previous) { + if (previous == 0) { + return current == 0 ? 0.0 : 100.0; + } + double rate = ((double) (current - previous) / previous) * 100.0; + return Math.round(rate * 10.0) / 10.0; + } + + private String buildSummaryMessage(long current, long previous, long diff) { + if (current == 0) { + return "이번 달 복습 기록을 만들어보세요"; + } + if (previous == 0) { + return "이번 달 복습을 시작했어요"; + } + if (diff > 0) { + return "지난달보다 " + diff + "문제 더 복습했어요"; + } + if (diff == 0) { + return "지난달과 같은 페이스예요"; + } + return "지난달보다 " + Math.abs(diff) + "문제 적게 복습했어요"; + } + + private LearningReportSummaryResponseDto readSummaryCache(String cacheKey) { + try { + String cachedJson = redisSingleDataService.getSingleData(cacheKey); + if (cachedJson == null || cachedJson.isBlank()) { + return null; + } + return objectMapper.readValue(cachedJson, LearningReportSummaryResponseDto.class); + } catch (Exception e) { + log.warn("Failed to read learning report summary cache. key={}, reason={}", cacheKey, e.getMessage()); + return null; + } + } + + private void writeSummaryCache(String cacheKey, LearningReportSummaryResponseDto summary) { + try { + String json = objectMapper.writeValueAsString(summary); + redisSingleDataService.setSingleData(cacheKey, json, ttlUntilNextMidnight()); + } catch (Exception e) { + log.warn("Failed to write learning report summary cache. key={}, reason={}", cacheKey, e.getMessage()); + } + } + private LearningPeriodReport buildPeriodReport( Long userId, String label, DateRange range, TrendType trendType ) { @@ -453,7 +551,7 @@ private void writeCache(String cacheKey, LearningReportResponseDto report) { } private Duration ttlUntilNextMidnight() { - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(KST); LocalDateTime nextMidnight = now.toLocalDate().plusDays(1).atStartOfDay(); Duration ttl = Duration.between(now, nextMidnight); return ttl.isNegative() || ttl.isZero() ? Duration.ofSeconds(1) : ttl; diff --git a/src/main/java/com/aisip/OnO/backend/problem/controller/ProblemController.java b/src/main/java/com/aisip/OnO/backend/problem/controller/ProblemController.java index 897d213d..513f0345 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/controller/ProblemController.java +++ b/src/main/java/com/aisip/OnO/backend/problem/controller/ProblemController.java @@ -1,12 +1,12 @@ package com.aisip.OnO.backend.problem.controller; -import com.aisip.OnO.backend.common.ratelimit.RateLimit; import com.aisip.OnO.backend.common.response.CommonResponse; import com.aisip.OnO.backend.common.response.CursorPageResponse; import com.aisip.OnO.backend.problem.dto.ProblemAnalysisResponseDto; import com.aisip.OnO.backend.problem.dto.ReviewDueResponseDto; import com.aisip.OnO.backend.problem.dto.ProblemDeleteRequestDto; import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; +import com.aisip.OnO.backend.problem.dto.ProblemRegisterV2BatchDto; import com.aisip.OnO.backend.problem.dto.ProblemRegisterV2Dto; import com.aisip.OnO.backend.problem.dto.ProblemResponseDto; import com.aisip.OnO.backend.problem.dto.ProblemTagUpdateDto; @@ -115,7 +115,6 @@ public CommonResponse getProblemAnalysis(@PathVariab } // ✅ 문제 분석 요청 (비동기 트리거) - @RateLimit(key = "ai_analysis", limitPerDay = 20) @PostMapping("/{problemId}/analysis") public CommonResponse requestProblemAnalysis(@PathVariable("problemId") Long problemId) { Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); @@ -143,8 +142,16 @@ public CommonResponse registerProblemV2(@RequestBody ProblemRegisterV2Dto return CommonResponse.success(problemId); } + // ✅ 문제 배치 등록 v2 (이미지 URL 동시 저장) + @PostMapping("/v2/batch") + public CommonResponse> registerProblemsV2(@RequestBody ProblemRegisterV2BatchDto problemRegisterV2BatchDto) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + + List problemIds = problemService.registerProblemsV2(problemRegisterV2BatchDto, userId); + return CommonResponse.success(problemIds); + } + // ✅ 문제 이미지 비동기 업로드 - @RateLimit(key = "ai_analysis", limitPerDay = 20) @PostMapping("/{problemId}/imageData") public CommonResponse uploadProblemImages( @PathVariable("problemId") Long problemId, diff --git a/src/main/java/com/aisip/OnO/backend/problem/dto/ProblemRegisterV2BatchDto.java b/src/main/java/com/aisip/OnO/backend/problem/dto/ProblemRegisterV2BatchDto.java new file mode 100644 index 00000000..f87df5f8 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/dto/ProblemRegisterV2BatchDto.java @@ -0,0 +1,8 @@ +package com.aisip.OnO.backend.problem.dto; + +import java.util.List; + +public record ProblemRegisterV2BatchDto( + List problems +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/entity/AnalysisStatus.java b/src/main/java/com/aisip/OnO/backend/problem/entity/AnalysisStatus.java index 855ba316..45bef96f 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/entity/AnalysisStatus.java +++ b/src/main/java/com/aisip/OnO/backend/problem/entity/AnalysisStatus.java @@ -5,5 +5,6 @@ public enum AnalysisStatus { COMPLETED, // 완료 FAILED, // 실패 NOT_STARTED, - NO_IMAGE + NO_IMAGE, + RATE_LIMIT_EXCEEDED } diff --git a/src/main/java/com/aisip/OnO/backend/problem/entity/ProblemAnalysis.java b/src/main/java/com/aisip/OnO/backend/problem/entity/ProblemAnalysis.java index eb4fb632..bb69a333 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/entity/ProblemAnalysis.java +++ b/src/main/java/com/aisip/OnO/backend/problem/entity/ProblemAnalysis.java @@ -37,6 +37,7 @@ public class ProblemAnalysis extends BaseEntity { private String studyTips; // 학습 팁 @Enumerated(EnumType.STRING) + @Column(columnDefinition = "VARCHAR(32)") private AnalysisStatus status; // 분석 상태 @Column(columnDefinition = "TEXT") @@ -83,4 +84,9 @@ public void updateToNoImage() { this.status = AnalysisStatus.NO_IMAGE; this.errorMessage = "이미지가 없어 분석을 진행할 수 없습니다."; } + + public void updateToRateLimitExceeded() { + this.status = AnalysisStatus.RATE_LIMIT_EXCEEDED; + this.errorMessage = "AI 분석 일일 요청 횟수를 초과해 분석을 진행하지 않았습니다."; + } } diff --git a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemAnalysisService.java b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemAnalysisService.java index 2be67908..4f91c1fb 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemAnalysisService.java +++ b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemAnalysisService.java @@ -107,6 +107,24 @@ public void updateToNoImage(Long problemId) { log.info("Updated analysis to NO_IMAGE for problemId: {}", problemId); } + /** + * 분석 상태를 RATE_LIMIT_EXCEEDED로 업데이트 (일일 요청 제한 초과) + */ + public void updateToRateLimitExceeded(Long problemId) { + ProblemAnalysis analysis = analysisRepository.findByProblemId(problemId) + .orElseGet(() -> { + Problem problem = problemRepository.findById(problemId) + .orElseThrow(() -> new ApplicationException(ProblemErrorCase.PROBLEM_NOT_FOUND)); + ProblemAnalysis newAnalysis = ProblemAnalysis.createSkipped(problem); + problem.updateProblemAnalysis(newAnalysis); + return newAnalysis; + }); + + analysis.updateToRateLimitExceeded(); + analysisRepository.save(analysis); + log.info("Updated analysis to RATE_LIMIT_EXCEEDED for problemId: {}", problemId); + } + /** * 동기적으로 문제 이미지를 분석합니다 (RabbitMQ Consumer에서 호출) * - 동시성 문제 해결: 이미 생성된 PROCESSING 엔티티만 조회하여 업데이트 diff --git a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java index a20689a7..e2ede973 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java +++ b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java @@ -2,15 +2,18 @@ import com.aisip.OnO.backend.admin.dto.AdminProblemResponseDto; import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.common.ratelimit.RateLimitService; import com.aisip.OnO.backend.common.response.CursorPageResponse; import com.aisip.OnO.backend.config.rabbitmq.producer.S3DeleteProducer; import com.aisip.OnO.backend.config.rabbitmq.producer.ProblemAnalysisProducer; import com.aisip.OnO.backend.mission.service.MissionLogService; import com.aisip.OnO.backend.problem.entity.AnalysisStatus; +import com.aisip.OnO.backend.problem.entity.ProblemAnalysis; import com.aisip.OnO.backend.problem.entity.ProblemImageType; import com.aisip.OnO.backend.util.fileupload.service.FileUploadService; import com.aisip.OnO.backend.problem.dto.ProblemImageDataRegisterDto; import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; +import com.aisip.OnO.backend.problem.dto.ProblemRegisterV2BatchDto; import com.aisip.OnO.backend.problem.dto.ProblemRegisterV2Dto; import com.aisip.OnO.backend.problem.dto.ProblemTagUpdateDto; import com.aisip.OnO.backend.folder.entity.Folder; @@ -32,6 +35,7 @@ import com.aisip.OnO.backend.tag.exception.TagErrorCase; import com.aisip.OnO.backend.tag.repository.ProblemTagMappingRepository; import com.aisip.OnO.backend.tag.repository.TagRepository; +import com.aisip.OnO.backend.util.redis.StreakCacheService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -57,6 +61,9 @@ @Service @RequiredArgsConstructor public class ProblemService { + private static final String AI_ANALYSIS_RATE_LIMIT_KEY = "ai_analysis"; + private static final int AI_ANALYSIS_LIMIT_PER_DAY = 20; + private final ProblemRepository problemRepository; private final ProblemImageDataRepository problemImageDataRepository; @@ -79,6 +86,8 @@ public class ProblemService { private final ProblemAnalysisProducer analysisProducer; private final TagRepository tagRepository; private final ProblemTagMappingRepository problemTagMappingRepository; + private final RateLimitService rateLimitService; + private final StreakCacheService streakCacheService; @Transactional(readOnly = true) public ProblemResponseDto findProblemForAdmin(Long problemId) { @@ -203,6 +212,7 @@ public Long registerProblem(ProblemRegisterDto problemRegisterDto, Long userId) problem.updateReviewSchedule(LocalDate.now(java.time.ZoneId.of("Asia/Seoul")), 1, 0); analysisService.createSkippedAnalysis(problem.getId()); + streakCacheService.evict(userId); missionLogService.registerProblemWriteMission(userId); log.info("userId: {} register problemId: {}", userId, problem.getId()); @@ -259,16 +269,81 @@ public Long registerProblemV2(ProblemRegisterV2Dto problemRegisterV2Dto, Long us } problem.updateReviewSchedule(LocalDate.now(java.time.ZoneId.of("Asia/Seoul")), 1, 0); analysisService.createSkippedAnalysis(problem.getId()); + streakCacheService.evict(userId); missionLogService.registerProblemWriteMission(userId); log.info("userId: {} register problem(v2) problemId: {}", userId, problem.getId()); return problem.getId(); } + /** + * 문제 배치 등록 v2 + * - 요청 전체를 하나의 트랜잭션으로 처리 + * - 폴더/태그 검증을 저장 전에 수행해 중간 실패 시 전체 롤백 + */ + @Transactional + public List registerProblemsV2(ProblemRegisterV2BatchDto problemRegisterV2BatchDto, Long userId) { + List registerDtos = problemRegisterV2BatchDto.problems(); + if (registerDtos == null || registerDtos.isEmpty()) { + return List.of(); + } + + Map foldersById = resolveRegisterFolders(registerDtos, userId); + Folder rootFolder = registerDtos.stream().anyMatch(dto -> dto.folderId() == null) + ? resolveRootFolder(userId) + : null; + Map tagsById = findRegisterTagsById(registerDtos, userId); + LocalDate today = LocalDate.now(java.time.ZoneId.of("Asia/Seoul")); + + List problems = registerDtos.stream() + .map(dto -> { + ProblemRegisterDto baseDto = toProblemRegisterDto(dto); + Problem problem = Problem.from(baseDto, userId); + Folder folder = dto.folderId() == null ? rootFolder : foldersById.get(dto.folderId()); + problem.updateFolder(folder); + problem.updateReviewSchedule(today, 1, 0); + return problem; + }) + .toList(); + problemRepository.saveAll(problems); + + List imageDataList = new ArrayList<>(); + List tagMappings = new ArrayList<>(); + List analyses = new ArrayList<>(); + + for (int i = 0; i < registerDtos.size(); i++) { + ProblemRegisterV2Dto dto = registerDtos.get(i); + Problem problem = problems.get(i); + + addImageData(imageDataList, problem, dto.problemImageUrls(), ProblemImageType.PROBLEM_IMAGE); + addImageData(imageDataList, problem, dto.answerImageUrls(), ProblemImageType.ANSWER_IMAGE); + + for (Long tagId : toDistinctIds(dto.tagIds())) { + tagMappings.add(ProblemTagMapping.from(problem, tagsById.get(tagId))); + } + + ProblemAnalysis analysis = ProblemAnalysis.createSkipped(problem); + problem.updateProblemAnalysis(analysis); + analyses.add(analysis); + } + + problemImageDataRepository.saveAll(imageDataList); + problemTagMappingRepository.saveAll(tagMappings); + problemAnalysisRepository.saveAll(analyses); + + streakCacheService.evict(userId); + problems.forEach(problem -> missionLogService.registerProblemWriteMission(userId)); + + List problemIds = problems.stream() + .map(Problem::getId) + .toList(); + log.info("userId: {} register problems(v2 batch) problemIds: {}", userId, problemIds); + return problemIds; + } + private Folder resolveRegisterFolder(Long folderId, Long userId) { if (folderId == null) { - return folderRepository.findByUserIdAndParentFolderIsNull(userId) - .orElseThrow(() -> new ApplicationException(FolderErrorCase.ROOT_FOLDER_NOT_EXIST)); + return resolveRootFolder(userId); } return folderRepository.findById(folderId) @@ -279,6 +354,83 @@ private Folder resolveRegisterFolder(Long folderId, Long userId) { .orElseThrow(() -> new ApplicationException(FolderErrorCase.FOLDER_NOT_FOUND)); } + private Folder resolveRootFolder(Long userId) { + return folderRepository.findByUserIdAndParentFolderIsNull(userId) + .orElseThrow(() -> new ApplicationException(FolderErrorCase.ROOT_FOLDER_NOT_EXIST)); + } + + private Map resolveRegisterFolders(List registerDtos, Long userId) { + Set folderIds = registerDtos.stream() + .map(ProblemRegisterV2Dto::folderId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + if (folderIds.isEmpty()) { + return Map.of(); + } + + Map foldersById = folderRepository.findAllById(folderIds).stream() + .collect(Collectors.toMap(Folder::getId, folder -> folder)); + if (foldersById.size() != folderIds.size()) { + throw new ApplicationException(FolderErrorCase.FOLDER_NOT_FOUND); + } + + foldersById.values().forEach(folder -> validateFolderOwner(folder, userId)); + return foldersById; + } + + private Map findRegisterTagsById(List registerDtos, Long userId) { + boolean hasTooManyTags = registerDtos.stream() + .map(dto -> toDistinctIds(dto.tagIds()).size()) + .anyMatch(size -> size > 5); + if (hasTooManyTags) { + throw new ApplicationException(TagErrorCase.TAG_LIMIT_EXCEEDED); + } + + Set tagIds = registerDtos.stream() + .flatMap(dto -> toDistinctIds(dto.tagIds()).stream()) + .collect(Collectors.toSet()); + + if (tagIds.isEmpty()) { + return Map.of(); + } + + List tags = tagRepository.findAllByIdInAndUserId(new ArrayList<>(tagIds), userId); + if (tags.size() != tagIds.size()) { + throw new ApplicationException(TagErrorCase.TAG_NOT_FOUND); + } + + return tags.stream().collect(Collectors.toMap(Tag::getId, tag -> tag)); + } + + private ProblemRegisterDto toProblemRegisterDto(ProblemRegisterV2Dto dto) { + return new ProblemRegisterDto( + dto.problemId(), + dto.memo(), + dto.reference(), + dto.folderId(), + dto.solvedAt(), + dto.tagIds() + ); + } + + private void addImageData(List imageDataList, Problem problem, List imageUrls, ProblemImageType imageType) { + if (imageUrls == null) { + return; + } + + imageUrls.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(url -> !url.isEmpty()) + .forEach(url -> { + ProblemImageData imageData = ProblemImageData.from( + new ProblemImageDataRegisterDto(problem.getId(), url, imageType)); + imageData.updateProblem(problem); + imageDataList.add(imageData); + }); + } + /** * 문제 이미지 비동기 업로드 및 AI 분석 트리거 */ @@ -330,11 +482,11 @@ public void uploadProblemImages(Long problemId, Long userId, List public void analysisProblem(Long problemId, Long userId) { findProblemEntity(problemId, userId); - analysisProblemWithoutOwnerCheck(problemId); + analysisProblemWithoutOwnerCheck(problemId, userId); } @Transactional - private void analysisProblemWithoutOwnerCheck(Long problemId) { + private void analysisProblemWithoutOwnerCheck(Long problemId, Long userId) { // 이미 분석이 완료된 문제는 재요청하지 않음 if (problemAnalysisRepository.findByProblemId(problemId) .map(analysis -> analysis.getStatus() == AnalysisStatus.COMPLETED) @@ -355,6 +507,12 @@ private void analysisProblemWithoutOwnerCheck(Long problemId) { analysisService.updateToNoImage(problemId); log.info("분석 불가 (이미지 없음) - problemId: {}", problemId); } else { + if (!rateLimitService.tryConsume(AI_ANALYSIS_RATE_LIMIT_KEY, userId, AI_ANALYSIS_LIMIT_PER_DAY)) { + analysisService.updateToRateLimitExceeded(problemId); + log.info("AI 분석 일일 요청 제한 초과 - userId: {}, problemId: {}", userId, problemId); + return; + } + // 이미지 있음 → PROCESSING 상태로 업데이트 후 RabbitMQ 전송 analysisService.updateToProcessing(problemId); analysisProducer.sendAnalysisMessage(problemId); @@ -539,6 +697,7 @@ private void deleteProblemWithoutOwnerCheck(Long problemId) { public void deleteProblem(Long problemId, Long userId) { findProblemEntity(problemId, userId); deleteProblemWithoutOwnerCheck(problemId); + streakCacheService.evict(userId); } @Transactional @@ -583,6 +742,7 @@ public void deleteAllUserProblems(Long userId) { deleteProblemWithoutOwnerCheck(problem.getId()); }); + streakCacheService.evict(userId); log.info("userId: {} delete all user problems", userId); } diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java b/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java index ff9fd066..4233fcf9 100644 --- a/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java @@ -2,6 +2,7 @@ import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.mission.service.MissionLogService; +import com.aisip.OnO.backend.util.redis.StreakCacheService; import com.aisip.OnO.backend.problem.service.ReviewIntervalCalculator; import com.aisip.OnO.backend.problemsolve.dto.ProblemSolveRegisterDto; import com.aisip.OnO.backend.problemsolve.dto.ProblemSolveResponseDto; @@ -39,6 +40,7 @@ public class ProblemSolveService { private final FileUploadService fileUploadService; private final S3DeleteProducer s3DeleteProducer; private final ObjectMapper objectMapper; + private final StreakCacheService streakCacheService; @Transactional(readOnly = true) public ProblemSolveResponseDto getProblemSolve(Long problemSolveId, Long userId) { @@ -117,6 +119,7 @@ public Long createProblemSolve(ProblemSolveRegisterDto dto, Long userId) { ); problemSolveRepository.save(problemSolve); + streakCacheService.evict(userId); missionLogService.registerProblemPracticeMission(userId, problem.getId()); ReviewIntervalCalculator.ReviewSchedule schedule = ReviewIntervalCalculator.calculate( @@ -195,6 +198,7 @@ public void deleteProblemSolve(Long problemSolveId, Long userId) { List images = problemSolve.getImages(); problemSolveRepository.delete(problemSolve); + streakCacheService.evict(userId); log.info("userId: {} deleted problem solve: {}", userId, problemSolveId); images.forEach(image -> { diff --git a/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java b/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java index e6178b00..e80e5b4c 100644 --- a/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java @@ -75,7 +75,7 @@ private static Long getTotalStudyResponsePoint(Long level, Long point) { private static Long getTotalStudyNextLevelThreshold(com.aisip.OnO.backend.mission.entity.UserMissionStatus status) { if (status.getTotalStudyLevel() >= MAX_LEVEL) { - return 0L; + return getTotalStudyThresholdForLevel(MAX_LEVEL); } // 개별 능력치 필요 경험치 × 4 return getTotalStudyThresholdForLevel(status.getTotalStudyLevel()); diff --git a/src/main/java/com/aisip/OnO/backend/user/entity/User.java b/src/main/java/com/aisip/OnO/backend/user/entity/User.java index 8a60d524..eb9090e1 100644 --- a/src/main/java/com/aisip/OnO/backend/user/entity/User.java +++ b/src/main/java/com/aisip/OnO/backend/user/entity/User.java @@ -112,5 +112,8 @@ public void updateUser(UserRegisterDto userRegisterDto) { this.password = userRegisterDto.password(); } } -} + public void maskIdentifierForDeletion(String maskedIdentifier) { + this.identifier = maskedIdentifier; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/user/service/UserService.java b/src/main/java/com/aisip/OnO/backend/user/service/UserService.java index 7789ccd1..34e89837 100644 --- a/src/main/java/com/aisip/OnO/backend/user/service/UserService.java +++ b/src/main/java/com/aisip/OnO/backend/user/service/UserService.java @@ -142,16 +142,24 @@ public void updateUser(Long userId, UserRegisterDto userRegisterDto) { @Transactional public void deleteUserById(Long userId) { + User user = findUserEntity(userId); practiceNoteService.deleteAllPracticesByUser(userId); problemService.deleteAllUserProblems(userId); folderService.deleteAllUserFolders(userId); + user.maskIdentifierForDeletion(makeDeletedIdentifier(userId)); + userRepository.flush(); + userRepository.deleteById(userId); userRepository.flush(); log.info("userId: {} has deleted", userId); } + private String makeDeletedIdentifier(Long userId) { + return "deleted:" + userId + ":" + UUID.randomUUID(); + } + private String makeGuestName() { return "Guest" + UUID.randomUUID().toString().substring(0, 8); } diff --git a/src/main/java/com/aisip/OnO/backend/util/redis/StreakCacheService.java b/src/main/java/com/aisip/OnO/backend/util/redis/StreakCacheService.java new file mode 100644 index 00000000..eb2d7ccb --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/util/redis/StreakCacheService.java @@ -0,0 +1,63 @@ +package com.aisip.OnO.backend.util.redis; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Optional; +import java.util.TreeSet; + +@Slf4j +@Service +@RequiredArgsConstructor +public class StreakCacheService { + + private static final String KEY_PREFIX = "STREAK"; + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private final RedisSingleDataService redisSingleDataService; + private final ObjectMapper objectMapper; + + public Optional> get(Long userId) { + try { + String json = redisSingleDataService.getSingleData(key(userId)); + if (json == null || json.isBlank()) { + return Optional.empty(); + } + return Optional.of(objectMapper.readValue(json, new TypeReference>() {})); + } catch (Exception e) { + log.warn("Failed to read streak cache. userId={}, reason={}", userId, e.getMessage()); + return Optional.empty(); + } + } + + public void put(Long userId, TreeSet dates) { + try { + String json = objectMapper.writeValueAsString(dates); + redisSingleDataService.setSingleData(key(userId), json, ttlUntilNextMidnight()); + } catch (Exception e) { + log.warn("Failed to write streak cache. userId={}, reason={}", userId, e.getMessage()); + } + } + + public void evict(Long userId) { + redisSingleDataService.deleteSingleData(key(userId)); + } + + private String key(Long userId) { + return KEY_PREFIX + ":" + userId; + } + + private Duration ttlUntilNextMidnight() { + LocalDateTime now = LocalDateTime.now(KST); + LocalDateTime nextMidnight = now.toLocalDate().plusDays(1).atStartOfDay(); + Duration ttl = Duration.between(now, nextMidnight); + return ttl.isNegative() || ttl.isZero() ? Duration.ofSeconds(1) : ttl; + } +} \ No newline at end of file diff --git a/src/main/resources/db/migration/V5__change_problem_analysis_status_to_varchar.sql b/src/main/resources/db/migration/V5__change_problem_analysis_status_to_varchar.sql new file mode 100644 index 00000000..d8bc8f53 --- /dev/null +++ b/src/main/resources/db/migration/V5__change_problem_analysis_status_to_varchar.sql @@ -0,0 +1,2 @@ +ALTER TABLE problem_analysis + MODIFY COLUMN status VARCHAR(32); diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index c2b5764c..a6d69a09 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -48,7 +48,7 @@ - + @@ -56,7 +56,7 @@ - + diff --git a/src/main/resources/spy.properties b/src/main/resources/spy.properties index 64f9318f..fb31976b 100644 --- a/src/main/resources/spy.properties +++ b/src/main/resources/spy.properties @@ -1,4 +1,5 @@ appender=com.p6spy.engine.spy.appender.Slf4JLogger +# 느린 쿼리를 DB에 바로 붙여 넣을 수 있는 단일 행 EXPLAIN SQL로 출력한다. logMessageFormat=com.aisip.OnO.backend.config.P6SpyExplainAppender # P6Spy 로그 출력 카테고리 제외 (성능 최적화) @@ -9,4 +10,4 @@ exclude=QRTZ_TRIGGERS,QRTZ_FIRED_TRIGGERS,QRTZ_JOB_DETAILS,QRTZ_CRON_TRIGGERS,QR # 실행 시간 필터링 # 300 = 300ms 이상 걸린 쿼리만 출력 -executionThreshold=300 +executionThreshold=100 diff --git a/src/main/resources/templates/analysis.html b/src/main/resources/templates/analysis.html index 4fa69138..78e97b86 100644 --- a/src/main/resources/templates/analysis.html +++ b/src/main/resources/templates/analysis.html @@ -331,6 +331,10 @@

전체 이미지 없음 0 +
+ 전체 요청 제한 초과 + 0 +
선택 기간 분석 완료 0 @@ -356,6 +360,10 @@

선택 기간 이미지 없음 0

+
+ 선택 기간 요청 제한 초과 + 0 +
diff --git a/src/test/java/com/aisip/OnO/backend/config/P6SpyExplainAppenderTest.java b/src/test/java/com/aisip/OnO/backend/config/P6SpyExplainAppenderTest.java new file mode 100644 index 00000000..4db90279 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/config/P6SpyExplainAppenderTest.java @@ -0,0 +1,35 @@ +package com.aisip.OnO.backend.config; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class P6SpyExplainAppenderTest { + + private final P6SpyExplainAppender formatter = new P6SpyExplainAppender(); + + @Test + void formatMessage_printsSlowQueryAsSingleLineExplainSql() { + String sql = """ + select * + from users + where email = 'test@example.com'; + """; + + String message = formatter.formatMessage(1, "2026-05-25 12:00:00", 301, + "statement", sql, sql, "jdbc:mysql://localhost:3306/ono_db"); + + assertThat(message).doesNotContain("\n"); + assertThat(message).contains("[P6Spy][SLOW_QUERY]"); + assertThat(message).contains("took=301ms"); + assertThat(message).contains("sql=EXPLAIN select * from users where email = 'test@example.com';"); + } + + @Test + void formatMessage_skipsQuartzQueries() { + String message = formatter.formatMessage(1, "2026-05-25 12:00:00", 301, + "statement", "", "select * from QRTZ_TRIGGERS", "jdbc:mysql://localhost:3306/ono_db"); + + assertThat(message).isEmpty(); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/learningcalendar/controller/LearningCalendarControllerTest.java b/src/test/java/com/aisip/OnO/backend/learningcalendar/controller/LearningCalendarControllerTest.java new file mode 100644 index 00000000..bb06ec18 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/learningcalendar/controller/LearningCalendarControllerTest.java @@ -0,0 +1,90 @@ +package com.aisip.OnO.backend.learningcalendar.controller; + +import com.aisip.OnO.backend.auth.WithMockCustomUser; +import com.aisip.OnO.backend.learningcalendar.dto.LearningCalendarResponseDto; +import com.aisip.OnO.backend.learningcalendar.service.LearningCalendarService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDate; +import java.util.List; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(SpringExtension.class) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class LearningCalendarControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private LearningCalendarService learningCalendarService; + + @Test + @DisplayName("학습 달력 조회") + @WithMockCustomUser(userId = 7L) + void getLearningCalendar() throws Exception { + LearningCalendarResponseDto response = LearningCalendarResponseDto.builder() + .year(2026) + .month(5) + .currentStreak(3) + .bestStreak(10) + .thisMonthStudyDays(1) + .records(List.of(LearningCalendarResponseDto.DailyStudyRecord.builder() + .date(LocalDate.of(2026, 5, 1)) + .hasStudied(true) + .reviewCount(2) + .noteWriteCount(1) + .studyMinutes(12) + .reviewedItems(List.of("이차방정식 오답노트")) + .build())) + .build(); + given(learningCalendarService.getLearningCalendar(7L, 2026, 5)).willReturn(response); + + mockMvc.perform(get("/api/learning-calendar") + .param("year", "2026") + .param("month", "5") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.year").value(2026)) + .andExpect(jsonPath("$.data.month").value(5)) + .andExpect(jsonPath("$.data.currentStreak").value(3)) + .andExpect(jsonPath("$.data.bestStreak").value(10)) + .andExpect(jsonPath("$.data.thisMonthStudyDays").value(1)) + .andExpect(jsonPath("$.data.records[0].date").value("2026-05-01")) + .andExpect(jsonPath("$.data.records[0].hasStudied").value(true)) + .andExpect(jsonPath("$.data.records[0].reviewCount").value(2)) + .andExpect(jsonPath("$.data.records[0].noteWriteCount").value(1)) + .andExpect(jsonPath("$.data.records[0].studyMinutes").value(12)) + .andExpect(jsonPath("$.data.records[0].reviewedItems[0]").value("이차방정식 오답노트")); + + verify(learningCalendarService).getLearningCalendar(7L, 2026, 5); + } + + @Test + @DisplayName("학습 달력 조회 - 잘못된 월은 400을 반환한다") + @WithMockCustomUser(userId = 7L) + void getLearningCalendar_invalidMonth() throws Exception { + mockMvc.perform(get("/api/learning-calendar") + .param("year", "2026") + .param("month", "13") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarServiceTest.java b/src/test/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarServiceTest.java new file mode 100644 index 00000000..9bf8d020 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarServiceTest.java @@ -0,0 +1,164 @@ +package com.aisip.OnO.backend.learningcalendar.service; + +import com.aisip.OnO.backend.learningcalendar.dto.LearningCalendarResponseDto; +import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; +import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.problem.repository.ProblemRepository; +import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; +import com.aisip.OnO.backend.problemsolve.entity.ProblemSolve; +import com.aisip.OnO.backend.problemsolve.repository.ProblemSolveRepository; +import com.aisip.OnO.backend.user.dto.UserRegisterDto; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.repository.UserRepository; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class LearningCalendarServiceTest { + + @Autowired + private LearningCalendarService learningCalendarService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private ProblemSolveRepository problemSolveRepository; + + @Autowired + private EntityManager entityManager; + + @Test + @DisplayName("학습 달력 조회 - 월별 일자 기록과 스트릭을 계산한다") + void getLearningCalendar_success() { + User targetUser = createUser("calendar-user"); + User otherUser = createUser("other-calendar-user"); + Long userId = targetUser.getId(); + + Problem targetProblem = createProblemWrite(userId, LocalDateTime.of(2026, 5, 1, 8, 0)); + createProblemWrite(userId, LocalDateTime.of(2026, 5, 2, 8, 0)); + createProblemWrite(userId, LocalDateTime.of(2026, 5, 5, 8, 0)); + + createSolve(targetProblem, userId, LocalDateTime.of(2026, 5, 1, 9, 0), 600); + createSolve(targetProblem, userId, LocalDateTime.of(2026, 5, 1, 10, 0), 120); + createSolve(targetProblem, userId, LocalDateTime.of(2026, 5, 3, 9, 0), 300); + createSolve(targetProblem, userId, LocalDateTime.of(2026, 5, 4, 9, 0), 60); + createSolve(targetProblem, userId, LocalDateTime.of(2026, 5, 5, 9, 0), 60); + + Problem previousMonthProblem = createProblemWrite(userId, LocalDateTime.of(2026, 4, 30, 8, 0)); + createSolve(previousMonthProblem, userId, LocalDateTime.of(2026, 4, 30, 9, 0), 60); + createSolve(previousMonthProblem, userId, LocalDateTime.of(2026, 4, 28, 9, 0), 60); + + Problem otherProblem = createProblemWrite(otherUser.getId(), LocalDateTime.of(2026, 5, 1, 8, 0)); + createSolve(otherProblem, otherUser.getId(), LocalDateTime.of(2026, 5, 1, 9, 0), 999); + + LearningCalendarResponseDto response = learningCalendarService.getLearningCalendar( + userId, + 2026, + 5, + LocalDate.of(2026, 5, 6) + ); + + assertThat(response.year()).isEqualTo(2026); + assertThat(response.month()).isEqualTo(5); + assertThat(response.records()).hasSize(31); + assertThat(response.thisMonthStudyDays()).isEqualTo(5); + assertThat(response.currentStreak()).isEqualTo(6); + assertThat(response.bestStreak()).isEqualTo(5); + + Map records = response.records().stream() + .collect(Collectors.toMap(LearningCalendarResponseDto.DailyStudyRecord::date, record -> record)); + + assertThat(records.get(LocalDate.of(2026, 5, 1)).hasStudied()).isTrue(); + assertThat(records.get(LocalDate.of(2026, 5, 1)).reviewCount()).isEqualTo(2); + assertThat(records.get(LocalDate.of(2026, 5, 1)).noteWriteCount()).isEqualTo(1); + assertThat(records.get(LocalDate.of(2026, 5, 1)).studyMinutes()).isEqualTo(12); + assertThat(records.get(LocalDate.of(2026, 5, 1)).reviewedItems()) + .containsExactly("ref-2026-05-01T08:00"); + assertThat(records.get(LocalDate.of(2026, 5, 2)).reviewCount()).isZero(); + assertThat(records.get(LocalDate.of(2026, 5, 2)).noteWriteCount()).isEqualTo(1); + assertThat(records.get(LocalDate.of(2026, 5, 6)).hasStudied()).isFalse(); + assertThat(records.get(LocalDate.of(2026, 5, 6)).reviewedItems()).isEmpty(); + } + + @Test + @DisplayName("학습 달력 조회 - 데이터가 없으면 0 기반 월 레코드를 반환한다") + void getLearningCalendar_emptyData() { + User user = createUser("empty-calendar-user"); + + LearningCalendarResponseDto response = learningCalendarService.getLearningCalendar( + user.getId(), + 2026, + 2, + LocalDate.of(2026, 2, 6) + ); + + assertThat(response.records()).hasSize(28); + assertThat(response.currentStreak()).isZero(); + assertThat(response.bestStreak()).isZero(); + assertThat(response.thisMonthStudyDays()).isZero(); + assertThat(response.records()).allSatisfy(record -> { + assertThat(record.hasStudied()).isFalse(); + assertThat(record.reviewCount()).isZero(); + assertThat(record.noteWriteCount()).isZero(); + assertThat(record.studyMinutes()).isZero(); + assertThat(record.reviewedItems()).isEmpty(); + }); + } + + private User createUser(String identifier) { + return userRepository.save(User.from(UserRegisterDto.builder() + .email(identifier + "@test.com") + .name(identifier) + .identifier(identifier) + .platform("GOOGLE") + .password("password") + .build())); + } + + private Problem createProblemWrite(Long userId, LocalDateTime createdAt) { + Problem problemEntity = problemRepository.save(Problem.from( + new ProblemRegisterDto(null, "memo-" + createdAt, "ref-" + createdAt, null, LocalDateTime.now()), + userId + )); + updateProblemCreatedAt(problemEntity.getId(), createdAt); + return problemEntity; + } + + private void createSolve(Problem problemEntity, Long userId, LocalDateTime practicedAt, Integer seconds) { + problemSolveRepository.save(ProblemSolve.create( + problemEntity, + userId, + practicedAt, + AnswerStatus.CORRECT, + null, + null, + seconds + )); + } + + private void updateProblemCreatedAt(Long problemId, LocalDateTime createdAt) { + entityManager.flush(); + entityManager.createNativeQuery("UPDATE problem SET created_at = :createdAt WHERE id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", problemId) + .executeUpdate(); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/problem/controller/ProblemControllerTest.java b/src/test/java/com/aisip/OnO/backend/problem/controller/ProblemControllerTest.java index 0ad2a864..52c0bb9f 100644 --- a/src/test/java/com/aisip/OnO/backend/problem/controller/ProblemControllerTest.java +++ b/src/test/java/com/aisip/OnO/backend/problem/controller/ProblemControllerTest.java @@ -185,6 +185,46 @@ void registerProblem() throws Exception { verify(problemService, times(1)).registerProblem(any(), eq(1L)); // userId가 1L인 것도 검증 } + @Test + @DisplayName("문제 배치 등록 v2 기능") + @WithMockCustomUser() + void registerProblemsV2() throws Exception { + given(problemService.registerProblemsV2(any(), eq(1L))).willReturn(List.of(1L, 2L)); + + ProblemRegisterV2BatchDto dto = new ProblemRegisterV2BatchDto(List.of( + new ProblemRegisterV2Dto( + null, + "memo1", + "reference1", + 1L, + LocalDateTime.now(), + List.of("https://example.com/problem1.png"), + List.of("https://example.com/answer1.png"), + null + ), + new ProblemRegisterV2Dto( + null, + "memo2", + "reference2", + 1L, + LocalDateTime.now(), + List.of("https://example.com/problem2.png"), + List.of(), + null + ) + )); + + mockMvc.perform(post("/api/problems/v2/batch") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.size()").value(2)) + .andExpect(jsonPath("$.data[0]").value(1)) + .andExpect(jsonPath("$.data[1]").value(2)); + + verify(problemService, times(1)).registerProblemsV2(any(), eq(1L)); + } + @Test @DisplayName("문제 이미지 등록") @WithMockCustomUser() diff --git a/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceTest.java b/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceTest.java index cf8b9ef5..9bf33635 100644 --- a/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceTest.java @@ -1,6 +1,8 @@ package com.aisip.OnO.backend.problem.service; import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.common.ratelimit.RateLimitService; +import com.aisip.OnO.backend.config.rabbitmq.producer.ProblemAnalysisProducer; import com.aisip.OnO.backend.util.fileupload.service.FileUploadService; import com.aisip.OnO.backend.folder.dto.FolderRegisterDto; import com.aisip.OnO.backend.folder.entity.Folder; @@ -8,12 +10,16 @@ import com.aisip.OnO.backend.folder.repository.FolderRepository; import com.aisip.OnO.backend.problem.dto.ProblemImageDataRegisterDto; import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; +import com.aisip.OnO.backend.problem.dto.ProblemRegisterV2BatchDto; import com.aisip.OnO.backend.problem.dto.ProblemRegisterV2Dto; import com.aisip.OnO.backend.problem.dto.ProblemResponseDto; +import com.aisip.OnO.backend.problem.entity.AnalysisStatus; import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.problem.entity.ProblemAnalysis; import com.aisip.OnO.backend.problem.entity.ProblemImageData; import com.aisip.OnO.backend.problem.entity.ProblemImageType; import com.aisip.OnO.backend.problem.exception.ProblemErrorCase; +import com.aisip.OnO.backend.problem.repository.ProblemAnalysisRepository; import com.aisip.OnO.backend.problem.repository.ProblemImageDataRepository; import com.aisip.OnO.backend.problem.repository.ProblemRepository; import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; @@ -38,6 +44,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; import static org.springframework.test.util.ReflectionTestUtils.setField; @@ -55,6 +62,9 @@ class ProblemServiceTest { @Autowired private ProblemImageDataRepository problemImageDataRepository; + @Autowired + private ProblemAnalysisRepository problemAnalysisRepository; + @Autowired private FolderRepository folderRepository; @@ -67,6 +77,12 @@ class ProblemServiceTest { @MockBean private FileUploadService fileUploadService; + @MockBean + private RateLimitService rateLimitService; + + @MockBean + private ProblemAnalysisProducer analysisProducer; + private final Long userId = 1L; private List problemList; @@ -77,6 +93,7 @@ void setUp() { problemList = new ArrayList<>(); folderList = new ArrayList<>(); + lenient().when(rateLimitService.tryConsume(anyString(), anyLong(), anyInt())).thenReturn(true); // 폴더 2개 생성 for (int f = 1; f <= 2; f++) { @@ -125,6 +142,7 @@ void tearDown() { problemSolveRepository.deleteAll(); problemImageDataRepository.deleteAll(); + problemAnalysisRepository.deleteAll(); problemRepository.deleteAll(); folderRepository.deleteAll(); } @@ -346,6 +364,80 @@ void registerProblemV2_nullFolderIdUsesRootFolder() { assertThat(problem.getFolder().getId()).isEqualTo(rootFolder.getId()); } + @Test + @DisplayName("문제 배치 등록 v2 - 정상 케이스") + void registerProblemsV2_success() { + Long folderId = folderList.get(0).getId(); + ProblemRegisterV2BatchDto dto = new ProblemRegisterV2BatchDto(List.of( + new ProblemRegisterV2Dto( + null, + "memo1", + "reference1", + folderId, + LocalDateTime.now(), + List.of("https://example.com/problem1.png"), + List.of("https://example.com/answer1.png"), + null + ), + new ProblemRegisterV2Dto( + null, + "memo2", + "reference2", + folderId, + LocalDateTime.now(), + List.of("https://example.com/problem2.png"), + List.of(), + null + ) + )); + + List problemIds = problemService.registerProblemsV2(dto, userId); + + assertThat(problemIds).hasSize(2); + assertThat(problemRepository.findAllByUserId(userId)).hasSize(problemList.size() + 2); + assertThat(problemImageDataRepository.findAllByProblemId(problemIds.get(0))).hasSize(2); + assertThat(problemImageDataRepository.findAllByProblemId(problemIds.get(1))).hasSize(1); + assertThat(problemAnalysisRepository.existsByProblemId(problemIds.get(0))).isTrue(); + assertThat(problemAnalysisRepository.existsByProblemId(problemIds.get(1))).isTrue(); + } + + @Test + @DisplayName("문제 배치 등록 v2 - 중간 실패 시 전체 롤백") + void registerProblemsV2_rollbackWhenFolderNotFound() { + long problemCount = problemRepository.countByUserId(userId); + long imageCount = problemImageDataRepository.count(); + + ProblemRegisterV2BatchDto dto = new ProblemRegisterV2BatchDto(List.of( + new ProblemRegisterV2Dto( + null, + "memo1", + "reference1", + folderList.get(0).getId(), + LocalDateTime.now(), + List.of("https://example.com/problem1.png"), + List.of(), + null + ), + new ProblemRegisterV2Dto( + null, + "memo2", + "reference2", + 999999L, + LocalDateTime.now(), + List.of("https://example.com/problem2.png"), + List.of(), + null + ) + )); + + assertThatThrownBy(() -> problemService.registerProblemsV2(dto, userId)) + .isInstanceOf(ApplicationException.class) + .hasMessageContaining(FolderErrorCase.FOLDER_NOT_FOUND.getMessage()); + + assertThat(problemRepository.countByUserId(userId)).isEqualTo(problemCount); + assertThat(problemImageDataRepository.count()).isEqualTo(imageCount); + } + @Test @DisplayName("문제 이미지 등록하기") void registerProblemImageData() { @@ -373,6 +465,26 @@ void registerProblemImageData() { assertThat(problem.getProblemImageDataList().get(imageDataSize - 1).getProblemImageType()).isEqualTo(ProblemImageType.SOLVE_IMAGE); } + @Test + @DisplayName("AI 분석 요청 제한 초과 시 예외 없이 상태 저장") + void analysisProblem_rateLimitExceeded() { + // given + Problem problem = problemList.get(0); + ProblemAnalysis analysis = ProblemAnalysis.createSkipped(problem); + problem.updateProblemAnalysis(analysis); + problemAnalysisRepository.save(analysis); + given(rateLimitService.tryConsume(eq("ai_analysis"), eq(userId), anyInt())).willReturn(false); + + // when + problemService.analysisProblem(problem.getId(), userId); + + // then + ProblemAnalysis savedAnalysis = problemAnalysisRepository.findByProblemId(problem.getId()).orElseThrow(); + assertThat(savedAnalysis.getStatus()).isEqualTo(AnalysisStatus.RATE_LIMIT_EXCEEDED); + assertThat(savedAnalysis.getErrorMessage()).contains("일일 요청 횟수"); + verify(analysisProducer, never()).sendAnalysisMessage(any()); + } + @Test @DisplayName("문제 정보(memo, reference) 수정") void updateProblemInfo() { diff --git a/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java b/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java index c506bf0d..373c1e45 100644 --- a/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java @@ -238,16 +238,20 @@ void updateUser() { void deleteUserById() { // Given Long userId = 1L; + User user = User.from(userRegisterDto); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); doNothing().when(userRepository).deleteById(userId); // When userService.deleteUserById(userId); // Then + assertThat(user.getIdentifier()).startsWith("deleted:" + userId + ":"); verify(practiceNoteService, times(1)).deleteAllPracticesByUser(userId); verify(problemService, times(1)).deleteAllUserProblems(userId); verify(folderService, times(1)).deleteAllUserFolders(userId); + verify(userRepository, times(1)).findById(userId); verify(userRepository, times(1)).deleteById(userId); - verify(userRepository, times(1)).flush(); + verify(userRepository, times(2)).flush(); } }