From 0b851d058a5dd09e6f875130d0fc99888568eb83 Mon Sep 17 00:00:00 2001 From: Jian Kim Date: Wed, 17 Sep 2025 22:21:58 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[EDMT-407]=20Loki=20=EB=9D=BC=EB=B2=A8=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0:=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=20=ED=95=84=ED=84=B0=EB=A7=81=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?label=20=EC=B6=94=EA=B0=80=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- promtail-config.yml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/promtail-config.yml b/promtail-config.yml index 9ea73838..209c8cc2 100644 --- a/promtail-config.yml +++ b/promtail-config.yml @@ -26,8 +26,6 @@ scrape_configs: expressions: timestamp: timestamp level: level - thread: thread - logger: logger message: message traceId: traceId userId: userId @@ -39,11 +37,12 @@ scrape_configs: expression: '.*(health|actuator|metrics|prometheus|favicon\.ico).*' source: requestUrl - # 동적 라벨 설정 (고카디널리티 라벨 최소화) + # 동적 라벨 설정 - labels: + timestamp: level: - logger: traceId: + requestUrl: # 타임스탬프 파싱 - timestamp: @@ -68,8 +67,6 @@ scrape_configs: expressions: timestamp: timestamp level: level - thread: thread - logger: logger message: message traceId: traceId userId: userId @@ -83,9 +80,10 @@ scrape_configs: # 동적 라벨 설정 (고카디널리티 라벨 최소화) - labels: + timestamp: level: - logger: traceId: + requestUrl: # 타임스탬프 파싱 - timestamp: From 051a2ed773688d972100d82ae4bc17a6747bc9f3 Mon Sep 17 00:00:00 2001 From: Jian Kim Date: Fri, 19 Sep 2025 20:44:37 +0900 Subject: [PATCH 2/5] =?UTF-8?q?[EDMT-452]=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?=EB=A9=94=ED=8A=B8=EB=A6=AD=20=EC=B6=94=EA=B0=80=20(#66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [feat] add metrics for student record updates using Micrometer * [feat] add AI generation metrics tracking for student records * [feat] rename metrics collection method and update logging for student record completion * [refac] change metrics collection method to AfterReturning for student record completion * [feat] add AI generation tracking and metrics for student records * [refac] update build.gradle to replace Spring Boot Web starter with validation starter * [fix] implement transaction-aware metrics collection for student record completion * [refac] enhance metrics collection logic to ensure business logic execution on errors * [refac] update type casting for memberId and recordId in metrics aspect * [refac] improve metrics collection by replacing Counter builder with direct counter registration and update logging level for error handling * [refac] refactor student record methods to use StudentRecordType enum and improve metrics collection * [refac] streamline generation tracking and metrics collection logic * [refac] add overloaded methods in StudentRecordAIFacade and StudentRecordFacade for controller compatibility * [refac] refactor StudentRecordAIController and StudentRecordFacade to improve type handling and streamline method calls --- build.gradle | 2 +- edukit-api/build.gradle | 6 +- .../controller/StudentRecordAIController.java | 9 +- .../facade/StudentRecordAIFacade.java | 14 +-- .../facade/StudentRecordFacade.java | 3 + .../annotation/AIGenerationMetrics.java | 11 ++ .../annotation/StudentRecordMetrics.java | 11 ++ edukit-core/build.gradle | 6 +- .../aop/StudentRecordMetricsAspect.java | 107 ++++++++++++++++++ .../service/GenerationTrackingService.java | 56 +++++++++ .../service/StudentRecordMetricsService.java | 54 +++++++++ 11 files changed, 265 insertions(+), 14 deletions(-) create mode 100644 edukit-common/src/main/java/com/edukit/common/annotation/AIGenerationMetrics.java create mode 100644 edukit-common/src/main/java/com/edukit/common/annotation/StudentRecordMetrics.java create mode 100644 edukit-core/src/main/java/com/edukit/core/studentrecord/aop/StudentRecordMetricsAspect.java create mode 100644 edukit-core/src/main/java/com/edukit/core/studentrecord/service/GenerationTrackingService.java create mode 100644 edukit-core/src/main/java/com/edukit/core/studentrecord/service/StudentRecordMetricsService.java diff --git a/build.gradle b/build.gradle index 839bac2b..54401e6f 100644 --- a/build.gradle +++ b/build.gradle @@ -43,7 +43,7 @@ subprojects { annotationProcessor 'org.projectlombok:lombok' // spring boot - implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework:spring-webflux' // spring boot configuration processor diff --git a/edukit-api/build.gradle b/edukit-api/build.gradle index 9c167ad7..6c4bc248 100644 --- a/edukit-api/build.gradle +++ b/edukit-api/build.gradle @@ -10,8 +10,7 @@ dependencies { implementation project(':edukit-common') runtimeOnly project(':edukit-external') - // Spring Boot Web - implementation 'org.springframework.boot:spring-boot-starter-web' + // validation implementation 'org.springframework.boot:spring-boot-starter-validation' // Spring Boot Security @@ -29,6 +28,9 @@ dependencies { // Prometheus Metrics implementation 'io.micrometer:micrometer-registry-prometheus' + // AOP Support for @Aspect + implementation 'org.springframework.boot:spring-boot-starter-aop' + // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' diff --git a/edukit-api/src/main/java/com/edukit/studentrecord/controller/StudentRecordAIController.java b/edukit-api/src/main/java/com/edukit/studentrecord/controller/StudentRecordAIController.java index 3111e71d..99abe84b 100644 --- a/edukit-api/src/main/java/com/edukit/studentrecord/controller/StudentRecordAIController.java +++ b/edukit-api/src/main/java/com/edukit/studentrecord/controller/StudentRecordAIController.java @@ -2,8 +2,10 @@ import com.edukit.common.EdukitResponse; import com.edukit.common.annotation.MemberId; +import com.edukit.core.studentrecord.db.entity.StudentRecord; import com.edukit.core.studentrecord.exception.StudentRecordErrorCode; import com.edukit.core.studentrecord.exception.StudentRecordException; +import com.edukit.core.studentrecord.service.StudentRecordService; import com.edukit.studentrecord.controller.request.StudentRecordPromptRequest; import com.edukit.studentrecord.facade.StudentRecordAIFacade; import com.edukit.studentrecord.facade.response.StudentRecordTaskResponse; @@ -25,14 +27,19 @@ public class StudentRecordAIController implements StudentRecordAIApi { private final StudentRecordAIFacade studentRecordAIFacade; + private final StudentRecordService studentRecordService; @PostMapping("/ai-generate/{recordId}") public ResponseEntity> aiGenerateStudentRecord( @MemberId final long memberId, @PathVariable final long recordId, @RequestBody @Valid final StudentRecordPromptRequest request) { + // 컨트롤러에서 직접 타입 조회 (1회 DB 조회) + StudentRecord studentRecord = studentRecordService.getRecordDetail(memberId, recordId); + + // 타입과 함께 호출 (AOP에서 DB 조회 없음) StudentRecordTaskResponse response = studentRecordAIFacade.createTaskId(memberId, recordId, - request.byteCount(), request.prompt()); + studentRecord.getStudentRecordType(), request.byteCount(), request.prompt()); return ResponseEntity.ok(EdukitResponse.success(response)); } diff --git a/edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordAIFacade.java b/edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordAIFacade.java index 0a88be53..12a2ff60 100644 --- a/edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordAIFacade.java +++ b/edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordAIFacade.java @@ -1,12 +1,12 @@ package com.edukit.studentrecord.facade; +import com.edukit.common.annotation.AIGenerationMetrics; import com.edukit.core.member.db.entity.Member; import com.edukit.core.member.service.MemberService; -import com.edukit.core.studentrecord.db.entity.StudentRecord; import com.edukit.core.studentrecord.db.entity.StudentRecordAITask; +import com.edukit.core.studentrecord.db.enums.StudentRecordType; import com.edukit.core.studentrecord.service.AITaskService; import com.edukit.core.studentrecord.service.SSEChannelManager; -import com.edukit.core.studentrecord.service.StudentRecordService; import com.edukit.core.studentrecord.util.AIPromptGenerator; import com.edukit.studentrecord.event.AITaskCreateEvent; import com.edukit.studentrecord.facade.response.StudentRecordTaskResponse; @@ -21,25 +21,25 @@ public class StudentRecordAIFacade { private final MemberService memberService; - private final StudentRecordService studentRecordService; private final AITaskService aiTaskService; private final SSEChannelManager sseChannelManager; private final ApplicationEventPublisher eventPublisher; @Transactional - public StudentRecordTaskResponse createTaskId(final long memberId, final long recordId, final int byteCount, + @AIGenerationMetrics + public StudentRecordTaskResponse createTaskId(final long memberId, final long recordId, + final StudentRecordType recordType, final int byteCount, final String userPrompt) { Member member = memberService.getMemberById(memberId); - StudentRecord studentRecord = studentRecordService.getRecordDetail(memberId, recordId); - String requestPrompt = AIPromptGenerator.createStreamingPrompt(studentRecord.getStudentRecordType(), byteCount, - userPrompt); + String requestPrompt = AIPromptGenerator.createStreamingPrompt(recordType, byteCount, userPrompt); StudentRecordAITask task = aiTaskService.createAITask(member, userPrompt); eventPublisher.publishEvent(AITaskCreateEvent.of(task, userPrompt, requestPrompt, byteCount)); return StudentRecordTaskResponse.of(String.valueOf(task.getId())); } + public SseEmitter createChannel(final long memberId, final String taskId) { aiTaskService.validateUserTask(memberId, taskId); diff --git a/edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordFacade.java b/edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordFacade.java index 2d03705f..f344d2ff 100644 --- a/edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordFacade.java +++ b/edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordFacade.java @@ -1,5 +1,6 @@ package com.edukit.studentrecord.facade; +import com.edukit.common.annotation.StudentRecordMetrics; import com.edukit.core.student.db.entity.Student; import com.edukit.core.student.service.ExcelService; import com.edukit.core.studentrecord.db.entity.StudentRecord; @@ -41,11 +42,13 @@ public StudentRecordsGetResponse getStudentRecords(final long memberId, final St } @Transactional + @StudentRecordMetrics public void updateStudentRecord(final long memberId, final long recordId, final String description) { StudentRecord studentRecord = studentRecordService.getRecordDetail(memberId, recordId); studentRecordService.updateStudentRecord(studentRecord, description); } + @Transactional(readOnly = true) public StudentRecordDetailResponse getStudentRecord(final long memberId, final long recordId) { StudentRecord recordDetail = studentRecordService.getRecordDetail(memberId, recordId); diff --git a/edukit-common/src/main/java/com/edukit/common/annotation/AIGenerationMetrics.java b/edukit-common/src/main/java/com/edukit/common/annotation/AIGenerationMetrics.java new file mode 100644 index 00000000..0e797321 --- /dev/null +++ b/edukit-common/src/main/java/com/edukit/common/annotation/AIGenerationMetrics.java @@ -0,0 +1,11 @@ +package com.edukit.common.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface AIGenerationMetrics { +} \ No newline at end of file diff --git a/edukit-common/src/main/java/com/edukit/common/annotation/StudentRecordMetrics.java b/edukit-common/src/main/java/com/edukit/common/annotation/StudentRecordMetrics.java new file mode 100644 index 00000000..f2a17fd7 --- /dev/null +++ b/edukit-common/src/main/java/com/edukit/common/annotation/StudentRecordMetrics.java @@ -0,0 +1,11 @@ +package com.edukit.common.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface StudentRecordMetrics { +} \ No newline at end of file diff --git a/edukit-core/build.gradle b/edukit-core/build.gradle index edb31ae1..feae52c4 100644 --- a/edukit-core/build.gradle +++ b/edukit-core/build.gradle @@ -13,9 +13,6 @@ dependencies { // Jackson implementation 'com.fasterxml.jackson.core:jackson-databind' - // Spring Web for SSE support - implementation 'org.springframework.boot:spring-boot-starter-web' - // JWT implementation 'io.jsonwebtoken:jjwt-api:0.11.5' implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' @@ -25,6 +22,9 @@ dependencies { implementation 'org.apache.poi:poi:5.4.0' implementation 'org.apache.poi:poi-ooxml:5.4.0' + // Micrometer for metrics + implementation 'io.micrometer:micrometer-core' + // QueryDSL implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" diff --git a/edukit-core/src/main/java/com/edukit/core/studentrecord/aop/StudentRecordMetricsAspect.java b/edukit-core/src/main/java/com/edukit/core/studentrecord/aop/StudentRecordMetricsAspect.java new file mode 100644 index 00000000..b469ca4f --- /dev/null +++ b/edukit-core/src/main/java/com/edukit/core/studentrecord/aop/StudentRecordMetricsAspect.java @@ -0,0 +1,107 @@ +package com.edukit.core.studentrecord.aop; + +import com.edukit.core.studentrecord.db.enums.StudentRecordType; +import com.edukit.core.studentrecord.service.GenerationTrackingService; +import com.edukit.core.studentrecord.service.StudentRecordMetricsService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.AfterReturning; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class StudentRecordMetricsAspect { + + private final StudentRecordMetricsService metricsService; + private final GenerationTrackingService generationTrackingService; + + @AfterReturning("@annotation(com.edukit.common.annotation.StudentRecordMetrics)") + public void collectCompletionMetrics(final JoinPoint joinPoint) { + Object[] args = joinPoint.getArgs(); + + if (args.length >= 4) { + long recordId = (Long) args[1]; + StudentRecordType recordType = (StudentRecordType) args[2]; + String description = (String) args[3]; + + try { + // 트랜잭션 커밋 후에만 메트릭 수집 및 정리 작업 수행 + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + try { + metricsService.recordCompletion(recordType, description); + + // 커밋 성공 후 생성 추적 정보 정리 (메모리 절약) + generationTrackingService.clearRecord(recordId); + + log.debug("Completion metrics recorded after transaction commit for recordId: {}", recordId); + + } catch (Exception e) { + log.warn("Error collecting completion metrics after commit for recordId: {}", recordId, e); + } + } + + @Override + public void afterCompletion(int status) { + if (status == STATUS_ROLLED_BACK) { + // 롤백 시에도 생성 추적 정보 정리 (메모리 누수 방지) + generationTrackingService.clearRecord(recordId); + log.debug("Transaction rolled back - cleared generation tracking for recordId: {}", recordId); + } + } + }); + } else { + metricsService.recordCompletion(recordType, description); + generationTrackingService.clearRecord(recordId); + log.warn("No transaction synchronization - recording metrics immediately for recordId: {}", recordId); + } + + } catch (Exception e) { + log.warn("Error setting up completion metrics collection", e); + } + } + } + + @Around("@annotation(com.edukit.common.annotation.AIGenerationMetrics)") + public Object collectAIGenerationMetrics(final ProceedingJoinPoint joinPoint) throws Throwable { + Object[] args = joinPoint.getArgs(); + + if (args.length >= 3) { + long recordId = (Long) args[1]; + StudentRecordType recordType = (StudentRecordType) args[2]; + + try { + boolean isFirstGeneration = generationTrackingService.isFirstGeneration(recordId); + + // 전체 AI 생성 요청 카운트 + metricsService.recordAIGenerationRequest(recordType); + + // 첫 생성 vs 재생성 구분 메트릭 + if (isFirstGeneration) { + metricsService.recordFirstGeneration(recordType); + log.debug("First generation request for recordId: {}", recordId); + } else { + metricsService.recordRegeneration(recordType); + log.debug("Regeneration request for recordId: {}", recordId); + } + + } catch (Exception e) { + // 메트릭 수집 실패는 로그만 남기고 비즈니스 로직은 계속 진행 + log.warn("Error collecting AI generation metrics for recordId: {}", recordId, e); + } + } + + // 비즈니스 로직은 반드시 1회만 실행, 예외는 그대로 전파 + return joinPoint.proceed(); + } +} diff --git a/edukit-core/src/main/java/com/edukit/core/studentrecord/service/GenerationTrackingService.java b/edukit-core/src/main/java/com/edukit/core/studentrecord/service/GenerationTrackingService.java new file mode 100644 index 00000000..f80095f1 --- /dev/null +++ b/edukit-core/src/main/java/com/edukit/core/studentrecord/service/GenerationTrackingService.java @@ -0,0 +1,56 @@ +package com.edukit.core.studentrecord.service; + +import java.time.LocalDateTime; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class GenerationTrackingService { + + private final ConcurrentHashMap generationCounts = new ConcurrentHashMap<>(); + + public boolean isFirstGeneration(long recordId) { + GenerationInfo info = generationCounts.compute(recordId, (key, existing) -> { + if (existing == null) { + return new GenerationInfo(1, LocalDateTime.now()); + } else { + existing.incrementCount(); + return existing; + } + }); + + boolean isFirst = info.getCount() == 1; + log.debug("RecordId: {}, Generation count: {}, Is first: {}", recordId, info.getCount(), isFirst); + + return isFirst; + } + + public void clearRecord(long recordId) { + generationCounts.remove(recordId); + log.debug("Cleared generation tracking for recordId: {}", recordId); + } + + private static class GenerationInfo { + private final AtomicInteger count; + @Getter + private final LocalDateTime firstGenerationTime; + + public GenerationInfo(int initialCount, LocalDateTime firstGenerationTime) { + this.count = new AtomicInteger(initialCount); + this.firstGenerationTime = firstGenerationTime; + } + + public void incrementCount() { + count.incrementAndGet(); + } + + public int getCount() { + return count.get(); + } + + } +} diff --git a/edukit-core/src/main/java/com/edukit/core/studentrecord/service/StudentRecordMetricsService.java b/edukit-core/src/main/java/com/edukit/core/studentrecord/service/StudentRecordMetricsService.java new file mode 100644 index 00000000..9fb80b89 --- /dev/null +++ b/edukit-core/src/main/java/com/edukit/core/studentrecord/service/StudentRecordMetricsService.java @@ -0,0 +1,54 @@ +package com.edukit.core.studentrecord.service; + +import com.edukit.core.studentrecord.db.enums.StudentRecordType; +import io.micrometer.core.instrument.MeterRegistry; +import java.nio.charset.StandardCharsets; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class StudentRecordMetricsService { + + private final MeterRegistry meterRegistry; + + private static final String COMPLETION_METRIC = "student_record_completion_total"; + private static final String AI_GENERATION_REQUEST_METRIC = "student_record_ai_generation_requests_total"; + private static final String AI_FIRST_GENERATION_METRIC = "student_record_ai_first_generation_total"; + private static final String AI_REGENERATION_METRIC = "student_record_ai_regeneration_total"; + + public void recordCompletion(final StudentRecordType type, final String description) { + if (isCompleted(type, description)) { + meterRegistry.counter(COMPLETION_METRIC, + "type", type.name(), "action", "completion") + .increment(); + } + } + + public void recordAIGenerationRequest(final StudentRecordType type) { + meterRegistry.counter(AI_GENERATION_REQUEST_METRIC, + "type", type.name(), "action", "ai_generation") + .increment(); + } + + public void recordFirstGeneration(final StudentRecordType type) { + meterRegistry.counter(AI_FIRST_GENERATION_METRIC, + "type", type.name(), "action", "first_generation") + .increment(); + } + + public void recordRegeneration(final StudentRecordType type) { + meterRegistry.counter(AI_REGENERATION_METRIC, + "type", type.name(), "action", "regeneration") + .increment(); + } + + private boolean isCompleted(final StudentRecordType type, final String description) { + if (description == null || description.trim().isEmpty()) { + return false; + } + + int minBytes = (type == StudentRecordType.SUBJECT) ? 1000 : 750; + return description.getBytes(StandardCharsets.UTF_8).length >= minBytes; + } +} From 7e2b614e636eec4cdc79468eea71c25c3c85f3ff Mon Sep 17 00:00:00 2001 From: Jian Kim Date: Sat, 20 Sep 2025 02:50:14 +0900 Subject: [PATCH 3/5] =?UTF-8?q?[EDMT-452]=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?=EB=A9=94=ED=8A=B8=EB=A6=AD=20=EC=A0=95=ED=95=A9=EC=84=B1=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [fix] enable AspectJ auto proxy support in EdukitApiApplication * [refac] refactor AI generation logic to retrieve student record type within AIFacade * [refac] rename StudentRecordMetricsService to StudentRecordMetricsCounter * [refac] rename GenerationTrackingService to RecordGenerationTracker and update related references * [fix] update argument count in collectAIGenerationMetrics method to support additional parameter * [fix] retrieve StudentRecordType from StudentRecord in collectCompletionMetrics method * [fix] update argument count in collectCompletionMetrics method to reflect new parameter structure * [fix] retrieve StudentRecordType from StudentRecord in StudentRecordMetricsAspect * [fix] refactor RecordGenerationTracker to use Redis for generation count tracking * [refac] refactor isFirstGeneration method to use Lua script for atomic increment and expiration in Redis * [fix] update RecordGenerationTracker to use configurable TTL for Redis expiration * [refac] update Lua script in RecordGenerationTracker to conditionally set Redis expiration based on TTL argument --- .../java/com/edukit/EdukitApiApplication.java | 2 + .../controller/StudentRecordAIController.java | 10 +-- .../facade/StudentRecordAIFacade.java | 10 +-- .../facade/StudentRecordFacade.java | 1 - .../aop/StudentRecordMetricsAspect.java | 70 ++++++------------- .../service/GenerationTrackingService.java | 56 --------------- .../service/RecordGenerationTracker.java | 65 +++++++++++++++++ ....java => StudentRecordMetricsCounter.java} | 2 +- 8 files changed, 100 insertions(+), 116 deletions(-) delete mode 100644 edukit-core/src/main/java/com/edukit/core/studentrecord/service/GenerationTrackingService.java create mode 100644 edukit-core/src/main/java/com/edukit/core/studentrecord/service/RecordGenerationTracker.java rename edukit-core/src/main/java/com/edukit/core/studentrecord/service/{StudentRecordMetricsService.java => StudentRecordMetricsCounter.java} (98%) diff --git a/edukit-api/src/main/java/com/edukit/EdukitApiApplication.java b/edukit-api/src/main/java/com/edukit/EdukitApiApplication.java index 4f549343..cc3df321 100644 --- a/edukit-api/src/main/java/com/edukit/EdukitApiApplication.java +++ b/edukit-api/src/main/java/com/edukit/EdukitApiApplication.java @@ -4,7 +4,9 @@ import java.util.TimeZone; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.EnableAspectJAutoProxy; +@EnableAspectJAutoProxy @SpringBootApplication public class EdukitApiApplication { diff --git a/edukit-api/src/main/java/com/edukit/studentrecord/controller/StudentRecordAIController.java b/edukit-api/src/main/java/com/edukit/studentrecord/controller/StudentRecordAIController.java index 99abe84b..43dc787f 100644 --- a/edukit-api/src/main/java/com/edukit/studentrecord/controller/StudentRecordAIController.java +++ b/edukit-api/src/main/java/com/edukit/studentrecord/controller/StudentRecordAIController.java @@ -2,7 +2,6 @@ import com.edukit.common.EdukitResponse; import com.edukit.common.annotation.MemberId; -import com.edukit.core.studentrecord.db.entity.StudentRecord; import com.edukit.core.studentrecord.exception.StudentRecordErrorCode; import com.edukit.core.studentrecord.exception.StudentRecordException; import com.edukit.core.studentrecord.service.StudentRecordService; @@ -31,15 +30,12 @@ public class StudentRecordAIController implements StudentRecordAIApi { @PostMapping("/ai-generate/{recordId}") public ResponseEntity> aiGenerateStudentRecord( - @MemberId final long memberId, - @PathVariable final long recordId, + @MemberId final long memberId, @PathVariable final long recordId, @RequestBody @Valid final StudentRecordPromptRequest request) { - // 컨트롤러에서 직접 타입 조회 (1회 DB 조회) - StudentRecord studentRecord = studentRecordService.getRecordDetail(memberId, recordId); - // 타입과 함께 호출 (AOP에서 DB 조회 없음) StudentRecordTaskResponse response = studentRecordAIFacade.createTaskId(memberId, recordId, - studentRecord.getStudentRecordType(), request.byteCount(), request.prompt()); + request.byteCount(), request.prompt() + ); return ResponseEntity.ok(EdukitResponse.success(response)); } diff --git a/edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordAIFacade.java b/edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordAIFacade.java index 12a2ff60..f1e7ae80 100644 --- a/edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordAIFacade.java +++ b/edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordAIFacade.java @@ -3,10 +3,11 @@ import com.edukit.common.annotation.AIGenerationMetrics; import com.edukit.core.member.db.entity.Member; import com.edukit.core.member.service.MemberService; +import com.edukit.core.studentrecord.db.entity.StudentRecord; import com.edukit.core.studentrecord.db.entity.StudentRecordAITask; -import com.edukit.core.studentrecord.db.enums.StudentRecordType; import com.edukit.core.studentrecord.service.AITaskService; import com.edukit.core.studentrecord.service.SSEChannelManager; +import com.edukit.core.studentrecord.service.StudentRecordService; import com.edukit.core.studentrecord.util.AIPromptGenerator; import com.edukit.studentrecord.event.AITaskCreateEvent; import com.edukit.studentrecord.facade.response.StudentRecordTaskResponse; @@ -22,17 +23,18 @@ public class StudentRecordAIFacade { private final MemberService memberService; private final AITaskService aiTaskService; + private final StudentRecordService studentRecordService; private final SSEChannelManager sseChannelManager; private final ApplicationEventPublisher eventPublisher; @Transactional @AIGenerationMetrics - public StudentRecordTaskResponse createTaskId(final long memberId, final long recordId, - final StudentRecordType recordType, final int byteCount, + public StudentRecordTaskResponse createTaskId(final long memberId, final long recordId, final int byteCount, final String userPrompt) { Member member = memberService.getMemberById(memberId); + StudentRecord studentRecord = studentRecordService.getRecordDetail(memberId, recordId); - String requestPrompt = AIPromptGenerator.createStreamingPrompt(recordType, byteCount, userPrompt); + String requestPrompt = AIPromptGenerator.createStreamingPrompt(studentRecord.getStudentRecordType(), byteCount, userPrompt); StudentRecordAITask task = aiTaskService.createAITask(member, userPrompt); eventPublisher.publishEvent(AITaskCreateEvent.of(task, userPrompt, requestPrompt, byteCount)); diff --git a/edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordFacade.java b/edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordFacade.java index f344d2ff..0a75480c 100644 --- a/edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordFacade.java +++ b/edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordFacade.java @@ -48,7 +48,6 @@ public void updateStudentRecord(final long memberId, final long recordId, final studentRecordService.updateStudentRecord(studentRecord, description); } - @Transactional(readOnly = true) public StudentRecordDetailResponse getStudentRecord(final long memberId, final long recordId) { StudentRecord recordDetail = studentRecordService.getRecordDetail(memberId, recordId); diff --git a/edukit-core/src/main/java/com/edukit/core/studentrecord/aop/StudentRecordMetricsAspect.java b/edukit-core/src/main/java/com/edukit/core/studentrecord/aop/StudentRecordMetricsAspect.java index b469ca4f..729e2958 100644 --- a/edukit-core/src/main/java/com/edukit/core/studentrecord/aop/StudentRecordMetricsAspect.java +++ b/edukit-core/src/main/java/com/edukit/core/studentrecord/aop/StudentRecordMetricsAspect.java @@ -1,8 +1,10 @@ package com.edukit.core.studentrecord.aop; +import com.edukit.core.studentrecord.db.entity.StudentRecord; import com.edukit.core.studentrecord.db.enums.StudentRecordType; -import com.edukit.core.studentrecord.service.GenerationTrackingService; -import com.edukit.core.studentrecord.service.StudentRecordMetricsService; +import com.edukit.core.studentrecord.service.RecordGenerationTracker; +import com.edukit.core.studentrecord.service.StudentRecordMetricsCounter; +import com.edukit.core.studentrecord.service.StudentRecordService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.JoinPoint; @@ -11,8 +13,6 @@ import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.springframework.stereotype.Component; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; @Slf4j @Aspect @@ -20,54 +20,27 @@ @RequiredArgsConstructor public class StudentRecordMetricsAspect { - private final StudentRecordMetricsService metricsService; - private final GenerationTrackingService generationTrackingService; + private final StudentRecordMetricsCounter metricsService; + private final RecordGenerationTracker recordGenerationTracker; + private final StudentRecordService studentRecordService; - @AfterReturning("@annotation(com.edukit.common.annotation.StudentRecordMetrics)") + @AfterReturning(pointcut = "@annotation(com.edukit.common.annotation.StudentRecordMetrics)") public void collectCompletionMetrics(final JoinPoint joinPoint) { Object[] args = joinPoint.getArgs(); - if (args.length >= 4) { + if (args.length == 3) { + long memberId = (Long) args[0]; long recordId = (Long) args[1]; - StudentRecordType recordType = (StudentRecordType) args[2]; - String description = (String) args[3]; + String description = (String) args[2]; - try { - // 트랜잭션 커밋 후에만 메트릭 수집 및 정리 작업 수행 - if (TransactionSynchronizationManager.isSynchronizationActive()) { - TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { - @Override - public void afterCommit() { - try { - metricsService.recordCompletion(recordType, description); - - // 커밋 성공 후 생성 추적 정보 정리 (메모리 절약) - generationTrackingService.clearRecord(recordId); - - log.debug("Completion metrics recorded after transaction commit for recordId: {}", recordId); - - } catch (Exception e) { - log.warn("Error collecting completion metrics after commit for recordId: {}", recordId, e); - } - } - - @Override - public void afterCompletion(int status) { - if (status == STATUS_ROLLED_BACK) { - // 롤백 시에도 생성 추적 정보 정리 (메모리 누수 방지) - generationTrackingService.clearRecord(recordId); - log.debug("Transaction rolled back - cleared generation tracking for recordId: {}", recordId); - } - } - }); - } else { - metricsService.recordCompletion(recordType, description); - generationTrackingService.clearRecord(recordId); - log.warn("No transaction synchronization - recording metrics immediately for recordId: {}", recordId); - } + StudentRecord studentRecord = studentRecordService.getRecordDetail(memberId, recordId); + StudentRecordType recordType = studentRecord.getStudentRecordType(); + try { + metricsService.recordCompletion(recordType, description); } catch (Exception e) { - log.warn("Error setting up completion metrics collection", e); + // 메트릭 수집 실패는 로그만 남기고 비즈니스 로직은 계속 진행 + log.warn("Error collecting completion metrics for recordType: {}", recordType, e); } } } @@ -76,12 +49,15 @@ public void afterCompletion(int status) { public Object collectAIGenerationMetrics(final ProceedingJoinPoint joinPoint) throws Throwable { Object[] args = joinPoint.getArgs(); - if (args.length >= 3) { + if (args.length == 4) { + long memberId = (Long) args[0]; long recordId = (Long) args[1]; - StudentRecordType recordType = (StudentRecordType) args[2]; + + StudentRecord studentRecord = studentRecordService.getRecordDetail(memberId, recordId); + StudentRecordType recordType = studentRecord.getStudentRecordType(); try { - boolean isFirstGeneration = generationTrackingService.isFirstGeneration(recordId); + boolean isFirstGeneration = recordGenerationTracker.isFirstGeneration(recordId); // 전체 AI 생성 요청 카운트 metricsService.recordAIGenerationRequest(recordType); diff --git a/edukit-core/src/main/java/com/edukit/core/studentrecord/service/GenerationTrackingService.java b/edukit-core/src/main/java/com/edukit/core/studentrecord/service/GenerationTrackingService.java deleted file mode 100644 index f80095f1..00000000 --- a/edukit-core/src/main/java/com/edukit/core/studentrecord/service/GenerationTrackingService.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.edukit.core.studentrecord.service; - -import java.time.LocalDateTime; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicInteger; -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Slf4j -@Service -public class GenerationTrackingService { - - private final ConcurrentHashMap generationCounts = new ConcurrentHashMap<>(); - - public boolean isFirstGeneration(long recordId) { - GenerationInfo info = generationCounts.compute(recordId, (key, existing) -> { - if (existing == null) { - return new GenerationInfo(1, LocalDateTime.now()); - } else { - existing.incrementCount(); - return existing; - } - }); - - boolean isFirst = info.getCount() == 1; - log.debug("RecordId: {}, Generation count: {}, Is first: {}", recordId, info.getCount(), isFirst); - - return isFirst; - } - - public void clearRecord(long recordId) { - generationCounts.remove(recordId); - log.debug("Cleared generation tracking for recordId: {}", recordId); - } - - private static class GenerationInfo { - private final AtomicInteger count; - @Getter - private final LocalDateTime firstGenerationTime; - - public GenerationInfo(int initialCount, LocalDateTime firstGenerationTime) { - this.count = new AtomicInteger(initialCount); - this.firstGenerationTime = firstGenerationTime; - } - - public void incrementCount() { - count.incrementAndGet(); - } - - public int getCount() { - return count.get(); - } - - } -} diff --git a/edukit-core/src/main/java/com/edukit/core/studentrecord/service/RecordGenerationTracker.java b/edukit-core/src/main/java/com/edukit/core/studentrecord/service/RecordGenerationTracker.java new file mode 100644 index 00000000..c41686ca --- /dev/null +++ b/edukit-core/src/main/java/com/edukit/core/studentrecord/service/RecordGenerationTracker.java @@ -0,0 +1,65 @@ +package com.edukit.core.studentrecord.service; + +import java.util.Collections; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RecordGenerationTracker { + + @Value("${record.generation.ttl}") + private long ttlSeconds; + + private static final String KEY_PREFIX = "sr:gen:"; + private static final String LUA_SCRIPT = + "local ttl = tonumber(ARGV[1]) or 0 " + + "local c = redis.call('INCR', KEYS[1]) " + + "if c == 1 and ttl > 0 then " + + " redis.call('EXPIRE', KEYS[1], ttl) " + + "end " + + "return c"; + + private static final DefaultRedisScript INCR_EXPIRE_SCRIPT; + + + static { + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setScriptText(LUA_SCRIPT); + script.setResultType(Long.class); + INCR_EXPIRE_SCRIPT = script; + } + + private final StringRedisTemplate redisTemplate; + + public boolean isFirstGeneration(long recordId) { + String key = getCountKey(recordId); + Long newCount = redisTemplate.execute( + INCR_EXPIRE_SCRIPT, + Collections.singletonList(key), + String.valueOf(ttlSeconds) + ); + + if (newCount == null) { + log.warn("Redis script returned null for key: {}", key); + return false; + } + + boolean isFirst = newCount == 1L; + if (isFirst) { + log.debug("RecordId: {}, Generation count set to 1 (first)", recordId); + } else { + log.debug("RecordId: {}, Generation count: {} (regeneration)", recordId, newCount); + } + return isFirst; + } + + private String getCountKey(long recordId) { + return KEY_PREFIX + recordId + ":count"; + } +} diff --git a/edukit-core/src/main/java/com/edukit/core/studentrecord/service/StudentRecordMetricsService.java b/edukit-core/src/main/java/com/edukit/core/studentrecord/service/StudentRecordMetricsCounter.java similarity index 98% rename from edukit-core/src/main/java/com/edukit/core/studentrecord/service/StudentRecordMetricsService.java rename to edukit-core/src/main/java/com/edukit/core/studentrecord/service/StudentRecordMetricsCounter.java index 9fb80b89..1ff99e06 100644 --- a/edukit-core/src/main/java/com/edukit/core/studentrecord/service/StudentRecordMetricsService.java +++ b/edukit-core/src/main/java/com/edukit/core/studentrecord/service/StudentRecordMetricsCounter.java @@ -8,7 +8,7 @@ @Component @RequiredArgsConstructor -public class StudentRecordMetricsService { +public class StudentRecordMetricsCounter { private final MeterRegistry meterRegistry; From 815a1630fb9b10c5f8f5036869063828100d29b4 Mon Sep 17 00:00:00 2001 From: Jian Kim Date: Sat, 20 Sep 2025 13:12:58 +0900 Subject: [PATCH 4/5] =?UTF-8?q?[EDMT-437]=20=ED=8F=AC=ED=8A=B8=20Switch=20?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=EC=97=90=20nginx=20=EC=BB=A8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=84=88=20=EC=8B=A4=ED=96=89=20=EB=B3=B4=EC=9E=A5=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [fix] enhance nginx deployment script with container check and target selection in dev * [fix] enhance nginx deployment script with container check and dynamic target selection in prod --- .github/workflows/api-dev-cd.yml | 9 +++++++++ .github/workflows/api-prod-cd.yml | 8 ++++++++ 2 files changed, 17 insertions(+) diff --git a/.github/workflows/api-dev-cd.yml b/.github/workflows/api-dev-cd.yml index 163fdea6..274827b5 100644 --- a/.github/workflows/api-dev-cd.yml +++ b/.github/workflows/api-dev-cd.yml @@ -153,14 +153,23 @@ jobs: - name: Switch nginx upstream and reload run: | + # 1️⃣ nginx 컨테이너 체크 & 실행 + if ! docker ps --format '{{.Names}}' | grep -q '^nginx$'; then + echo "nginx not running. starting with docker-compose..." + docker-compose -f docker-compose.dev.yml up -d nginx + fi + + # 2️⃣ 신규 target 결정 if [ "${{ steps.current.outputs.CURRENT }}" = "blue" ]; then NEW_TARGET="app-green:8080" else NEW_TARGET="app-blue:8080" fi + # 3️⃣ nginx 설정 변경 & reload docker exec nginx bash -c \ "echo 'set \$service_url $NEW_TARGET;' > /etc/nginx/conf.d/service-url.inc && nginx -t && nginx -s reload" + - name: Stop and remove old container run: | diff --git a/.github/workflows/api-prod-cd.yml b/.github/workflows/api-prod-cd.yml index 04bc3768..a1c326f7 100644 --- a/.github/workflows/api-prod-cd.yml +++ b/.github/workflows/api-prod-cd.yml @@ -153,12 +153,20 @@ jobs: - name: Switch nginx upstream and reload run: | + # 1️⃣ nginx 컨테이너 체크 & 실행 + if ! docker ps --format '{{.Names}}' | grep -q '^nginx$'; then + echo "nginx not running. starting with docker-compose..." + docker-compose -f docker-compose.dev.yml up -d nginx + fi + + # 2️⃣ 신규 target 결정 if [ "${{ steps.current.outputs.CURRENT }}" = "blue" ]; then NEW_TARGET="app-green:8080" else NEW_TARGET="app-blue:8080" fi + # 3️⃣ nginx 설정 변경 & reload docker exec nginx bash -c \ "echo 'set \$service_url $NEW_TARGET;' > /etc/nginx/conf.d/service-url.inc && nginx -t && nginx -s reload" From c7b1ee29691dc9d41115175df73ac3a2b5f2a7fe Mon Sep 17 00:00:00 2001 From: Jian Kim Date: Sun, 21 Sep 2025 01:03:46 +0900 Subject: [PATCH 5/5] =?UTF-8?q?[EDMT-451]=20Claude=20Code=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C=20=EB=8F=84=EA=B5=AC=20=EB=B0=8F=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=ED=99=94=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [feat] add automated commit message generator and pull request creator * [feat] update PR template to improve clarity and structure * [feat] add comprehensive test generator documentation and update PR template * [refac] enforce mandatory test profile usage and update test configuration guidelines * [refac] enhance commit script safety by restricting to staged files only * [refac] require user confirmation before pushing to remote in PR template * [docs] add manual rollback guidance for Flyway Community Edition --- .claude/agents/flyway-migration-generator.md | 62 +++++++ .claude/agents/swagger-documenter.md | 33 ++++ .claude/agents/test-generator.md | 185 +++++++++++++++++++ .claude/commands/commit.md | 74 ++++++++ .claude/commands/pr.md | 106 +++++++++++ 5 files changed, 460 insertions(+) create mode 100644 .claude/agents/flyway-migration-generator.md create mode 100644 .claude/agents/swagger-documenter.md create mode 100644 .claude/agents/test-generator.md create mode 100644 .claude/commands/commit.md create mode 100644 .claude/commands/pr.md diff --git a/.claude/agents/flyway-migration-generator.md b/.claude/agents/flyway-migration-generator.md new file mode 100644 index 00000000..27f297f1 --- /dev/null +++ b/.claude/agents/flyway-migration-generator.md @@ -0,0 +1,62 @@ +# Flyway Migration Generator Agent + +## Description +Automatically generates Flyway migration files when database schema changes are detected in JPA entities or when manual schema modifications are needed for the EduKit application. + +## Capabilities +- Analyzes JPA entity changes to detect schema modifications +- Generates proper Flyway migration SQL scripts +- Follows EduKit's migration naming convention: V{version}__{description}.sql +- Creates migrations for: + - New tables and columns + - Index additions/modifications + - Foreign key constraints + - Data type changes + - Table/column renames +- Validates migration syntax for MySQL +- Ensures timezone considerations (Asia/Seoul) + +## Usage Instructions +This agent should be used when: +- JPA entities are modified (new fields, annotations, etc.) +- Database schema needs manual changes +- New tables or relationships are required +- Index optimization is needed +- Data migration scripts are required + +## EduKit-Specific Context +- Database: MySQL on AWS RDS +- Timezone: Asia/Seoul +- Migration location: `edukit-api/src/main/resources/db/migration/` +- Naming convention: `V{version}__{description}.sql` +- JPA validation mode (no auto-DDL) +- Entities are in edukit-core module +- Multi-module Spring Boot application structure + +## Migration File Template +```sql +-- Migration: V{version}__{description}.sql +-- Description: {detailed description} +-- Author: Generated by Claude Code +-- Date: {current date} + +-- Add your migration SQL here +-- Example: +-- CREATE TABLE example_table ( +-- id BIGINT AUTO_INCREMENT PRIMARY KEY, +-- name VARCHAR(255) NOT NULL, +-- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, +-- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +-- ); + +-- Manual Rollback Guide (Flyway Community Edition): +-- To rollback this migration, manually execute: +-- DROP TABLE example_table; +``` + +## Expected Output +- Properly versioned Flyway migration files +- MySQL-compatible SQL syntax +- Appropriate indexes and constraints +- Timezone-aware timestamp handling +- Manual rollback guidance (Flyway Community Edition) \ No newline at end of file diff --git a/.claude/agents/swagger-documenter.md b/.claude/agents/swagger-documenter.md new file mode 100644 index 00000000..49211d07 --- /dev/null +++ b/.claude/agents/swagger-documenter.md @@ -0,0 +1,33 @@ +# Swagger API Documenter Agent + +## Description +Automatically generates and updates Swagger/OpenAPI documentation when new controllers or API endpoints are added to the EduKit Spring Boot application. + +## Capabilities +- Analyzes Spring Boot controllers to extract API information +- Generates proper Swagger annotations (@Operation, @ApiResponse, @Schema, etc.) +- Updates existing controllers with missing documentation +- Validates API documentation completeness +- Generates OpenAPI 3.0 specification files +- Ensures consistency with EduKit's API versioning pattern (/v1/, /v2/) + +## Usage Instructions +This agent should be used when: +- New REST controllers are created +- New endpoints are added to existing controllers +- API documentation is missing or incomplete +- Swagger annotations need to be updated + +## EduKit-Specific Context +- Multi-module Spring Boot application (edukit-api, edukit-core, edukit-external, edukit-common) +- Controllers are in edukit-api module +- API versioning follows /v1/, /v2/ pattern +- Uses Spring Boot 3.5.3 with Java 21 +- JWT authentication with Spring Security +- Request/response DTOs follow facade pattern + +## Expected Output +- Complete Swagger annotations on controllers and methods +- Proper @Schema annotations on DTOs +- API documentation following OpenAPI 3.0 standards +- Updated application.yml with Swagger configuration if needed \ No newline at end of file diff --git a/.claude/agents/test-generator.md b/.claude/agents/test-generator.md new file mode 100644 index 00000000..d0501d16 --- /dev/null +++ b/.claude/agents/test-generator.md @@ -0,0 +1,185 @@ +# Comprehensive Test Generator Agent + +## Description +Generates comprehensive test suites including unit tests, integration tests, and edge case scenarios with special focus on concurrency, deadlock prevention, and exception handling for the EduKit Spring Boot application. + +## Capabilities +### 🧪 Test Types Generated +- **Unit Tests**: Service layer, Repository layer, Utility classes +- **Integration Tests**: Controller tests, Database integration, External API tests +- **Concurrency Tests**: Thread safety, Race conditions, Deadlock scenarios +- **Exception Tests**: Business exceptions, Validation errors, System failures +- **Security Tests**: Authentication, Authorization, Input validation +- **Performance Tests**: Load testing, Memory usage, Query performance + +### 🎯 Test Scenarios Covered +- **Happy Path**: Normal flow scenarios +- **Edge Cases**: Boundary conditions, Null values, Empty collections +- **Error Handling**: Exception propagation, Error responses, Rollback scenarios +- **Concurrency Issues**: + - Simultaneous user operations + - Database transaction conflicts + - Redis cache race conditions + - JWT token concurrent access +- **Security Vulnerabilities**: + - SQL injection attempts + - XSS prevention + - CSRF protection + - Rate limiting + +## EduKit-Specific Test Patterns + +### 🏗️ Architecture Testing +```java +// Service Layer Tests +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + @Mock private UserRepository userRepository; + @Mock private RedisTemplate redisTemplate; + + // Concurrency test example + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + void shouldHandleConcurrentUserCreation() throws InterruptedException { + // Multi-thread user creation test + } +} + +// Controller Integration Tests +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") // REQUIRED: Always use test profile +class UserControllerIntegrationTest { + // JWT authentication tests + // CORS tests + // API versioning tests +} +``` + +### 🗄️ Database Testing +```java +// JPA Repository Tests +@DataJpaTest +@ActiveProfiles("test") // REQUIRED: Always use test profile +class UserRepositoryTest { + // Transaction isolation tests + // Deadlock prevention tests + // Timezone handling tests (Asia/Seoul) +} + +// Flyway Migration Tests +@Sql(scripts = "/db/test-data.sql") +@Transactional +class MigrationTest { + // Schema validation tests + // Data migration integrity tests +} +``` + +### ⚡ Concurrency & Performance Testing +```java +// Redis Cache Concurrency +@Test +void shouldHandleRedisConcurrentAccess() { + // Multiple threads accessing same cache key + // Cache invalidation race conditions + // Distributed lock testing +} + +// Database Connection Pool +@Test +void shouldHandleConnectionPoolExhaustion() { + // Connection leak detection + // Pool saturation scenarios + // Transaction timeout handling +} + +// JWT Token Concurrency +@Test +void shouldHandleSimultaneousTokenOperations() { + // Concurrent token validation + // Token refresh race conditions + // Session management conflicts +} +``` + +### 🛡️ Security Testing +```java +// Authentication Tests +@WithMockUser(roles = "USER") +@Test +void shouldPreventUnauthorizedAccess() { + // Role-based access control + // JWT token validation + // Session hijacking prevention +} + +// Input Validation Tests +@ParameterizedTest +@ValueSource(strings = {"", "'; DROP TABLE users; --"}) +void shouldSanitizeUserInput(String maliciousInput) { + // XSS prevention + // SQL injection prevention + // Input length validation +} +``` + +## Test Configuration Templates + +### 🔧 Test Properties +**Use existing `edukit-api/src/test/resources/application-test.yml`** +- MySQL on port 3307 (container-based) +- Redis on port 6370 (container-based) +- Mock AWS services +- Test JWT configuration + +### 📦 Test Dependencies +```gradle +testImplementation 'org.springframework.boot:spring-boot-starter-test' +testImplementation 'org.springframework.security:spring-security-test' +testImplementation 'org.testcontainers:mysql' +testImplementation 'org.testcontainers:junit-jupiter' +testImplementation 'com.github.tomakehurst:wiremock-jre8' +testImplementation 'org.awaitility:awaitility' +``` + +## Usage Instructions +This agent should be used when: +- New services or controllers are created +- Critical business logic is implemented +- External integrations are added +- Performance-critical code is written +- Security-sensitive operations are implemented +- Database operations involve complex transactions + +## Test Generation Strategy +1. **Analyze Code Structure**: Identify testable components +2. **Detect Dependencies**: Mock external services, databases +3. **Identify Risk Areas**: Concurrency, security, performance +4. **Generate Test Matrix**: Cover all scenarios systematically +5. **Create Test Data**: Realistic test datasets +6. **Validate Coverage**: Ensure high code coverage + +## ⚠️ MANDATORY TEST REQUIREMENTS +- **ALL test classes MUST use `@ActiveProfiles("test")`** +- **NO test should run without test profile** +- **Always verify test uses application-test.yml configuration** + +## Expected Output +- Complete test classes with proper annotations +- Mock configurations for external dependencies +- Parameterized tests for edge cases +- Concurrency tests using ExecutorService +- Performance benchmarks using JMH +- Security tests with penetration scenarios +- Test data builders and fixtures +- Test documentation explaining test scenarios + +## EduKit-Specific Context +- Multi-module Spring Boot application structure +- MySQL database with Asia/Seoul timezone +- Redis caching layer +- JWT authentication with 2-week expiration +- AWS services integration (S3, SES, SQS) +- OpenAI API integration +- Blue-green deployment considerations +- Korean localization requirements \ No newline at end of file diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md new file mode 100644 index 00000000..38a30249 --- /dev/null +++ b/.claude/commands/commit.md @@ -0,0 +1,74 @@ +# Auto Commit Command + +## Slash Command +`/commit` + +## Description +Automatically analyzes staged changes and generates appropriate commit messages following EduKit's commit conventions and Jira ticket format. + +## Functionality +- Analyzes all staged files and changes +- Detects the type of changes (feature, fix, refactor, docs, etc.) +- Generates commit messages in EduKit format: `[prefix] descriptive message` +- Follows conventional commit patterns +- Includes Korean descriptions when appropriate for Korean team members +- **SAFETY**: Only commits already staged files (no auto-staging by default) + +## Commit Message Patterns +- `[feat] 새로운 기능: {feature description}` - for new features +- `[fix] 버그 수정: {bug description}` - for bug fixes +- `[refac] 리팩토링: {refactor description}` - for code refactoring +- `[docs] 문서 업데이트: {docs description}` - for documentation +- `[test] 테스트 추가: {test description}` - for tests +- `[infra] 설정 변경: {config description}` - for configuration changes +- `[dependency] 의존성 업데이트: {dependency description}` - for dependency updates + +## EduKit-Specific Context +- Project uses Jira tickets with EDMT prefix +- Multi-module Spring Boot application +- Team uses both Korean and English in commit messages +- Follows clean architecture principles +- Common file patterns: + - Controllers: `edukit-api/src/main/java/.../controller/` + - Services: `edukit-core/src/main/java/.../service/` + - Entities: `edukit-core/src/main/java/.../domain/` + - Migrations: `edukit-api/src/main/resources/db/migration/` + - Configuration: `application-*.yml` + +## Usage Examples +```bash +# User types: +/commit + +# Agent analyzes changes and generates: +[EDMT-123] 새로운 기능: 사용자 인증 API 엔드포인트 추가 + +🤖 Generated with Claude Code +Co-Authored-By: Claude +``` + +## Expected Behavior +1. **Check for staged changes** - abort if nothing staged +2. Analyze current git status and staged changes only +3. Determine the primary type of changes +4. Extract or prompt for Jira ticket number if not found +5. Generate descriptive commit message +6. Execute git commit with generated message +7. Include Claude Code attribution + +## Safety Options +- Default: Only commit staged files +- `--stage`: Interactive staging with `git add -p` +- `--stage-all`: Stage all modified files (use with caution) + +## Usage Examples +```bash +# Safe default - only staged files +/commit + +# Interactive staging first +/commit --stage + +# Stage all (dangerous - requires confirmation) +/commit --stage-all +``` diff --git a/.claude/commands/pr.md b/.claude/commands/pr.md new file mode 100644 index 00000000..cb5685cb --- /dev/null +++ b/.claude/commands/pr.md @@ -0,0 +1,106 @@ +# Pull Request Automation Command + +## Slash Command +`/pr` + +## Description +Automatically creates pull requests with comprehensive summaries, test plans, and proper formatting for the EduKit project using GitHub CLI. + +## Functionality +- Analyzes all commits in the current branch since diverging from develop +- Generates detailed PR title and description +- Creates comprehensive test plan based on changes +- Includes Jira ticket references +- Follows EduKit's PR template format +- **SAFETY**: Requires user confirmation before pushing to remote +- Uses Korean descriptions when appropriate + +## PR Template Format +```markdown +## 📣 Jira Ticket + +[EDMT-] + + +## 👩‍💻 작업 내용 + + + +## 📝 리뷰 요청 & 논의하고 싶은 내용 + + + +## 📸 스크린 샷 (선택) + +## Test Plan +### ✅ Manual Testing Checklist +- [ ] {Test case 1} +- [ ] {Test case 2} +- [ ] {Test case 3} + +### 🧪 Automated Tests +- [ ] Unit tests pass: `./gradlew test` +- [ ] Integration tests pass +- [ ] Build succeeds: `./gradlew build` + +### 🔍 Code Quality +- [ ] Code review completed +- [ ] No new warnings or errors +- [ ] Security considerations reviewed + +## Deployment Notes +- [ ] Database migrations included (if applicable) +- [ ] Environment variables updated (if needed) +- [ ] AWS resources configured (if required) + +🤖 Generated with Claude Code +``` + +## EduKit-Specific Context +- Base branch: `develop` +- Jira project: EDMT +- Multi-module Spring Boot application +- Uses blue-green deployment +- AWS infrastructure (S3, SES, SQS, RDS, ElastiCache) +- Korean team communication preferred +- Common change patterns: + - API additions in edukit-api + - Business logic in edukit-core + - External integrations in edukit-external + - Database migrations in Flyway + +## Usage Examples +```bash +# User types: +/pr + +# Agent analyzes branch and creates PR with title: +[EDMT-123] 사용자 인증 API 개선 및 JWT 토큰 갱신 로직 추가 +``` + +## Expected Behavior +1. Check git status and current branch +2. Compare with develop branch to get all commits +3. Analyze changed files and commit messages +4. Extract Jira ticket numbers +5. Generate comprehensive PR title and description +6. **Check if branch needs pushing** - ask user for confirmation +7. Create PR using GitHub CLI (only if remote branch exists) +8. Return PR URL for easy access + +## Safety Options +- Default: No automatic pushing - user confirmation required +- `--push`: Auto-push without confirmation (use with caution) +- `--no-push`: Create PR draft only (local analysis) + +## Usage Examples +```bash +# Safe default - asks before pushing +/pr + +# Auto-push (use carefully) +/pr --push + +# Analysis only, no remote operations +/pr --no-push +```