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 +``` 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" 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/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 3111e71d..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 @@ -4,6 +4,7 @@ import com.edukit.common.annotation.MemberId; 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 +26,16 @@ 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, + @MemberId final long memberId, @PathVariable final long recordId, @RequestBody @Valid final StudentRecordPromptRequest request) { + StudentRecordTaskResponse response = studentRecordAIFacade.createTaskId(memberId, recordId, - 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 0a88be53..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 @@ -1,5 +1,6 @@ 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; @@ -21,25 +22,26 @@ public class StudentRecordAIFacade { private final MemberService memberService; - private final StudentRecordService studentRecordService; 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 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(studentRecord.getStudentRecordType(), 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..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 @@ -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,6 +42,7 @@ 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); 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..729e2958 --- /dev/null +++ b/edukit-core/src/main/java/com/edukit/core/studentrecord/aop/StudentRecordMetricsAspect.java @@ -0,0 +1,83 @@ +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.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; +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; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class StudentRecordMetricsAspect { + + private final StudentRecordMetricsCounter metricsService; + private final RecordGenerationTracker recordGenerationTracker; + private final StudentRecordService studentRecordService; + + @AfterReturning(pointcut = "@annotation(com.edukit.common.annotation.StudentRecordMetrics)") + public void collectCompletionMetrics(final JoinPoint joinPoint) { + Object[] args = joinPoint.getArgs(); + + if (args.length == 3) { + long memberId = (Long) args[0]; + long recordId = (Long) args[1]; + String description = (String) args[2]; + + StudentRecord studentRecord = studentRecordService.getRecordDetail(memberId, recordId); + StudentRecordType recordType = studentRecord.getStudentRecordType(); + + try { + metricsService.recordCompletion(recordType, description); + } catch (Exception e) { + // ๋ฉ”ํŠธ๋ฆญ ์ˆ˜์ง‘ ์‹คํŒจ๋Š” ๋กœ๊ทธ๋งŒ ๋‚จ๊ธฐ๊ณ  ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์€ ๊ณ„์† ์ง„ํ–‰ + log.warn("Error collecting completion metrics for recordType: {}", recordType, e); + } + } + } + + @Around("@annotation(com.edukit.common.annotation.AIGenerationMetrics)") + public Object collectAIGenerationMetrics(final ProceedingJoinPoint joinPoint) throws Throwable { + Object[] args = joinPoint.getArgs(); + + if (args.length == 4) { + long memberId = (Long) args[0]; + long recordId = (Long) args[1]; + + StudentRecord studentRecord = studentRecordService.getRecordDetail(memberId, recordId); + StudentRecordType recordType = studentRecord.getStudentRecordType(); + + try { + boolean isFirstGeneration = recordGenerationTracker.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/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/StudentRecordMetricsCounter.java b/edukit-core/src/main/java/com/edukit/core/studentrecord/service/StudentRecordMetricsCounter.java new file mode 100644 index 00000000..1ff99e06 --- /dev/null +++ b/edukit-core/src/main/java/com/edukit/core/studentrecord/service/StudentRecordMetricsCounter.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 StudentRecordMetricsCounter { + + 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; + } +} 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: